In DRF, an unmapped viewset action is a denial, not a CRUD fallback
A permission matrix that names domain actions cannot be derived from HTTP verbs: shipment.reroute and shipment.deliver are both POST, and neither of them is create. Any adapter that guesses the permission from the method will eventually wave a custom route through on the strength of a capability that route has nothing to do with. The mistake slips past review because the route looks guarded.
We keep running into this shape in multi-actor backends. One product answers to customers, couriers, warehouse, receiving, support and back-office, and five of those audiences read the same order table. The authorization library we maintain for that case, role-scopes, takes a deliberate position: a route whose action is not mapped gets refused, with a rule and a reason, instead of falling back to whatever CRUD its verb implies.
The matrix names the business, not the router#
Permissions in the core are declared as data and named <resource>.<action> after the domain rather than after CRUD, because shipment.reroute is a thing support does and PATCH is not. A courier's row is a frozenset of exactly what being a courier means:
ACTOR_PERMISSIONS = {
Actor.COURIER: frozenset(
{
Permission.ORDER_VIEW,
Permission.SHIPMENT_VIEW,
Permission.SHIPMENT_PICKUP,
Permission.SHIPMENT_DELIVER,
}
),
# ...one row per actor; back-office holds frozenset(Permission)
}Now count the POSTs that a shipment viewset carries in a real delivery backend: pickup, deliver, reroute, assign, escalate. Five routes, one verb, five different answers about who may call them. The verb is not a lossy encoding of the action, it carries no information about the action at all. That is the constraint the adapter has to live with, and it rules out the convenient mapping that most role-check helpers ship with.
Resolution order: declaration, then verb, then a narrow fallback#
ScopedPermission in src/role_scopes/contrib/rest_framework.py picks the Permission gating a request before asking whether the actor holds it. The whole decision about which permission applies lives in one method:
def permission_for(self, request: Request, view: Any) -> Permission | None:
"""Return the declared permission gating this request, if one is mapped."""
method = str(getattr(request, "method", "") or "").upper()
action = getattr(view, "action", None)
for key in (action, method):
if key and key in self.actions:
return self.actions[key]
# A viewset names what it is doing, so a custom action never inherits
# the fallback of the method it happens to use.
default = VIEWSET_ACTIONS.get(action) if action else METHOD_ACTIONS.get(method)
if default is None or not self.resource:
return None
try:
return Permission(f"{self.resource}.{default}")
except ValueError:
return NoneThe class's own actions mapping gets consulted first by viewset action and second by HTTP method, so an explicit entry under either key wins. Only when neither is mapped does a fallback apply, and the fallback is chosen by what the view says it is doing rather than by what it received:
METHOD_ACTIONS: Mapping[str, str] = MappingProxyType(
{"GET": "view", "HEAD": "view", "OPTIONS": "view", "POST": "create"}
)
# The viewset actions that carry an obvious matrix name. ``update``, ``destroy``
# and custom actions are absent on purpose: the matrix names domain actions
# rather than CRUD, so they are declared per view or refused.
VIEWSET_ACTIONS: Mapping[str, str] = MappingProxyType(
{"list": "view", "retrieve": "view", "create": "create"}
)A viewset consults VIEWSET_ACTIONS, a plain APIView consults METHOD_ACTIONS. The two tables are not the same table, and that difference is what buys you the guarantee: list, retrieve and create are the only viewset actions whose matrix name is not a guess, so they keep their convenience. update, destroy and every @action are missing from VIEWSET_ACTIONS deliberately. A custom POST route is a viewset action, so it never reaches METHOD_ACTIONS, and therefore never inherits create from its verb.
That behaviour is pinned by tests rather than by a docstring:
def test_unmapped_action_is_refused_rather_than_guessed() -> None:
permission = ShipmentAccess()
assert not permission.has_permission(Request("DELETE", courier()), View("destroy"))
assert permission.denial is not None
assert permission.denial.rule is Rule.ACTION_KNOWN
assert permission.denial.action == "destroy"
def test_custom_post_action_does_not_inherit_create() -> None:
orders = scoped_permission("order")
permission = orders()
assert not permission.has_permission(Request("POST", courier()), View("escalate"))
assert permission.denial is not None
assert permission.denial.rule is Rule.ACTION_KNOWNDeclaring a route is then one row, and it reads like the matrix because it is the matrix:
class ShipmentAccess(ScopedPermission):
resource = "shipment"
actions = {
"deliver": Permission.SHIPMENT_DELIVER,
"pickup": Permission.SHIPMENT_PICKUP,
}list, retrieve and create need no entry; deliver and pickup do; assign is a denial under PERMISSION_GRANTED for a courier because the matrix says so, and escalate is a denial under ACTION_KNOWN until somebody writes it down.
The misconfiguration is reported before the actor is#
Ordering inside the gate matters more than you would expect. When permission_for() returns None, the internal _gate() turns that into a denial under Rule.ACTION_KNOWN, whose reason reads no permission is mapped for the action that was requested, naming the actor, or ANONYMOUS, the module's "anonymous" sentinel, when the request carries none. Only after that does it deny an actorless request under Rule.ACTOR_KNOWN.
So an unauthenticated probe against a route nobody declared comes back as action.known, not as actor.known. The denial says this route has no rule, not you are not logged in. That is the difference between a 403 body that sends an engineer to the permission class and one that sends them to the session middleware, and it holds for the request most likely to arrive first, the anonymous one. A missing declaration cannot hide behind a missing session.
From there the two questions are answered in the same class: has_permission() runs check() from the core, has_object_permission() runs check_object() on the detail routes that call get_object(), and whichever one refuses lands its Denial on message through as_dict(), so the 403 body carries exactly what the log line carries:
{
"actor": "courier",
"action": "escalate",
"rule": "action.known",
"reason": "no permission is mapped for escalate"
}Four rules exist, applied in the order they are listed in Rule: actor.known, action.known, permission.granted, object.owned. A reader of that body can tell a stale role from an undeclared route from a capability the actor genuinely lacks from a row outside the actor's slice, without reading our source.
A typo fails at startup, not on the first request#
Refusing by omission only helps if the declarations themselves are real. scoped_permission() builds a permission class from a resource and a mapping, refuses a resource outside RESOURCES with ValueError, and resolves every mapped value through Permission() as the class is built. An undeclared capability therefore fails at import, when the module defining the view is loaded, rather than on the first request that happens to hit that route:
def test_factory_rejects_declarations_the_matrix_does_not_know() -> None:
with pytest.raises(ValueError):
scoped_permission("shipment", {"deliver": "shipment.delivery"})Writing actions by hand with Permission enum members gets you the same guarantee from the enum itself. Either way, there is no state in which a route is mapped to a permission name that does not exist and nobody knows until a courier taps a button.
What it costs to run#
The bill is mapping work, and it is not zero. Every update, every destroy, and every @action needs a line in actions. A plain APIView serving PUT, PATCH or DELETE has no fallback at all, since METHOD_ACTIONS covers only GET, HEAD, OPTIONS and POST, so such a route must declare or be refused. OPTIONS costing <resource>.view is a real consequence too: a preflight-style introspection request is gated like a read, which is correct for us and probably not for everyone.
The practical cost lands in review, which is where we want it. A diff that adds an @action and no row is incomplete, and that is a one-line comment on a pull request rather than an incident. Tests get cheaper in exchange: because a denial carries its rule, an assertion names Rule.ACTION_KNOWN instead of matching a 403 that could mean four different things, so a regression in resolution order fails loudly instead of looking like a stricter endpoint.
What you buy is a single property worth paying for: no route in the service is allowed by omission. A route is permitted because someone wrote down which domain action it performs, and an unwritten route answers with the reason it is unwritten. Every other default we were offered, inherit the verb, inherit the parent viewset, allow and log, makes silence mean yes, and silence is exactly the state a growing codebase drifts into.