RBAC and permission architecture
Roles and Permissions documents the 5-role model a workspace admin configures and the matrix of what each role can do. This page is the other side of that: how the permission classes are actually built in code, why there are several layers rather than one, and how a role check made in one place (a REST permission class) ends up expressed consistently in three others (the response body, the WebSocket gate, and the client UI) without being re-implemented in each.
The layering: membership, then threshold, then predicate
Section titled “The layering: membership, then threshold, then predicate”Every RBAC permission class lives in one file,
apps/access/permissions.py, and the design reads as three tiers stacked on top
of each other. Each tier answers a narrower question than the one below it.
Tier 0 — does the caller belong to this project at all? IsProjectMember (and
its program-level mirror IsProgramMember) is the Viewer-and-above read gate.
On a nested route (/projects/{id}/tasks/) it runs in has_permission, before a
single row is fetched, so a non-member’s list request never reaches the
queryset. Underneath every membership check sits ProjectScopedViewSet, a
mixin every project-scoped viewset inherits:
class ProjectScopedViewSet(IdempotencyMixin, viewsets.GenericViewSet): def get_queryset(self) -> QuerySet[Any]: qs = super().get_queryset() user = getattr(self.request, "user", None) if user is None or not user.is_authenticated: return qs.none() member_project_ids = ProjectMembership.objects.filter( user=user, is_deleted=False, ).values_list("project_id", flat=True) ... return qs.filter(project_id__in=member_project_ids)This is the mechanism Roles and Permissions calls IDOR prevention: a non-member’s request resolves against an empty queryset, so a detail request 404s exactly as it would for an object that does not exist. The requester learns nothing about whether the project is real. This “existence oracle” framing recurs through the RBAC design — closing it is what lets a later tier return a richer, structured error without leaking anything a plain 404 wouldn’t have.
Tier 1 — does the caller’s role clear a threshold? IsProjectMemberWrite
(Member+), IsProjectScheduler (Scheduler+), IsProjectAdmin (Admin+), and
IsProjectOwner (Owner, exactly) each compare the caller’s ProjectMembership.role
against a Role ordinal. Two comparison styles appear, and the choice between
them is a real design decision, not a style preference:
role >= Role.X— “at least this band.” A future Enterprise custom role registered inside a band (say, ordinal 250, between Scheduler and Admin) inherits this check automatically, with no OSS code change.role == Role.X— “specifically this OSS tier.” The last-Owner guard and a handful of Owner-only actions use this deliberately; a custom role must never silently absorb an Owner-only capability just by sitting near it.
See Roles and Permissions
for the ordinal bands themselves; the point here is that the comparison
operator, not just the ordinal value, is part of the contract. A raw integer
comparison (role < 1) is treated as a defect wherever it’s found — it hard-codes
today’s spacing into a permission check that is supposed to survive a future
re-spacing. ADR-0072
is the ADR that formalizes this contract, and its Amendment 1 is the reason the
Viewer ordinal is 1, not 0 — a falsy JSON 0 in a client’s role || DEFAULT
would otherwise read a Viewer as “no role” and silently grant the default.
Tier 2 — does the caller’s role, plus something about this object, clear the
bar? Role thresholds alone can’t express “a Member may edit their own assigned
task but no one else’s,” or “a Product Owner may edit an EPIC without holding
Admin.” Those rules live in shared predicate functions that a permission class
calls from has_object_permission, and — this is the part worth dwelling on —
the same function backs the client-visible capability flags on the API
response. See the next section.
A composed viewset typically stacks all three tiers per action rather than picking one:
class TaskViewSet(...): def _rbac_permissions(self) -> list[BasePermission]: if self.action in ("update", "partial_update", "destroy", "restore"): return [IsAuthenticated(), IsProjectMemberWriteOrOwn(), IsProjectNotArchived()] if self.action == "create": return [IsAuthenticated(), IsProjectMemberWrite(), IsProjectNotArchived()] ... return [IsAuthenticated(), IsProjectMember(), IsProjectNotArchived()]IsProjectNotArchived is a fourth kind of gate — a lifecycle check, not a role
check — appended alongside the role gate on every action except the ones that
manage archival itself. It composes additively: a role that would otherwise
pass is still blocked while the project is archived.
The shared predicate: one rule, enforced and declared from the same place
Section titled “The shared predicate: one rule, enforced and declared from the same place”can_user_edit_task(request, task, method) is the authoritative “may this user
write this task” rule. It is not merely called by the permission class that
enforces the write — it is also called by the serializer to populate the
can_edit / can_delete fields Roles and
Permissions documents
from the client’s side:
def can_user_edit_task(request, task, *, method="PATCH") -> bool: role = _membership_role(request, task.project_id) if role is None: return False if role >= Role.ADMIN: return True if method != "DELETE" and task.type in (TaskType.EPIC, TaskType.STORY) \ and _is_product_owner(request, task.project_id): return True # PO facet: may edit, never delete if role == Role.SCHEDULER: return False # read-only on task content if role == Role.MEMBER: return task.assignee_id == request.user.pk return False # Viewer: no writesThis predicate is why the frontend never re-implements the rule. Before this
existed, the web client’s own approximation of “can I edit this task”
(role >= Member) drifted from the server in three separate ways — it missed
the Scheduler read-only carve-out, the Member-owns-this-task-only restriction,
and the Product Owner facet — so the client would show an edit control the
server then rejected. Fusing enforcement and declaration into one function
makes that class of drift structurally impossible: the client capability flag
and the server’s write decision are, by construction, the same boolean.
A sibling predicate, can_user_log_time, backs CanLogTime and can_log_time
the same way but deliberately diverges in scope — any Member+ may log time
against any task in their project, not only their own, because the log entry’s
user field is server-set and therefore already IDOR-safe without a
narrower gate.
The facet axis behind the Product Owner and Scrum Master carve-outs is a
TeamMembership boolean (is_product_owner, is_scrum_master) — orthogonal to
the role ordinal, not a rung on the role ladder. Roles and
Permissions
covers why that split exists from the persona side; the architectural point is
that can_user_edit_task and the backlog/scope-manager predicates are exactly
where role and facet are combined into one write decision.
WebSocket-connection enforcement
Section titled “WebSocket-connection enforcement”A REST permission class runs once, on a request. A WebSocket connection is long-lived, so the same role check has to run at connect time and then survive until the socket closes — including surviving a role change that happens while the socket is still open.
ProjectConsumer (the board/project real-time channel) and WorkshopConsumer
(live collaboration) both gate the initial connection identically:
role = await self._get_role(user, project_pk)if role is None or role < Role.MEMBER: await self.close(code=4003) returnA Viewer is rejected outright — real-time push is a Member+ affordance, not a Viewer one. ADR-0184 documents this as an intentional design decision, not an oversight the 0.3 RBAC audit happened to leave unfixed: a Viewer’s REST reads are a point-in-time snapshot they can always refresh, and admitting Viewers to the realtime channel (presence visibility, every board event) is a separate design question — about per-event read-gating parity and connection-count cost — the ADR explicitly defers rather than folds in as a side effect.
Membership is checked once, at connect. To keep a role change effective
immediately rather than “whenever this socket happens to reconnect,”
ProjectConsumer also listens for a connection.evict channel-layer message
and force-closes (code 4003) any live socket belonging to a user whose
membership was just demoted below Member or removed — the same authorization
boundary the REST layer enforces per-request, re-applied to a connection that
REST’s request/response model doesn’t otherwise touch.
Close codes carry meaning: 4001 is “no valid credential” (see Auth architecture for how that credential is minted and validated); 4003 is “authenticated, but role below Member”; 4004 is Workshop-specific (“no active session”).
Defense-in-depth: why the same rule sometimes appears twice
Section titled “Defense-in-depth: why the same rule sometimes appears twice”ADR-0184 is also the record of a deliberate redundancy the 0.3 rbac-check
audit introduced on purpose. Several endpoints — project/program membership
management, cross-project slip-conflict acknowledgment — enforced their real
role gate only inside the view body (_require_actor_role(OWNER) and
similar), with [IsAuthenticated, IsProjectMember] at the DRF permission layer.
Every path still failed closed; the gap was one of visibility, not safety —
a body-only check is invisible to an OpenAPI security-scheme audit and to a
reviewer scanning permission_classes.
The fix layers a second, declarative expression of the same rule at the permission-class level, without removing the body check:
ProjectMembershipViewSetgainsIsProjectOwneroncreate/partial_update— but pointedly not ondestroy, because any member may remove themselves, and the last-Owner guard (aSELECT FOR UPDATE-protected invariant, see below) is what actually prevents a project from being left ownerless.CrossProjectSlipConflictViewSet.acknowledgegains a purpose-builtIsTaskScopeManager, created because the ADR needed a scope-manager class that resolves its project id by following ataskforeign key — the existingIsProjectScopeManageronly followsproject/project_id/predecessorand would have resolvedNone(denying everyone) on a task-keyed object.
One endpoint, SprintScopeChangeViewSet.accept/reject, deliberately keeps its
gate body-only. Its service layer returns a structured {"code": "scope_accept_forbidden", ...} 403 body the frontend depends on; a permission
class would pre-empt the body and return a generic {"detail": ...} 403
instead, breaking that contract for no security gain — the existence oracle is
already closed by the member-scoped queryset, so only a member below the
scope-manager bar ever reaches the structured 403, never a non-member probing
for object existence.
The general shape — a body-level check that carries an invariant a permission
class cannot express (an atomic SELECT FOR UPDATE guard, a structured error
contract, an assign-below-your-own-role rule) stays in the body; a permission
class is added alongside it when the same rule should also be visible at the
DRF/OpenAPI layer — is the pattern to reach for the next time a new write path
needs this kind of audit-visible enforcement.
Where to go next
Section titled “Where to go next”- Roles and Permissions — the role table, the ordinal bands, and the capability matrix this page’s permission classes enforce.
- Auth architecture — how a caller’s identity and the WebSocket ticket that gates a socket connection are established in the first place.
- Architecture Decision Records — ADR-0072 (role ordinals), ADR-0184 (defense-in-depth and the Viewer WS decision), and ADR-0133 (server-derived task capabilities) are the primary records behind this page.