Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

New data model permissions #29

Merged
merged 11 commits into from
Dec 18, 2023
Merged
Show file tree
Hide file tree
Changes from 10 commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
9 changes: 0 additions & 9 deletions Pipfile
Original file line number Diff line number Diff line change
Expand Up @@ -13,15 +13,6 @@ django-cors-headers = "==4.1.0"
pydantic = "==1.10.7"
flask = "==2.2.3"
pyotp = "==2.9.0"
importlib-metadata = "==4.13.0" # TODO: remove. needed by old portal
django-formtools = "==2.2" # TODO: remove. needed by old portal
django-otp = "==1.0.2" # TODO: remove. needed by old portal
# https://pypi.org/user/codeforlife/
cfl-common = "==6.37.1" # TODO: remove
codeforlife-portal = "==6.37.1" # TODO: remove
aimmo = "==2.10.6" # TODO: remove
rapid-router = "==5.11.3" # TODO: remove
phonenumbers = "==8.12.12" # TODO: remove

[dev-packages]
black = "==23.1.0"
Expand Down
828 changes: 29 additions & 799 deletions Pipfile.lock

Large diffs are not rendered by default.

14 changes: 11 additions & 3 deletions codeforlife/models/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -88,29 +88,37 @@ def get_queryset(self):
)

def filter(self, *args, **kwargs):
"""A stub that return our custom queryset."""
"""A stub that returns our custom queryset."""

return t.cast(
WarehouseModel.QuerySet,
super().filter(*args, **kwargs),
)

def exclude(self, *args, **kwargs):
"""A stub that return our custom queryset."""
"""A stub that returns our custom queryset."""

return t.cast(
WarehouseModel.QuerySet,
super().exclude(*args, **kwargs),
)

def all(self):
"""A stub that return our custom queryset."""
"""A stub that returns our custom queryset."""

return t.cast(
WarehouseModel.QuerySet,
super().all(),
)

def none(self):
"""A stub that returns our custom queryset."""

return t.cast(
WarehouseModel.QuerySet,
super().none(),
)

objects: Manager = Manager()

# Default for how long to wait before a model is deleted.
Expand Down
6 changes: 6 additions & 0 deletions codeforlife/permissions/__init__.py
Original file line number Diff line number Diff line change
@@ -1 +1,7 @@
"""
© Ocado Group
Created on 14/12/2023 at 14:04:57(+00:00).
"""

from .is_cron_request_from_google import IsCronRequestFromGoogle
from .is_self import IsSelf
26 changes: 26 additions & 0 deletions codeforlife/permissions/is_self.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,26 @@
"""
© Ocado Group
Created on 12/12/2023 at 15:08:08(+00:00).
"""

from rest_framework.permissions import BasePermission
from rest_framework.request import Request
from rest_framework.views import APIView


class IsSelf(BasePermission):
"""Request's user must be the selected user."""

def __init__(self, keyword: str = "pk"):
"""Initialize permission.

Args:
keyword: The key for the url kwargs that contains the user's primary
key.
"""

super().__init__()
self.keyword = keyword

def has_permission(self, request: Request, view: APIView):
return request.user.pk == view.kwargs[self.keyword]
18 changes: 12 additions & 6 deletions codeforlife/tests/api.py
Original file line number Diff line number Diff line change
Expand Up @@ -38,8 +38,9 @@ def generic(
status_code_assertion: StatusCodeAssertion = None,
**extra,
):
wsgi_response = super().generic(
method, path, data, content_type, secure, **extra
response = t.cast(
Response,
super().generic(method, path, data, content_type, secure, **extra),
)

# Use a custom kwarg to handle the common case of checking the
Expand All @@ -49,13 +50,18 @@ def generic(
elif isinstance(status_code_assertion, int):
expected_status_code = status_code_assertion
status_code_assertion = (
lambda status_code: status_code == expected_status_code
# pylint: disable-next=unnecessary-lambda-assignment
lambda status_code: status_code
== expected_status_code
)

# pylint: disable=no-member
assert status_code_assertion(
wsgi_response.status_code
), f"Unexpected status code: {wsgi_response.status_code}."
response.status_code
), f"Unexpected status code: {response.status_code}."
# pylint: enable=no-member

return wsgi_response
return response

def login(self, **credentials):
assert super().login(
Expand Down
14 changes: 9 additions & 5 deletions codeforlife/user/auth/backends/email_and_password.py
Original file line number Diff line number Diff line change
@@ -1,31 +1,35 @@
import typing as t

from django.contrib.auth.backends import BaseBackend
from django.http.request import HttpRequest

from ....request import WSGIRequest
from ...models import User


class EmailAndPasswordBackend(BaseBackend):
"""Authenticates if the password belongs to the anon user's email."""

def authenticate(
self,
request: WSGIRequest,
request: t.Optional[HttpRequest],
email: t.Optional[str] = None,
password: t.Optional[str] = None,
**kwargs
):
if email is None or password is None:
return
return None

try:
user = User.objects.get(email=email)
if user.check_password(password):
return user
except User.DoesNotExist:
return
return None

return None

def get_user(self, user_id: int):
try:
return User.objects.get(id=user_id)
except User.DoesNotExist:
return
return None
15 changes: 10 additions & 5 deletions codeforlife/user/auth/backends/otp_bypass_token.py
Original file line number Diff line number Diff line change
@@ -1,26 +1,29 @@
import typing as t

from django.contrib.auth.backends import BaseBackend
from django.http.request import HttpRequest

from ....request import WSGIRequest
from ...models import AuthFactor, User


class OtpBypassTokenBackend(BaseBackend):
"""Authenticates if the OTP bypass token belongs to the identified user."""

def authenticate(
self,
request: WSGIRequest,
request: t.Optional[HttpRequest],
token: t.Optional[str] = None,
**kwargs,
):
if (
token is None
request is None
or token is None
or not isinstance(request.user, User)
or not request.user.session.session_auth_factors.filter(
auth_factor__type=AuthFactor.Type.OTP
).exists()
):
return
return None

for otp_bypass_token in request.user.otp_bypass_tokens.all():
if otp_bypass_token.check_token(token):
Expand All @@ -31,8 +34,10 @@ def authenticate(

return request.user

return None

def get_user(self, user_id: int):
try:
return User.objects.get(id=user_id)
except User.DoesNotExist:
return
return None
2 changes: 0 additions & 2 deletions codeforlife/user/auth/backends/user_id_and_login_id.py
Original file line number Diff line number Diff line change
@@ -1,7 +1,5 @@
import typing as t

from common.helpers.generators import get_hashed_login_id
from common.models import Student
from django.contrib.auth.backends import BaseBackend

from ....request import WSGIRequest
Expand Down
15 changes: 10 additions & 5 deletions codeforlife/user/fixtures/users.json
Original file line number Diff line number Diff line change
Expand Up @@ -4,10 +4,11 @@
"pk": 1,
"fields": {
"last_saved_at": "2023-01-01 00:00:00.0+00:00",
"is_active": true,
"first_name": "John",
"last_name": "Doe",
"email": "[email protected]",
"password": "password",
"password": "pbkdf2_sha256$260000$a2nFLqpwD88sOeZ7wsQskW$WIJACyDluEJWKMPsO/jawrR0sHXHmYebDJoyUihKJxU=",
"teacher": 1
}
},
Expand All @@ -16,10 +17,11 @@
"pk": 2,
"fields": {
"last_saved_at": "2023-01-01 00:00:00.0+00:00",
"is_active": true,
"first_name": "Jane",
"last_name": "Doe",
"email": "[email protected]",
"password": "password",
"password": "pbkdf2_sha256$260000$a2nFLqpwD88sOeZ7wsQskW$WIJACyDluEJWKMPsO/jawrR0sHXHmYebDJoyUihKJxU=",
"teacher": 2
}
},
Expand All @@ -28,8 +30,9 @@
"pk": 3,
"fields": {
"last_saved_at": "2023-01-01 00:00:00.0+00:00",
"is_active": true,
"first_name": "SpongeBob",
"password": "password",
"password": "pbkdf2_sha256$260000$a2nFLqpwD88sOeZ7wsQskW$WIJACyDluEJWKMPsO/jawrR0sHXHmYebDJoyUihKJxU=",
"student": 1
}
},
Expand All @@ -38,8 +41,9 @@
"pk": 4,
"fields": {
"last_saved_at": "2023-01-01 00:00:00.0+00:00",
"is_active": true,
"first_name": "Patrick",
"password": "password",
"password": "pbkdf2_sha256$260000$a2nFLqpwD88sOeZ7wsQskW$WIJACyDluEJWKMPsO/jawrR0sHXHmYebDJoyUihKJxU=",
"student": 2
}
},
Expand All @@ -48,10 +52,11 @@
"pk": 5,
"fields": {
"last_saved_at": "2023-01-01 00:00:00.0+00:00",
"is_active": true,
"first_name": "Indiana",
"last_name": "Jones",
"email": "[email protected]",
"password": "password"
"password": "pbkdf2_sha256$260000$a2nFLqpwD88sOeZ7wsQskW$WIJACyDluEJWKMPsO/jawrR0sHXHmYebDJoyUihKJxU="
}
}
]
1 change: 1 addition & 0 deletions codeforlife/user/models/auth_factor.py
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,7 @@ class Type(models.TextChoices):

OTP = "otp", _("one-time password")

user_id: int
user: "_user.User" = models.ForeignKey( # type: ignore[assignment]
"user.User",
related_name="auth_factors",
Expand Down
2 changes: 2 additions & 0 deletions codeforlife/user/models/klass.py
Original file line number Diff line number Diff line change
Expand Up @@ -47,12 +47,14 @@ class Manager(WarehouseModel.Manager["Class"]):
],
)

teacher_id: int
teacher: "_teacher.Teacher" = models.ForeignKey( # type: ignore[assignment]
"user.Teacher",
related_name="classes",
on_delete=models.CASCADE,
)

school_id: int
school: "_school.School" = models.ForeignKey( # type: ignore[assignment]
"user.School",
related_name="classes",
Expand Down
1 change: 1 addition & 0 deletions codeforlife/user/models/otp_bypass_token.py
Original file line number Diff line number Diff line change
Expand Up @@ -77,6 +77,7 @@ def key(otp_bypass_token: OtpBypassToken):

objects: Manager = Manager()

user_id: int
user: "_user.User" = models.ForeignKey( # type: ignore[assignment]
"user.User",
related_name="otp_bypass_tokens",
Expand Down
1 change: 1 addition & 0 deletions codeforlife/user/models/session.py
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,7 @@ class Session(AbstractBaseSession):

session_auth_factors: QuerySet["_session_auth_factor.SessionAuthFactor"]

user_id: t.Optional[int]
user: t.Optional[
"_user.User"
] = models.OneToOneField( # type: ignore[assignment]
Expand Down
2 changes: 2 additions & 0 deletions codeforlife/user/models/session_auth_factor.py
Original file line number Diff line number Diff line change
Expand Up @@ -19,12 +19,14 @@ class SessionAuthFactor(models.Model):
pending, the user is not authenticated.
"""

session_id: int
session: "_session.Session" = models.ForeignKey( # type: ignore[assignment]
"user.Session",
related_name="session_auth_factors",
on_delete=models.CASCADE,
)

auth_factor_id: int
auth_factor: "_auth_factor.AuthFactor" = (
models.ForeignKey( # type: ignore[assignment]
"user.AuthFactor",
Expand Down
2 changes: 2 additions & 0 deletions codeforlife/user/models/student.py
Original file line number Diff line number Diff line change
Expand Up @@ -111,13 +111,15 @@ def bulk_create_users(

user: "_user.User"

school_id: int
school: "_school.School" = models.ForeignKey( # type: ignore[assignment]
"user.School",
related_name="students",
editable=False,
on_delete=models.CASCADE,
)

klass_id: str
klass: "_class.Class" = models.ForeignKey( # type: ignore[assignment]
"user.Class",
related_name="students",
Expand Down
1 change: 1 addition & 0 deletions codeforlife/user/models/teacher.py
Original file line number Diff line number Diff line change
Expand Up @@ -44,6 +44,7 @@ def create_user(self, teacher: t.Dict[str, t.Any], **fields):
user: "_user.User"
classes: QuerySet["_class.Class"]

school_id: t.Optional[int]
school: t.Optional[
"_school.School"
] = models.ForeignKey( # type: ignore[assignment]
Expand Down
7 changes: 6 additions & 1 deletion codeforlife/user/models/user.py
Original file line number Diff line number Diff line change
Expand Up @@ -173,6 +173,7 @@ def create_superuser(self, password: str, first_name: str, **fields):
),
)

teacher_id: t.Optional[int]
teacher: t.Optional[
"_teacher.Teacher"
] = models.OneToOneField( # type: ignore[assignment]
Expand All @@ -182,6 +183,7 @@ def create_superuser(self, password: str, first_name: str, **fields):
on_delete=models.CASCADE,
)

student_id: t.Optional[int]
student: t.Optional[
"_student.Student"
] = models.OneToOneField( # type: ignore[assignment]
Expand Down Expand Up @@ -261,7 +263,10 @@ def is_authenticated(self):
"""Check if the user has any pending auth factors."""

try:
return not self.session.session_auth_factors.exists()
return (
self.is_active
and not self.session.session_auth_factors.exists()
)
except _session.Session.DoesNotExist:
return False

Expand Down
Loading
Loading