Shipmind Labs

A mixin that exists to narrow a queryset must not have a path where it hands back everything.

That sounds obvious until you sit down and write it. The natural shape is to look for the thing that defines the slice, and if it is not there, fall back to whatever the view already had. That fallback is the bug. It turns a forgotten declaration into a working endpoint that serves the full table.

In the DRF adapter of our open-source project role-scopes, the scoped queryset mixin deals with this by failing loudly at the wiring boundary.

The sequence is small. It calls the parent get_queryset to get the view's own queryset. Then it walks the view's permission instances in declared order and stops at the first one that is a scoped permission, handing that queryset to the permission along with the request and the view. If the walk finishes and no scoped permission was found, it raises ImproperlyConfigured and names the view class in the message: this view mixes in the scoped queryset mixin but declares no scoped permission, so there is nothing to narrow by.

Two details matter more than the raise itself.

First, non-scoped permissions are passed over rather than rejected. A view can list a throttle-style or authentication-style permission class next to a scoped one and still get narrowed. Being strict about a missing declaration is useful. Being strict about the presence of unrelated ones would probably just push teams into copy-pasted wrappers.

Second, the refusal lives only at the wiring boundary. Inside the scoping call, the ordinary answers are preserved. If there is no actor, or no resolvable permission for that actor, you get an empty queryset. Otherwise the declared slice is applied. Those outcomes depend on data, and empty is the correct response to them. A developer who forgot a line of configuration is a different category of event, and it deserves a different category of response.

The general rule we keep coming back to in authorization code: separate "this request legitimately sees nothing" from "nobody declared what this request is allowed to see." The first one is an answer. The second is a question that was never asked, and defaulting it to an open queryset is how a permission bug ships as a silent data leak (tests pass, the endpoint returns rows, and the review notices nothing because the missing line is missing).

Failing at import-adjacent wiring time means the forgotten declaration shows up the first time anyone touches the endpoint, including in CI, instead of in an access log six months later.

That is where we draw the line in our own permission layers: a missing scope declaration fails the request rather than falling back to a default. Where you draw it in your own code is a separate call.

Was this useful?

Building something similar?

or email hello@shipmindlabs.com