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
pkglobally.pknames the project onprojects/<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 same guard, for the lifecycle gate
Section titled “The same guard, for the lifecycle gate”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_permissionresolves nothing andhas_object_permissionnever runs (DRF does not call it on a create); - the route spells the project
pkwith noproject_url_kwarg— the same fail-open as above, one layer down; - the object handed to
has_object_permissionhas no relation the resolver walks, so it resolvesNoneand returnsTrue.
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 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.
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.
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) 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) 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); 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:
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.