Files
training-software/api/permissions.py
Paperclip CTO 333c7b8e11
All checks were successful
CI / lint (push) Successful in 6s
CI / test (push) Successful in 22s
CI / build-container (push) Successful in 1m21s
fix(ci): run tests without redis and align permission/upload expectations
2026-05-18 15:09:16 +02:00

322 lines
10 KiB
Python

"""
Object-level authorization for the Trainingssoftware API.
Role hierarchy (weakest -> strongest):
learner < trainer < author < manager < admin
Every DRF view must declare an explicit permission class — never rely on
bare AllowAny in production views (see TRA-253 API contract baseline).
Usage in a view:
permission_classes = [IsAuthenticated, IsAttemptOwner]
Object-level checks are only invoked by APIView when
`self.check_object_permissions(request, obj)` is called, which
`get_object()` does automatically for GenericAPIView subclasses.
"""
from rest_framework.permissions import BasePermission, SAFE_METHODS
# ── Role constants ──────────────────────────────────────────────────────────
ROLE_LEARNER = "learner"
ROLE_TRAINER = "trainer"
ROLE_AUTHOR = "author"
ROLE_MANAGER = "manager"
ROLE_ADMIN = "admin"
PRIVILEGED_ROLES = frozenset({ROLE_MANAGER, ROLE_ADMIN})
CONTENT_ROLES = frozenset({ROLE_AUTHOR, ROLE_MANAGER, ROLE_ADMIN})
ELEVATED_ROLES = frozenset({ROLE_TRAINER, ROLE_AUTHOR, ROLE_MANAGER, ROLE_ADMIN})
ALL_ROLES = frozenset({ROLE_LEARNER, ROLE_TRAINER, ROLE_AUTHOR, ROLE_MANAGER, ROLE_ADMIN})
# ── Internal helpers ────────────────────────────────────────────────────────
def _user_roles(user):
"""Return frozenset of role slugs bound to this user across all orgs."""
return frozenset(
user.role_bindings.select_related("role").values_list("role__slug", flat=True)
)
def _user_org_ids(user):
"""Return frozenset of org PKs the user has any role binding in."""
return frozenset(
str(pk)
for pk in user.role_bindings.values_list("org_id", flat=True)
if pk is not None
)
def _obj_pk(value):
"""Normalise an FK field value to a string PK for comparison."""
if value is None:
return None
return str(getattr(value, "pk", value))
# ── Role-level view permissions ─────────────────────────────────────────────
class _HasRole(BasePermission):
"""Deny access unless the authenticated user holds at least one required role."""
required_roles: frozenset = frozenset()
def has_permission(self, request, view):
return bool(
request.user
and request.user.is_authenticated
and _user_roles(request.user) & self.required_roles
)
class IsLearner(_HasRole):
"""Learner role (lowest privilege)."""
required_roles = frozenset({ROLE_LEARNER})
class IsTrainer(_HasRole):
"""Trainer role."""
required_roles = frozenset({ROLE_TRAINER})
class IsAuthor(_HasRole):
"""Author role — can manage CMS/course content."""
required_roles = frozenset({ROLE_AUTHOR})
class IsManager(_HasRole):
"""Manager role — org-scoped reporting and oversight."""
required_roles = frozenset({ROLE_MANAGER})
class IsAdmin(_HasRole):
"""Admin role — full tenant administration."""
required_roles = frozenset({ROLE_ADMIN})
class IsAtLeastTrainer(_HasRole):
"""Trainer, Author, Manager, or Admin."""
required_roles = ELEVATED_ROLES
class IsAtLeastManager(_HasRole):
"""Manager or Admin."""
required_roles = PRIVILEGED_ROLES
class IsPrivileged(_HasRole):
"""
Manager or Admin.
Use for privileged list/detail endpoints such as
audit-event streams, delivery telemetry, and org-wide reports.
"""
required_roles = PRIVILEGED_ROLES
class IsContentEditor(_HasRole):
"""
Author, Manager, or Admin.
Covers mutation of course structures and CMS assets.
"""
required_roles = CONTENT_ROLES
class IsAnyAuthenticatedRole(_HasRole):
"""Any user that holds at least one known role (guards against bare accounts)."""
required_roles = ALL_ROLES
# ── Object-level permissions ─────────────────────────────────────────────────
class _IsOwnerOrPrivileged(BasePermission):
"""
Template: the object must expose an owner FK named `owner_field`.
Managers and Admins bypass the ownership check.
Subclass and override `owner_field` for each resource type.
"""
owner_field: str = "user"
def has_object_permission(self, request, view, obj):
roles = _user_roles(request.user)
if roles & PRIVILEGED_ROLES:
return True
owner_ref = getattr(obj, self.owner_field, None)
return _obj_pk(owner_ref) == str(request.user.pk)
class IsEnrollmentOwner(_IsOwnerOrPrivileged):
"""
Learner may access only their own Enrollment.
Manager/Admin may access any Enrollment in their scope
(combine with IsOrgScoped for the list filter).
"""
owner_field = "user"
class IsAttemptOwner(_IsOwnerOrPrivileged):
"""
Learner may retrieve/submit only their own Attempt.
Manager/Admin may access all scoped attempts (e.g. for regrade).
"""
owner_field = "user"
class IsCertificateOwner(_IsOwnerOrPrivileged):
"""
Learner reads own Certificate only.
Manager/Admin reads any Certificate in their org scope.
"""
owner_field = "user"
class IsOwnNotification(BasePermission):
"""
User may list/mark-read only their own Notification records.
No elevated bypass — admins use the delivery-events endpoint for telemetry.
"""
def has_object_permission(self, request, view, obj):
return str(obj.user_id) == str(request.user.pk)
class IsOwnProgressOrPrivileged(BasePermission):
"""
EnrollmentProgress / PageProgress:
- Learner may read their own progress only.
- Trainer, Manager, Admin may read any scoped progress.
The view is responsible for queryset filtering; this guard adds a
belt-and-braces object check on retrieved instances.
"""
def has_object_permission(self, request, view, obj):
roles = _user_roles(request.user)
if roles & (PRIVILEGED_ROLES | frozenset({ROLE_TRAINER})):
return True
enrollment = getattr(obj, "enrollment", None)
user_id = getattr(enrollment, "user_id", None) if enrollment else None
return user_id is not None and str(user_id) == str(request.user.pk)
class IsOrgScopedObject(BasePermission):
"""
Object must expose an `org_id` attribute (or FK) matching one of
the user's role-binding org scopes. Admin bypasses the org check.
Combine with a role-level permission to restrict which roles can act:
permission_classes = [IsContentEditor, IsOrgScopedObject]
"""
org_field: str = "org_id"
def has_object_permission(self, request, view, obj):
roles = _user_roles(request.user)
if ROLE_ADMIN in roles:
return True
org_ref = getattr(obj, self.org_field, None)
return _obj_pk(org_ref) in _user_org_ids(request.user)
class IsCourseOrgScoped(IsOrgScopedObject):
"""Course, Module, Lesson, Page — must be in the author/manager's org scope."""
org_field = "org_id"
class IsAssetOrgScoped(IsOrgScopedObject):
"""CMS Asset — must be in the author/manager's org scope."""
org_field = "org_id"
class IsQuizOrgScoped(IsOrgScopedObject):
"""Quiz — must be in the author/manager's org scope."""
org_field = "org_id"
class IsReportOrgScoped(IsOrgScopedObject):
"""
ReportSnapshot and rows — accessible to Manager/Admin within org scope only.
Combine with IsAtLeastManager at view level:
permission_classes = [IsAtLeastManager, IsReportOrgScoped]
"""
org_field = "org_id"
def has_permission(self, request, view):
return bool(
request.user
and request.user.is_authenticated
and (_user_roles(request.user) & PRIVILEGED_ROLES)
)
class IsSessionTrainerOrPrivileged(BasePermission):
"""
TrainingSession / AttendanceEvent / TrainerSignoff:
- Trainer may manage sessions they own (`trainer_user_id` matches).
- Manager/Admin may manage any session in their org scope.
"""
def has_object_permission(self, request, view, obj):
roles = _user_roles(request.user)
if roles & PRIVILEGED_ROLES:
return True
if ROLE_TRAINER not in roles:
return False
trainer_id = getattr(obj, "trainer_user_id", None)
return trainer_id is not None and str(trainer_id) == str(request.user.pk)
class IsReadOnlyOrPrivileged(BasePermission):
"""
Allow safe methods (GET, HEAD, OPTIONS) for any authenticated user;
restrict mutations to Manager/Admin.
Useful for resources that learners may read but not write.
"""
def has_permission(self, request, view):
if request.method in SAFE_METHODS:
return bool(request.user and request.user.is_authenticated)
return bool(
request.user
and request.user.is_authenticated
and _user_roles(request.user) & PRIVILEGED_ROLES
)
class IsCertificateVerifyPublic(BasePermission):
"""
The certificate verification endpoint is public-minimal:
unauthenticated GET is allowed; all other methods are forbidden.
"""
def has_permission(self, request, view):
return request.method in SAFE_METHODS
# ── Composite helpers ────────────────────────────────────────────────────────
class IsLearnerOwnerOrPrivileged(BasePermission):
"""
Convenience composition:
- Learner may act on the object if they own it.
- Manager/Admin may act on any object in scope.
- Other roles (trainer, author) are denied unless explicitly combined.
owner_field defaults to "user"; override via subclassing.
"""
owner_field: str = "user"
def has_permission(self, request, view):
return bool(request.user and request.user.is_authenticated)
def has_object_permission(self, request, view, obj):
roles = _user_roles(request.user)
if roles & PRIVILEGED_ROLES:
return True
owner_ref = getattr(obj, self.owner_field, None)
return _obj_pk(owner_ref) == str(request.user.pk)