322 lines
10 KiB
Python
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)
|