Skip to content

Commit

Permalink
New data model permissions (#29)
Browse files Browse the repository at this point in the history
* add foreign key id fields

* add permissions

* support checking admin teachers

* replace permissions

* remove unused requirements and fix test discovery

* fix unit tests

* add module docstrings

* pylint disable

* create unit tests

* feedback
  • Loading branch information
SKairinos authored Dec 18, 2023
1 parent 8e30eda commit c5c11c2
Show file tree
Hide file tree
Showing 39 changed files with 783 additions and 990 deletions.
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

0 comments on commit c5c11c2

Please sign in to comment.