Shipmind Labs

A permission check should return a denial, not False

· 8 min read

Most Django backends answer authorization questions with a boolean, and everything the check knew gets thrown away on the way out. A courier's app shows a refund button that returns 403, support asks why, and nobody can answer without reading the view. More logging around the call site does not fix that. What fixes it is making the check return a value that names the actor, the action and the rule that refused, and then reusing that same declaration for queryset scoping, object ownership and the Django admin. We build this shape often enough in operations backends that we extracted it into a library of our own, role-scopes.

One False, four questions#

A boolean check collapses four questions that fail for different reasons and want different handling:

  • Is this actor a thing we know about at all?
  • Is this action a thing we know about at all?
  • Is this actor granted this capability in general?
  • Is this row theirs?

Only the third one is what people mean when they say "permission check". The first two are usually a typo or a stale role string arriving from a token claim, a user column, or another service. The fourth is a completely different bug class, and it is the one that gets exploited.

So the rules are named and ordered, and the name travels with the answer:

python
class Rule(str, Enum):
    """Rule consulted by a check, listed in the order it is applied."""

    ACTOR_KNOWN = "actor.known"
    ACTION_KNOWN = "action.known"
    PERMISSION_GRANTED = "permission.granted"
    OBJECT_OWNED = "object.owned"

Unknown actors and unknown actions are denials too, tagged actor.known and action.known, rather than exceptions. We chose that deliberately: a role string that no longer exists should refuse the request, not turn into a 500 that pages someone. The stack trace tells you the same thing the denial does, but only one of the two is safe to return to a client.

The matrix is data, in one file#

Permission logic rots because it gets restated. A view knows one version, a serializer knows another, the admin knows a third, and the mobile client hides buttons according to a fourth. So the matrix is declared once, as data, and every mechanism reads it:

python
class Actor(str, Enum):
    """Party acting on the system."""

    CUSTOMER = "customer"
    COURIER = "courier"
    WAREHOUSE = "warehouse"
    RECEIVING = "receiving"
    SUPPORT = "support"
    BACK_OFFICE = "back_office"


ACTOR_PERMISSIONS: Mapping[Actor, frozenset[Permission]] = MappingProxyType(
    {
        Actor.COURIER: frozenset(
            {
                Permission.ORDER_VIEW,
                Permission.SHIPMENT_VIEW,
                Permission.SHIPMENT_PICKUP,
                Permission.SHIPMENT_DELIVER,
            }
        ),
        # ...
        # Back-office runs the business and holds every capability.
        Actor.BACK_OFFICE: frozenset(Permission),
    }
)

Permissions are named <resource>.<action> (order.refund, shipment.deliver, inventory.adjust) because the resource half is later what queryset scoping keys on. The mapping is a MappingProxyType of frozensets for a boring reason: nothing at runtime should be able to grant itself a capability by mutating the matrix it was checked against.

Declaring it as data also makes the inverse question answerable, and the inverse question is the one audits actually ask. Not "may a courier refund an order" but "who can refund an order at all":

python
actors_with(Permission.ORDER_REFUND)

That is a one-liner against the same declaration, not a grep across views.

The denial is a value, not a message#

A check returns a Decision. It is falsy when denied, and it carries the denial:

python
check(Actor.SUPPORT, Permission.ORDER_CANCEL).allowed
# True

decision = check("courier", "order.refund")

str(decision.denial)
# 'courier may not order.refund: courier is not granted order.refund [permission.granted]'

decision.denial.as_dict()
# {'actor': 'courier', 'action': 'order.refund',
#  'rule': 'permission.granted', 'reason': 'courier is not granted order.refund'}

The Denial is a frozen dataclass with four fields and two renderings: __str__ for a log line and as_dict() for a JSON body. This is the part that pays for itself. The line an engineer reads in the logs and the object the client's frontend received are the same four fields, so a support conversation stops being an archaeology exercise. And because rule is a stable enum value rather than prose, denials aggregate. A sudden concentration of permission.granted denials on one actor and one action usually means the matrix and the product's expectations have drifted apart, and it shows up as a count before it shows up as a complaint.

In a view, require() raises a PermissionDenied that subclasses Django's own, so the framework still turns it into a 403 on its own, while carrying the structured denial for handlers that want the body:

python
from role_scopes import PermissionDenied, require

try:
    require(request.user.role, Permission.INVENTORY_ADJUST)
except PermissionDenied as exc:
    return JsonResponse(exc.denial.as_dict(), status=403)

Nothing here requires an exception handler to know anything about permissions. It formats a value.

"Not allowed at all" and "not on this row" are different failures#

A permission says what an actor may do. It says nothing about which objects are theirs. The mistake worth naming, because we have inherited it more than once: detail and action endpoints check the role and stop there (a courier may deliver shipments) without asking whether this shipment was assigned to that courier. The list endpoint is filtered correctly, so the bug stays invisible until someone walks the URL space.

The slice is declared next to the permission, keyed by the resource half, so a view narrows a queryset without restating the filter:

python
# request.user carries `courier_id`
scope_queryset(Shipment.objects.all(), "courier", Permission.SHIPMENT_VIEW, request.user)
# Shipment.objects.filter(courier_id=request.user.courier_id)

scope_for(Actor.WAREHOUSE, Permission.INVENTORY_ADJUST).label
# 'inventory.own_store'

And the object check asks both questions against that same declared slice, so the row a list view hides is the row a detail view refuses:

python
owns("courier", Permission.SHIPMENT_DELIVER, shipment, request.user)
# True only when shipment.courier_id == request.user.courier_id

decision = check_object("courier", "order.view", someone_elses_order, request.user)
str(decision.denial)
# 'courier may not order.view: this order is outside order.own_assignment [object.owned]'

The rule is object.owned, not permission.granted, and that distinction is why we keep the rule in the value. "You may not do this at all" is a UI bug or a stale client. "You may do this, but not on that row" is somebody probing identifiers. Those two lines should stay distinguishable in a log.

The same declarations split the admin#

Internal tools are where role logic is most often re-derived, because Django's admin has its own permission vocabulary and mapping roles onto it by hand is tempting. We do not. The adapter in role_scopes.contrib.admin answers the admin's per-model questions from the same matrix and the same scopes the API uses, so back-office sees everything because BACK_OFFICE holds frozenset(Permission), and warehouse staff opening the same model see their own store's slice because that is what inventory.own_store already says.

The shape it produces is the shape you would otherwise repeat in every ModelAdmin:

python
from role_scopes import Permission, check, scope_queryset


class ShipmentAdmin(admin.ModelAdmin):
    def has_view_permission(self, request, obj=None):
        return check(request.user.role, Permission.SHIPMENT_VIEW).allowed

    def get_queryset(self, request):
        return scope_queryset(
            super().get_queryset(request),
            request.user.role,
            Permission.SHIPMENT_VIEW,
            request.user,
        )

In practice, granting a role a capability is one edit in one file, and the API, the DRF viewsets and the back-office screens agree afterwards without a second review pass. A REST Framework adapter exists for the same reason: an endpoint should stop hand-rolling the role checks its neighbours already spell out.

What it costs to run#

Two things are worth knowing before adopting this shape.

It fails loudly on missing data. A principal that lacks the attribute a slice filters on raises MissingScopeKey, and an object missing the field the slice narrows on raises MissingObjectKey. Both could have been made to return an empty queryset instead, which is the tempting choice because it never pages anyone. It is also the choice where a misconfigured token silently widens or empties results, and nobody finds out for a quarter. We would rather have the 500.

The denial text is public API. order.own_assignment and permission.granted are internal labels, and returning them to a client means committing to them. We did that on purpose, since a client's engineer reading the reason is cheaper for everyone than a ticket, but it means a scope gets renamed carefully, and the reason line is never rewritten into something friendlier at one call site while the log keeps the old wording.

The underlying discipline is small: authorization is a question with more than two answers, so the code that asks it should be allowed to give more than two. Once the answer is a value, the log line, the error body and the internal tools stop being three separate implementations of the same rule.

Was this useful?

Building something similar?

or email hello@shipmindlabs.com