""" 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)