description: Why Karni enforces permissions at the queryset rather than the view: one row filter that list, detail, relation fields and expansion all share.¶
Permissions at the queryset¶
Most permission systems answer one question — may this request reach this
view? — and leave the second, which rows?, to whoever writes
get_queryset(). The leak everyone eventually ships is a retrieve that
forgot to filter, a relation field that accepts any primary key, or an
?expand= that embeds a row the requester could never list. Karni compiles
one declaration into both answers, so they cannot disagree.
One declaration, two layers¶
class BookingViewSet(BaseViewSet):
model = Booking
access = Access(
read=Owned("attendee") | Owned("event__organizer") | HasRole("staff"),
write=Owned("attendee"),
create=Authenticated(),
)
Every predicate has a request-level answer and a row-level answer:
| Predicate | Gate (may the request proceed?) | Rows |
|---|---|---|
Public() |
everyone | all |
Authenticated() |
signed-in users | all |
HasRole("staff") |
holders of the group | all for holders, none otherwise |
Owned("attendee") |
signed-in users | attendee = request.user |
Owned("event__organizer") |
signed-in users | the relation path ends at the user |
Where(fn) |
fn(request) as a bool, or passes |
the Q fn(request) returns |
a \| b, a & b |
any / all | OR / AND of the row filters |
The gate runs first, as a DRF permission, before any query. The row
filter is applied in get_queryset(), and it is authoritative: lists,
details, custom actions, relation-field validation and ?expand= all go
through it. A row outside read does not exist for that request — a 404
with no existence oracle — and a readable row outside write is a 403.
Why the queryset, not the object¶
Object-level permission checks (has_object_permission) run after the row
was fetched, one object at a time. They cannot filter a list, they cannot
scope a relation field's choices, and they are easy to skip in a custom
action. A queryset filter is applied once, before anything is fetched, and
every code path that asks for rows inherits it:
- Lists and details share one filter, so a row you cannot list you cannot fetch by id either.
- Relation fields (
booking.event) accept only primary keys of rows the requester can read through the target model's viewset. An out-of-scope id fails exactly like a missing one. - Expansion embeds only readable rows; a to-one target outside the scope
renders as
null. - Owner stamping: a direct foreign key to the user named by
Ownedinwriteorcreateis set fromrequest.useron create and is read-only in the serializer. The body never chooses the owner.
Model scopes sit underneath¶
The row filter is only ever filter()-ed onto the model's default manager,
and the default manager runs the scope pipeline:
soft_delete today, tenant when the tenancy module is installed. No
predicate, HasRole bypasses included, can reach a soft-deleted row or
another tenant's row. Lifting a scope is a separate, logged, reasoned call:
Model.objects.without("soft_delete", reason="restore screen").
Testing it in two lines¶
class BookingAPITests(APITestCase):
def test_crud(self):
self.assert_crud(BookingViewSet, "attendee") # every promised allow and deny
def test_isolation(self):
self.assert_isolated(BookingViewSet, "attendee") # another owner's rows: unreachable
assert_crud derives the expected status of every action from the
declaration and checks the API against it, including the 404-not-403 rule;
assert_isolated creates rows for two owners and proves one cannot see,
change or reference the other's.
Escape hatches, least to most drastic¶
- Change the declaration.
Where(...)for arbitrary row logic;@action(access=...)for one action;get_access()to compute the declaration per request.- Override
filter_queryset_for_user(queryset)— it receives the scoped queryset — with a# karni: allow(access-override): <reason>marker. access = Unscoped(reason="..."): plain DRF permissions, rows limited only by model scopes. The reason is published in the schema.
Everything in between is a declaration you can read in one place, and that
karni context prints for every viewset.