Most permission bugs we find in audits are not missing checks. They are two checks that were meant to say the same thing and quietly stopped saying it.
The shape repeats. A list endpoint filters the queryset down to "the rows that belong to you." A detail endpoint fetches one row and asks whether it belongs to you. Two developers, two tickets, two implementations of one sentence. Six months later someone adds a relation, updates the list filter, and forgets the object check. The list stays correct. The detail view hands out someone else's record by ID.
So in our open-source project role-scopes we stopped treating those as two features. Reach gets declared once, and both paths are derived from it.
There are two read-only tables. One answers whether an actor may act at all. The other, keyed by the resource the permission refers to, answers which rows are theirs. If a resource was never declared, the answer is Scope.NOTHING, not "everything," and not something inherited from a parent role. Reach is granted rather than assumed. A forgotten line in config fails closed.
The list path checks the permission first and then applies the slice: no permission or NOTHING returns an empty queryset, EVERYTHING returns the queryset untouched, OWN returns it filtered by the scope's keys.
The object path reuses that same slice instead of reimplementing it. It follows "__" lookups across relations the way the ORM would join them (order.shipment.courier_id resolves the same on one object as it does in a WHERE clause), and it denies on a mismatch or on a NOTHING slice.
The decision we would defend hardest is the error behaviour. When the principal has no value for the scope key, or the object has no such attribute, it raises. It does not widen the result to be helpful, and it does not quietly return nothing.
Both of those failure modes are worse than a 500. Widening leaks data. Emptying looks exactly like "you have no orders," so support closes the ticket, the customer shrugs, and a broken scope lives in production for a quarter. A loud exception in staging costs an afternoon. A silent empty list costs trust, and you probably hear about it from the wrong person.
That is why we treat authorization as data with one source of truth instead of logic sprinkled at call sites. Worth asking of any codebase: if someone adds a relation tomorrow, how many places have to change for "yours" to stay correct? If the answer is more than one, the two answers will drift, and the only thing left open is which endpoint tells you first.
We keep list filters and object checks from drifting apart by giving them one declaration to read from, and we would rather hear how you do it than assume ours is the only way.