Skip to content

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 archival exception is scoped to ProjectViewSet, not to the four action names. archive, unarchive, restore and destroy bypass the check on the project row itself, because an archived project would otherwise be unable to unarchive itself — a catch-22. They do not bypass it anywhere else: destroy is minted by the router on every ModelViewSet, so matching the name alone exempted the delete route of every project-scoped viewset in the API from the read-only contract. Deleting a task, risk, comment, attachment, label, phase, baseline, membership or assignment in an archived project is an ordinary write and is refused like any other.

Naming the project kwarg: the fail-open a route inherits by accident

Section titled “Naming the project kwarg: the fail-open a route inherits by accident”

Tier 0 only runs if the permission layer can find the project, and it finds it by reading one URL kwarg by name:

def _project_pk_from_view(view: APIView) -> Any | None:
declared = getattr(view, "project_url_kwarg", None)
kwarg = declared if isinstance(declared, str) else DEFAULT_PROJECT_URL_KWARG
...

Every project-scoped class then follows the same shape — enforce if the project resolved, and otherwise return True, because a genuinely top-level route (/api/v1/dependencies/) has no project in its URL and must fall through to the object-level check.

That fallthrough is the trap. A route declared projects/<pk>/… rather than projects/<project_pk>/… resolves nothing, so the class returns True for everyone — while still sitting in permission_classes and reading as a live gate to anyone reviewing the view. Three separate issues turned out to share this one root cause, which is what makes it a pattern rather than an incident.

So a view whose route spells it differently says so:

class ProjectOverviewView(APIView):
project_url_kwarg = "pk" # route is projects/<pk>/overview/
permission_classes = [IsAuthenticated, IsProjectMember]

Two things that look like simpler fixes, and why neither is one:

  • Do not alias pk globally. pk names the project on projects/<pk>/… and names something else entirely everywhere else — a dependency on /dependencies/<pk>/, a task on /tasks/<pk>/. A blanket alias would hand those ids to the membership lookup, find no membership, and deny every legitimate request on those routes. Guessing fails worse than the fail-open it replaces: a silent no-op becomes a live outage.
  • Do not make an unresolved project fail closed. Top-level routes legitimately resolve nothing, and denying them would break the object-level tier that is supposed to handle them.

Because neither the resolver nor the type system can tell an intentionally top-level route from a mistyped one, the guard is a route-table test rather than a runtime rule: tests/apps/access/test_route_table_invariants.py walks the live URL resolver and asserts that every route naming a project enforces access by some path — the declared kwarg, a ViewSet’s object check, or an explicit call in the view body — and it names the routes in that last category so removing one is a failing test rather than a silent downgrade.

The membership invariant is scoped to routes whose URL names a project, which is the right scope for the kwarg defect and the wrong one for IsProjectNotArchived: most routes that skipped the archived check are top-level, so they fell outside what it inspects. A second invariant in the same file therefore enumerates every project-scoped write route — discovered from the permissions the view really applies at runtime, not from a source grep — and asserts each one enforces archived state on one of four paths, or carries an archived_write_exempt = "<reason>" attribute saying in a sentence why the write must survive archiving. The exemption lives on the view rather than in a list, so it travels with the code it excuses.

A declared permission class only counts when it can actually fire. Three shapes defeat it, and all three shipped:

  • the project is named only in the request body, so has_permission resolves nothing and has_object_permission never runs (DRF does not call it on a create);
  • the route spells the project pk with no project_url_kwarg — the same fail-open as above, one layer down;
  • the object handed to has_object_permission has no relation the resolver walks, so it resolves None and returns True.

Where none of the declarative paths can work, the check goes in the view body as assert_project_not_archived(project_id), which raises the same refusal with the same message from wherever it is called.

One deliberate wrinkle: when a declared route is given an id that matches no live project, the permission layer stands down so the view’s own get_object_or_404 answers. Without that, an unknown id would start returning 403 where the published schema documents 404. Note the consequence — an existing-but-forbidden project answers 403 while an unknown one answers 404, so the two remain distinguishable here. That is deliberate scope, not an oversight: ProjectScopedViewSet above takes the opposite approach and hides existence behind an empty queryset, and reconciling the two conventions is an API-visible decision rather than something to settle inside a resolver fix.

The role dimension: a checked-in refusal matrix

Section titled “The role dimension: a checked-in refusal matrix”

Both invariants above are binary — access is enforced by some path, the archived contract is kept on some path. Neither asks which of the five roles gets in, and that is the larger dimension: an endpoint can be correctly gated to members and still admit a Viewer to a write.

tests/apps/access/test_role_route_matrix.py closes that gap by enumerating it. For every (method, route, action) the URL resolver serves, it instantiates the view, binds the action, calls get_permissions() — the permissions the view really applies, not the ones its class attribute lists, because many viewsets hand-roll that method — and runs each returned permission’s has_permission for seven callers. The seven verdicts become a seven-character mask, and every mask is checked in to role_route_matrix.txt beside the test:

# anon non-member Viewer Member Scheduler Admin Owner
--+++++ GET api/v1/projects/<project_pk>/task-runs/::list
-----++ DELETE api/v1/projects/<project_pk>/phases/<pk>/::destroy
------+ PATCH api/v1/projects/<uuid:project_pk>/members/<uuid:pk>/::partial_update

+ means the caller gets past the entry gate. - means they are refused — by a permission class, by the authenticator (a view that accepts only a project API token sees an anonymous request from every human caller and reads -------), or because the route is scoped to something the fixture does not grant, such as a team or a workspace role. So ------- is not a claim that a route is maximally locked down; it is a claim that these seven callers do not get in. A route with no line fails — absence is the defect, so it cannot be the default-pass — and a route whose mask changes fails with both masks in the message. That second case is the one that matters: a - becoming + is a role gate that stopped firing, and it is invisible to a test suite written one role at a time, because the author’s own account still works.

Three properties are asserted on top of the file, each with an opt-out that has to be a sentence somebody wrote:

  • No project- or program-scoped route is reachable anonymously. Unlike the non-member column, there is no object-level check that can rescue an anonymous request, so a + here is a finding on its face.
  • A route whose project kwarg resolves must refuse a non-member. Scoped to routes where the resolver has a live project id in hand — “the check could not run” is not available as an explanation there.
  • The role bands are upward-closed. If a route admits Member it must admit Scheduler, Admin and Owner. An inversion is either a deliberate facet rule (the task-authoring endpoints, where the resource-management band is read-only on task content) or a reversed comparison.

A view opts out of the first two with role_gate_exempt = "<reason>", which lives on the view for the same reason archived_write_exempt does — a reason in a list keeps passing after the code it excuses has been rewritten into something else.

Read the scope honestly. This oracle covers the entry gate, has_permission, which is the one layer whose verdict is a property of the route and the role alone. It says nothing about has_object_permission, about role checks written into a view body, or about what a serializer chooses to emit. Two of those are where several past findings actually lived, and they are covered by the invariants above and by ordinary tests — not by a green run here. What a green run does claim, across the whole enumerated surface, is that nobody’s entry-gate verdict changed.

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 writes

This 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.

The facet is a widening of project access, never a substitute for it, and that takes an explicit check rather than following from the data model. A signal mirrors each ProjectMembership onto the project’s default team, but the mirror is create-only — there is no delete-side counterpart, deliberately, so that re-inviting someone restores their facets instead of making offboarding destroy them. The consequence is that revoking project access leaves a residual TeamMembership behind with its flags intact, and every facet gate consults the facet on precisely the branch where the role lookup returned None. So the resolvers in apps/teams/services.py apply the liveness floor themselves:

def user_facets(user, project_id, *, live_project_members_only=True):
...

The single-user resolvers (user_facets, has_team_facet) require a live, non-soft-deleted ProjectMembership on the same project, as a correlated subquery inside the existing query rather than a second round trip. The floor defaults to on; the opt-out exists only for callers that have already read live membership and intersect it themselves. Putting it in the resolver rather than in each gate is the point — a future gate, management command, or MCP write path inherits the rule instead of having to remember it.

The set-shaped resolver facet_holder_user_ids answers the same question for a whole project at once, to build notification cohorts rather than to decide writes. The two are meant to agree — a cohort that silently includes someone an authorization gate would refuse is how a project’s task and sprint names reach a person who can no longer open it. If you are adding a facet cohort, check which guarantee the resolver you are calling actually gives, and intersect live ProjectMembership in the cohort itself if you need one it does not.

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) gates the initial connection like this:

role = await self._get_role(user, project_pk)
if role is None or role < Role.MEMBER:
await self.close(code=4003)
return

A 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); and 4003 is “authenticated, but role below Member”.

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:

  • ProjectMembershipViewSet gains IsProjectOwner on create/partial_update — but pointedly not on destroy, because any member may remove themselves, and the last-Owner guard (a SELECT FOR UPDATE-protected invariant, see below) is what actually prevents a project from being left ownerless.
  • CrossProjectSlipConflictViewSet.acknowledge gains a purpose-built IsTaskScopeManager, created because the ADR needed a scope-manager class that resolves its project id by following a task foreign key — the existing IsProjectScopeManager only follows project/project_id/predecessor and would have resolved None (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.

  • 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.