Shipmind Labs

The list endpoint was correct. The detail endpoint, same scope and same role, returned a row the user should never have seen.

We were building role-scopes, our open-source authorization layer, and the idea was simple: one declaration describes what a role may see, and two things read it. One narrows a queryset. One answers yes or no about a single object.

The declaration that broke us looked reasonable in review. Something like a store filter plus a created-at lower bound. The queryset side handled it fine, because a database will happily compare a timestamp. The single-object side could not. It had a field value, a scope value, and no idea the comparison was meant to be greater-than-or-equal instead of equal. So it compared them as equals, failed, and we ended up with two different answers to the same authorization question depending on which endpoint you hit.

The leak itself was not the expensive part. We caught it in staging. What cost us was realizing that every scope anyone would ever write was a chance to reintroduce it, and that no list test would catch it, because the list side is right. Only the pairing is wrong.

So the fix moved to declaration time instead of request time.

We split every declared lookup on its last segment and check that segment against the exact set of names the ORM treats as lookups. A store-id filter with a trailing membership segment splits into a field path and the lookup name. A nested path ending in a courier-id field keeps its whole path, because that last segment is a column and not a lookup. Then the scope rejects, at import, any declaration whose trailing name is a lookup other than membership. One allowed lookup. Everything else refuses to exist.

That sounds restrictive until you write the parity proof on the back of it: membership is the one lookup where both readers do the same work. The queryset asks whether the column is in a tuple. The object check asks whether the field value is in that same tuple. There is nothing to drift.

Two details we probably only got right because of that first bug.

Membership values have to be an iterable that is not a string or bytes. A string is iterable, and a scope that accidentally passes one widens into a per-character match that still looks like it works.

And an empty collection of values stays empty. A principal entitled to nothing filters to zero rows. The tempting shortcut is to drop an empty filter as meaningless, and dropping it turns nothing into everything.

The general shape we reach for now: if two code paths must agree, do not test the agreement. Shrink the language until disagreement cannot be expressed. You have likely had a config of your own that needed narrowing so you could stop verifying it.

Was this useful?

Building something similar?

or email hello@shipmindlabs.com