- tests/test_security.py: 30 security regression tests covering secure headers, CSP directives, middleware ordering, DRF throttle configuration, and SecurityAuditMiddleware event-detection logic - tests/test_upload.py: 19 upload defense tests covering extension allow-list, byte-length limits, and magic-byte signature validation (polyglot / disguised executable detection) - pytest.ini: register 'security' and 'upload' markers (--strict-markers enforcement was already on) Security settings already committed in feat(TRA-233) via harness include: SECURE_REFERRER_POLICY, CSP_* directives, DEFAULT_THROTTLE_*, MAX_UPLOAD_SIZE, SESSION/CSRF cookie hardening, AWS presigned URL policy, and SecurityAuditMiddleware with dual-logger (access + security) pattern. Co-Authored-By: Paperclip <noreply@paperclip.ing>
12 KiB
TRA-235 Object-Level Authorization Matrix
Date: 2026-05-07
Issue: TRA-235
Owner: Senior Django Backend Engineer
Status: Enforced (see api/permissions.py)
1) Roles
| Slug | Description |
|---|---|
learner |
End-user consuming assigned courses and training. |
trainer |
Instructor managing attendance/signoff for assigned sessions. |
author |
Content creator managing courses, quizzes, and CMS assets. |
manager |
Org-scoped oversight: reporting, enrollment management, exports. |
admin |
Full tenant administration, policy overrides. |
Role bindings are org-scoped (UserRoleBinding.org_id). A user may hold
different roles in different orgs simultaneously. Admins bypass org-scope
restrictions for tenant administration actions.
2) Status Codes for Access Failures
| Condition | Code |
|---|---|
| Unauthenticated (no valid session/token) | 401 Unauthorized |
| Authenticated but insufficient role | 403 Forbidden |
| Object exists but must be concealed per policy | 404 Not Found |
Rule: use 404 only when revealing the existence of the object would itself
be a security disclosure (e.g. a learner should not know whether an
unpublished course exists in an org they don't belong to). In all other
cases, return 403.
3) Object-Level Matrix
3.1 Courses (courses app)
| Object | Action | learner | trainer | author | manager | admin | Notes |
|---|---|---|---|---|---|---|---|
Course |
list | own enrolled & published | - | org-scoped | org-scoped | all | Learners see only enrolled/published courses |
Course |
retrieve | own enrolled & published | - | org-scoped | org-scoped | all | |
Course |
create | ✗ | ✗ | ✓ (org) | ✓ (org) | ✓ | |
Course |
update | ✗ | ✗ | ✓ (org) | ✓ (org) | ✓ | |
Course |
delete/archive | ✗ | ✗ | ✗ | ✓ (org) | ✓ | |
Module / Lesson / Page |
all | same as parent Course above | |||||
Enrollment |
create | ✗ | ✗ | ✗ | ✓ (org) | ✓ | Enrollment assignment |
Enrollment |
retrieve | own only | ✗ | ✗ | org-scoped | all | IsEnrollmentOwner |
Enrollment |
revoke | ✗ | ✗ | ✗ | ✓ (org) | ✓ |
Permission classes: IsContentEditor + IsCourseOrgScoped, IsEnrollmentOwner
3.2 CMS (cms app)
| Object | Action | learner | trainer | author | manager | admin | Notes |
|---|---|---|---|---|---|---|---|
Asset |
list | ✗ | ✗ | ✓ (org) | ✓ (org) | ✓ | |
Asset |
upload / presign | ✗ | ✗ | ✓ (org) | ✓ (org) | ✓ | |
Asset |
delete / takedown | ✗ | ✗ | ✗ | ✓ (org) | ✓ | |
PageContent |
read (published) | ✓ (via course delivery) | ✓ | ✓ (org) | ✓ (org) | ✓ | Learners access through course player endpoint only |
PageContent |
write | ✗ | ✗ | ✓ (org) | ✓ (org) | ✓ | |
Theme |
read | ✓ (resolved) | ✓ | ✓ (org) | ✓ (org) | ✓ | |
Theme |
write | ✗ | ✗ | ✓ (org) | ✓ (org) | ✓ |
Permission classes: IsContentEditor + IsAssetOrgScoped
3.3 Tracking (tracking app)
| Object | Action | learner | trainer | author | manager | admin | Notes |
|---|---|---|---|---|---|---|---|
TrackingEvent |
create (heartbeat/focus) | own enrollment only | ✗ | ✗ | ✗ | ✗ | Must own the enrollment |
PageProgress |
retrieve | own only | org-scoped | ✗ | org-scoped | all | IsOwnProgressOrPrivileged |
EnrollmentProgress |
retrieve | own only | org-scoped | ✗ | org-scoped | all | |
ProgressAuditEvent list |
✗ | ✗ | ✗ | ✓ (org) | ✓ (org) | ✓ | Privileged audit stream |
Permission classes: IsEnrollmentOwner (write), IsOwnProgressOrPrivileged (read), IsPrivileged (audit stream)
3.4 Quizzes (quizzes app)
| Object | Action | learner | trainer | author | manager | admin | Notes |
|---|---|---|---|---|---|---|---|
Quiz |
list / retrieve | published only | org-scoped | org-scoped | org-scoped | all | |
Quiz |
create / update | ✗ | ✗ | ✓ (org) | ✓ (org) | ✓ | |
Quiz |
archive | ✗ | ✗ | ✗ | ✓ (org) | ✓ | |
Question / Choice |
read | via published quiz | ✓ (org) | ✓ (org) | ✓ (org) | ✓ | |
Question / Choice |
write | ✗ | ✗ | ✓ (org) | ✓ (org) | ✓ | |
Attempt |
create (start) | own enrollment | ✗ | ✗ | ✗ | ✓ | Must be enrolled; respects attempt_limit |
Attempt |
retrieve | own only | org-scoped cohort | ✗ | org-scoped | all | IsAttemptOwner |
Attempt |
submit | own in-progress | ✗ | ✗ | ✗ | ✗ | Idempotent |
Attempt |
regrade | ✗ | ✗ | ✗ | ✓ (org) | ✓ | Privileged flow |
GradingDecisionAudit |
read | ✗ | ✗ | ✗ | ✓ (org) | ✓ |
Permission classes: IsAttemptOwner, IsContentEditor + IsQuizOrgScoped, IsPrivileged
3.5 Training (training app)
| Object | Action | learner | trainer | author | manager | admin | Notes |
|---|---|---|---|---|---|---|---|
TrainingSession |
list | own registered only | own sessions + org | ✗ | org-scoped | all | |
TrainingSession |
retrieve | own registration | ✓ (own/org) | ✗ | org-scoped | all | |
TrainingSession |
create / update | ✗ | ✓ (assigned org) | ✗ | ✓ (org) | ✓ | |
TrainingSession |
cancel | ✗ | ✗ | ✗ | ✓ (org) | ✓ | |
AttendanceEvent |
create | ✗ | ✓ (own session) | ✗ | ✓ (org) | ✓ | IsSessionTrainerOrPrivileged |
TrainerSignoff |
create | ✗ | ✓ (own session) | ✗ | ✓ (org) | ✓ | Immutable once written; requires audit trail |
TrainingCompletionDecision |
read | own only | org-scoped | ✗ | org-scoped | all |
Permission classes: IsSessionTrainerOrPrivileged, IsAtLeastManager + IsOrgScopedObject
3.6 Certificates (certificates app)
| Object | Action | learner | trainer | author | manager | admin | Notes |
|---|---|---|---|---|---|---|---|
Certificate |
issue (trigger) | ✗ | ✗ | ✗ | ✓ (org) | ✓ | Requires TrainingCompletionDecision == eligible |
Certificate |
list | own only | ✗ | ✗ | org-scoped | all | IsCertificateOwner |
Certificate |
retrieve | own only | ✗ | ✗ | org-scoped | all | |
Certificate |
revoke | ✗ | ✗ | ✗ | ✓ (org) | ✓ | |
Verify GET /verify/{hash}/ |
public | ✓ | ✓ | ✓ | ✓ | ✓ | No auth required; minimal public response |
CertificateRenderJob |
read | ✗ | ✗ | ✗ | ✓ (org) | ✓ |
Permission classes: IsCertificateOwner, IsAtLeastManager + IsOrgScopedObject, IsCertificateVerifyPublic
3.7 Reports (reports app)
| Object | Action | learner | trainer | author | manager | admin | Notes |
|---|---|---|---|---|---|---|---|
| Progress / Attempt / Completion reports | list | ✗ | ✗ | ✗ | ✓ (org) | ✓ | IsReportOrgScoped |
| CSV export trigger | ✗ | ✗ | ✗ | ✓ (org) | ✓ | Actor-scoped data only | |
AuditExportJob |
request | ✗ | ✗ | ✗ | limited scope | ✓ | Manager: own-org approved policy scopes only |
AuditExportJob |
retrieve / download | ✗ | ✗ | ✗ | ✓ (own jobs) | ✓ | Access log mandatory |
AuditExportJob |
revoke | ✗ | ✗ | ✗ | ✓ (own jobs) | ✓ |
Permission classes: IsAtLeastManager + IsReportOrgScoped, IsPrivileged
3.8 Notifications (notifications app)
| Object | Action | learner | trainer | author | manager | admin | Notes |
|---|---|---|---|---|---|---|---|
Notification |
list | own only | own only | own only | own only | own only | All roles read own |
Notification |
mark read | own only | own only | own only | own only | own only | IsOwnNotification |
NotificationDelivery telemetry |
✗ | ✗ | ✗ | ✓ (org) | ✓ (org) | ✓ | Admin/Manager privileged |
Permission classes: IsOwnNotification, IsPrivileged
4) Permission Class Reference
| Class | Location | Use case |
|---|---|---|
IsLearner |
api/permissions.py |
Restrict to learner role at view level |
IsTrainer |
api/permissions.py |
Restrict to trainer role |
IsAuthor |
api/permissions.py |
Restrict to author role |
IsManager |
api/permissions.py |
Restrict to manager role |
IsAdmin |
api/permissions.py |
Restrict to admin role |
IsAtLeastTrainer |
api/permissions.py |
Trainer, Author, Manager, Admin |
IsAtLeastManager |
api/permissions.py |
Manager or Admin |
IsPrivileged |
api/permissions.py |
Manager or Admin (privileged endpoints) |
IsContentEditor |
api/permissions.py |
Author, Manager, Admin (content mutation) |
IsEnrollmentOwner |
api/permissions.py |
Learner owns enrollment; managers bypass |
IsAttemptOwner |
api/permissions.py |
Learner owns attempt; managers bypass |
IsCertificateOwner |
api/permissions.py |
Learner owns certificate; managers bypass |
IsOwnNotification |
api/permissions.py |
User owns notification (no bypass) |
IsOwnProgressOrPrivileged |
api/permissions.py |
Own progress or trainer/manager/admin |
IsOrgScopedObject |
api/permissions.py |
Object org_id in user's role-binding orgs |
IsCourseOrgScoped |
api/permissions.py |
Course/Module/Lesson/Page org scope |
IsAssetOrgScoped |
api/permissions.py |
CMS Asset org scope |
IsQuizOrgScoped |
api/permissions.py |
Quiz org scope |
IsReportOrgScoped |
api/permissions.py |
ReportSnapshot org scope |
IsSessionTrainerOrPrivileged |
api/permissions.py |
Trainer owns session; manager/admin bypass |
IsCertificateVerifyPublic |
api/permissions.py |
Public GET for cert verification hash |
IsReadOnlyOrPrivileged |
api/permissions.py |
Safe methods for all; mutations for privileged |
IsLearnerOwnerOrPrivileged |
api/permissions.py |
Composite owner-or-privileged check |
5) Enforcement Rules
-
Default closed:
REST_FRAMEWORK["DEFAULT_PERMISSION_CLASSES"] = [IsAuthenticated]is already set inconfig/settings/base.py. Every view must override with a more restrictive class — never loosen toAllowAnywithout explicit@api_viewdecorator and justification comment. -
Object-level checks:
GenericAPIView.get_object()callscheck_object_permissions()automatically. Views usingget_queryset()+ manual filtering must callself.check_object_permissions(request, obj)explicitly when returning objects by PK. -
Queryset pre-filtering: Object-level permission classes are the last line of defense. Views must also filter querysets to the user's org scope so list endpoints don't expose row counts for unauthorized orgs (even when pagination conceals the data).
-
Concealment policy: For learner-facing list endpoints, filter the queryset to exclude inaccessible objects (return
404on direct PK lookup rather than403) when the object's existence is org-private. -
Audit log: All privileged accesses (audit export download, delivery telemetry, regrade) must write an access log entry (see
AuditExportAccessLog,CertificateVerificationAudit).
6) Test Coverage Requirement
Per TRA-253 §8.3, CI must fail when permission tests are missing for changed
endpoints. Minimum required per endpoint:
- One passing test per role class (learner / trainer / author / manager / admin).
- One owner test (owner should pass, non-owner should fail).
- One unauthenticated test (must return
401).
Test file: tests/test_permissions.py
7) Out of Scope
- Enterprise ABAC policy engine (dynamic attribute-based rules).
- Field-level read/write visibility beyond serializer include/exclude.
- Row-level security at the database layer (Postgres RLS).