Permissions and roles¶
Authentication (covered in Authentication) establishes who is making a request. This page is about the separate question every view still has to answer for itself: given that identity, is this specific action on this specific object allowed? DRF's permission classes answer the first, coarse half of that; object-level checks inside the view answer the rest.
Roles¶
Profile.role (backend/common/models.py) is a two-value CharField:
common.utils.ROLES = (("ADMIN", "ADMIN"), ("USER", "USER")), defaulting to USER. That's the
role a person holds within one org: a user with profiles in two orgs can be ADMIN in one and
USER in the other, because a role lives on the Profile (the user-org membership row), not on
User itself.
A second, independent flag exists alongside it: Profile.is_organization_admin, a plain boolean,
exposed as the .is_admin property. It is genuinely a separate field from role, nothing
enforces that the two agree, and a fair amount of permission-checking code treats either one as
sufficient for "this person is an admin":
# common/permissions.py. IsOrgAdmin
return request.profile.role == "ADMIN" or request.profile.is_organization_admin
# cases/access.py
def is_org_admin(profile):
"""True for the two ways this codebase spells "admin"."""
return profile.role == "ADMIN" or bool(getattr(profile, "is_admin", False))
When you write a new admin check, check both, the way these two do, a check that only tests
role == "ADMIN" will silently deny someone whose admin status was granted only through
is_organization_admin, and vice versa.
Separately from any org role, IsSuperAdmin (backend/common/permissions.py) gates
platform-level access on User.is_superuser. An explicit flag set with manage.py
createsuperuser, the Django admin, or another audited path, never inferred from an email address
or domain. The class's own docstring explains why that matters: deriving super-admin status from
an email domain would hand platform-wide access (every org, every user) to anyone who can obtain
an account at that domain, turning an ordinary signup into vertical privilege escalation. This flag
is unrelated to any Profile.role; a User can be is_superuser with no org membership at all.
Permission classes¶
backend/common/permissions.py defines the building blocks every view composes:
HasOrgContext: requiresrequest.profileandrequest.orgto both be set (by the middleware described in Overview) andrequest.profile.is_activeto be true. This is the check that turns "the JWT didn't carry a valid org claim" into a clean 403 with the message "Organization context is required. Please login again." at the view layer, mirroring whatRequireOrgContextalready enforces at the middleware layer.IsOrgAdmin:role == "ADMIN"oris_organization_admin, as shown above.IsSuperAdmin:user.is_superuser, as shown above.
The standard combination on nearly every org-scoped view in this codebase is
For example CaseListView, CaseAttachmentView, and CaseCommentView in
backend/cases/views.py, or TagsListView in backend/common/views/tags_views.py. Notice what
that combination does not do: it doesn't check role, and it doesn't check which specific object
the request is about. IsAuthenticated + HasOrgContext gets you "a real, active member of this
org is making this request" and nothing more specific than that. Role checks
(request.profile.role == "ADMIN") and object-level checks are layered on inside the view methods
themselves: DRF's declarative permission_classes list doesn't see the object being acted on
until the view has already fetched it, so per-object authorization has to live in code, which is
what the next section covers.
Object-level checks¶
backend/cases/access.py is the clearest example in the codebase of object-level authorization
done deliberately, in one place, rather than re-implemented per view. Its own module docstring
explains the problem it fixes: before this module existed, "who may read/write/delete a case" was
answered independently in five different call sites, and had drifted into three different answers
by accident. A watcher could see their case in the list but got a 403 opening it, because the list
view's queryset included watchers and the detail view's permission check didn't. That's the
consequence of not centralizing this kind of check: verbs disagree with each other silently, and
the person who notices is a user hitting a wall the product didn't intend.
The module now states three explicit, different rules and enforces them from one place:
def is_org_admin(profile):
return profile.role == "ADMIN" or bool(getattr(profile, "is_admin", False))
def has_case_write_access(profile, case):
if is_org_admin(profile):
return True
if profile.user_id == case.created_by_id:
return True
return profile.id in {p.id for p in case.assigned_to.all()}
def assert_case_delete_access(profile, case):
"""Narrower than writing on purpose: an assignee is somebody the work was
handed to, which is a reason to let them work the ticket and not a reason
to let them erase it."""
if is_org_admin(profile):
return
if profile.user_id == case.created_by_id:
return
raise PermissionDenied(_DENIED)
Two things worth noticing in has_case_write_access: the comparison is profile.user_id ==
case.created_by_id, a User id compared to a User id, not profile == case.created_by.
Case.created_by (from common/mixins.py's AuditModel, which nearly every model inherits
transitively through BaseModel. See Data model for the few
exceptions) is a foreign key to User, not to Profile. Comparing a Profile instance directly to
a User foreign key is always False. A comparison that type-checks in Python but can never be
true, which quietly turns an intended "creator or admin" check into an "admin only" check with no
error anywhere.
backend/cases/views.py's own CaseAttachmentView.delete docstring documents two separate defects
having been live in that endpoint before the fix. An unscoped objects.get(pk=pk) lookup (no org
filter at all), and this same created_by type mismatch, and its closing line says only that "the
same one-line lookup bug is still open in leads, tasks and opportunity." That sentence is
about the missing-org-filter defect specifically, not the created_by comparison, and, checked
against the current source of all three apps rather than taken on the docstring's word. It's now
stale for one of the three:
leads(leads/views/lead_interactions.py). The unscoped lookup is still open (.objects .get(pk=pk)at line 204, noorg=). Thecreated_bycomparison here is correct, though:request.profile.user == self.object.created_by(line 208) compares aUserto aUser, not aProfileto aUser, so this endpoint'screated_bycheck works.tasks(tasks/views/task_views.py). Both defects are fixed: the lookup is org-filtered (.filter(pk=pk, org=request.profile.org), lines 975-977) and the comparison isrequest.profile.user_id == self.object.created_by_id(line 987), with a comment at lines 982-984 explaining why. The cases docstring's claim that the lookup bug is "still open" intasksis stale.opportunity(opportunity/views/opportunity_interactions.pyandkanban_views.py). Both defects are open, and the type mismatch exists at two separate call sites:request.profile == self.object.created_byin the attachment delete (opportunity_interactions.py:158for the unscoped lookup,:162for the comparison) and again asis_owner = request.profile == opportunity.created_byin the Kanban move endpoint (kanban_views.py:162).
So as of this writing: the created_by type-mismatch bug itself is open in opportunity only (two
call sites); the separate unscoped-lookup bug is open in leads and opportunity. Neither defect
is something this documentation task fixes. It's flagged here, precisely, because a docstring that
lists which apps still have a bug goes stale exactly like any other comment does, and the fix in
this case is checking the current source yourself, the way this paragraph did, rather than trusting
either the docstring or a prior summary of it. When you write a created_by comparison, compare
profile.user_id to obj.created_by_id, and write a test that proves the check can return both
True and False.
Read wide, write narrow¶
A pattern recurs across this codebase enough to name: for a given kind of resource, who may read it is broader than who may change it, which in turn is broader than who may destroy it. Two concrete shapes of it:
Per-object access, as in cases/access.py above:
Watching a case is subscribing to updates, not being handed the keys to it, so the watcher clause that widens read access is deliberately not carried into write or delete. Each verb states its own rule; none is inferred from another.
Shared, org-wide configuration (tags, vertical packs, and similar org-level settings) follows
a simpler but equally deliberate version of the same shape: any active member of the org may read
it, only an admin may change it. TagsListView (backend/common/views/tags_views.py) is a clean
example: get has no additional check beyond the standard (IsAuthenticated, HasOrgContext), but
post gates explicitly:
def post(self, request, *args, **kwargs):
"""Create a new tag (admin only)."""
if request.profile.role != "ADMIN" and not request.user.is_superuser:
return Response(
{"error": True, "errors": "Only admins can create tags"},
status=status.HTTP_403_FORBIDDEN,
)
common/views/pack_views.py states the same shape as a doc comment before a single line of code:
"any authenticated member may list packs; only an org ADMIN may apply one or clear sample data."
When you're adding a new endpoint over something that's genuinely shared, org-wide configuration
rather than a record with a specific owner, this is the default to reach for: don't gate the read
at all beyond org membership, and gate every write and destroy behind an explicit admin check in
the view, not a permission_classes entry that would also block the read.