S
supero.docs
Documentation/Under the hood/The permission that returned zero rows to everyone, silently

The permission that returned zero rows to everyone, silently

Read filters and write stamps must be generated from one declaration. When they drift, the endpoint returns an empty list instead of an error, and empty looks exactly like new.

  • access-control
  • rbac
  • policies
  • correctness
  • postmortem

Overview

An empty list is a claim about the world. It is also what a broken filter looks like.

---
Almost every application has a screen that shows you your own things. My orders. My tickets. My documents. It is the second or third screen anyone builds, usually in an afternoon of week 1, and it is almost never revisited after the demo where somebody logged in as themselves and saw the two records they had just created.
It is such a small feature that it rarely gets a design discussion, and it is made of two halves that have to agree with each other exactly.
On **write**, the server decides which user owns the new record. On **read**, the server filters to records owned by the caller. If those two halves disagree about where ownership is recorded, the read finds nothing. The endpoint returns `200` and an empty array.
Not an error. An empty array.

What we shipped, and what it did

Our first version, `OWNER-SCOPE-V1`, recorded ownership by appending the username as a fourth segment of the record's address, alongside the domain, project and tenant segments already there.
That is a defensible idea. A record address is already `[domain, project, tenant, name]`, so extending it by one level to `[domain, project, tenant, user, name]` has a certain symmetry to it.
No write path ever stored a record at an address with a username segment.
Not one.
So the read filter was looking for records under an address that nothing wrote to, and it matched nothing. Every customer's "my things" screen returned an empty list. For every user. Silently, with a `200`, for as long as it was deployed.
An empty list is the hardest failure in the catalogue to notice.
It is indistinguishable from the correct answer. A new user with no orders sees an empty list, a user with forty orders sees an empty list, and only the second of those is a defect. Nothing in the response, the status code, the logs or the error tracker tells the two apart, because from the server's point of view nothing went wrong: a query ran, it matched no rows, and the matching of no rows is a perfectly ordinary outcome that happens thousands of times a day for entirely legitimate reasons.

The fix is a shape, not a patch

The second version records ownership in a field on the record, and both halves are generated from **one declaration**:
text
filter_field: owner_username
filter_match: $user.name
That declaration does two jobs.
On write the server resolves `$user.name` from the authenticated principal and stamps it onto the record. Whatever the client sent in that field is discarded, so a caller cannot create a record owned by someone else, and cannot reassign one on update either.
On read, the same resolved value is injected as a filter on `owner_username`.
The property that matters is not cleverness in either half. It is that the two halves cannot drift apart. Ownership is defined in exactly one place, so there is nowhere for a second definition to disagree from. **Records a user creates are exactly the records that filter finds**, and that sentence is true by construction rather than by two teams remembering the same convention.
The same filter is what an AI agent's queries are subject to, because the agent forwards the caller's token rather than holding one of its own — Give your AI agent no credentials of its own walks through that.
Administrators have no filter on their policy. Their reads are unscoped and their writes pass through unstamped, from the same mechanism and a different declaration.

The test that only passes on the fix

Eight tests cover this in `test_51_owner_scope_username.py`, and the interesting thing about them is that they were written to fail against the previous version and pass against this one. That is a different and much more useful property than simply passing today.
One covers the stamp happening on create. Two more cover a client-supplied `owner_username` being overwritten, once on create and once on update, because the mechanism is overwrite and not rejection. Three more cover the three separate read paths. A list, a query and a bulk list are three different code paths, and a filter applied to only two of them is a leak in the third.
The one that matters most is the one asserting the list returns your own records **and is not empty**.
A test that asserts "the caller sees only their own records" passes against a completely broken implementation that returns nothing to anybody. Zero records satisfies "only your own records" perfectly. The regression we shipped would have sailed through that assertion. So the test insists on a non-empty result, and that detail is written into its name where the next person will see it.
If you take one thing from this: **any test for a scoping rule needs a positive case that fails when the answer is empty.**

Pick the right column to key on

One warning that belongs with the pattern rather than after it.
The declaration above keys ownership on a username. `$user.uuid` and `$user.email` are equally available, and for most applications the uuid is the right choice, because usernames change.
Think through what a rename does under each. Key on an immutable id and a rename is a display change. Key on the username and every record that user owns is, from the moment of the rename, owned by a name that no longer refers to anybody — which is an empty list for the user, arriving silently. It is the exact failure this whole article is about. If your system ever frees a username for re-registration, it is worse than an empty list.
We support all three because different applications genuinely want different ones, and a platform that picked for you would be wrong for somebody. If you are choosing today and you have no strong reason, choose the uuid.

Fail closed on read, fail open on write, deliberately

Two asymmetries here. We argued about both.
If the policy service cannot be reached, a **read** returns `503`. It does not return `200` and an unfiltered result set. A momentary policy outage that degraded into "show everyone everything" would be a data breach caused by a timeout, and an error page is a much better outcome than that. A read that silently widens its own scope because a dependency was slow is not a degraded read. It is a different read, answering a question nobody asked.
The owner **stamp** on write, by contrast, never blocks the write.
The reasoning is that refusing writes during a policy hiccup makes the application unusable for a reason the user cannot understand, while the read side is where a mistake becomes somebody else's data. The two directions genuinely deserve different answers, and it is written down so that whoever revisits it knows it was a decision rather than an oversight.

Two sharp edges

**A stamped field is not automatically an immutable field.** The stamp overwrites whatever the client sent on update, and that covers the obvious attack. Making the field genuinely read-only is a separate declaration, and the two interact, so set them together and test that a write still lands afterwards.
**Owner scoping does not apply to everything.** Seven record types are exempt: the ones describing the structure of a workspace, the ones describing a user, and the policy records themselves. They are governed by role and not by ownership, which is the right split, and it means "everything is owner-scoped" is not a sentence anyone should say about this platform. That is correct and it means "everything is owner-scoped" is not a sentence anyone should say about this platform.

Count the rows, as two different people

Log in as a user who definitely owns records. Open the screen that shows their own things and count the rows. Run the same query as a second user who owns none, and count again.
If the first number is zero, you have this bug right now.
If both numbers are correct, do the harder one. Find every code path that lists that record type, not only the one the screen uses, and confirm the filter reaches all of them.
A search endpoint that forgets the filter has the same hole in it, and nobody will find it, because search results are the one screen where a short list never looks wrong.
*(Disclosure: I work on Supero. The empty-list regression described here was ours, it reached a deployment, and the test that now guards it exists because the previous test was satisfied by the broken behaviour.)*