Model
What a row is, what makes it unique, and which facts are allowed to be missing — decided against the records the business actually keeps.
The schema and the access rules are the first thing built and the last thing anyone sees. Two failures we find repeatedly: a policy that guards inserts and not updates, so a signed-in user can raise their own role; and a view that reads around the row rule, so the public key shipped inside the browser reaches data no anonymous request should ever see. Both kinds pass a demo. Neither had ever been attempted as the person it was written to stop.
| Table | Owner signed in, owns the org | Staff signed in, member of the org | Another tenant signed in, different org | Anonymous key the public key in the client |
|---|---|---|---|---|
| organisations the tenant record itself | r w its own row | r its own row | — no rows | — no rows |
| members who belongs, and as what | r w role column excluded | r its own org | — no rows | — no rows |
| payout_accounts where money is sent Read by the owner alone | r last four digits | — no rows | — no rows | — no rows |
| orders what was bought, and from whom | r w its own org | r w assigned only | — no rows | — no rows |
| audit_log what changed, and who changed it | r append only | — no rows | — no rows | — no rows |
four roles across — the matrix scrolls
Every rule is attempted from outside the application, as the role it is written against
Signed in as staff of another organisation, asked for a payout account by its id.
0 rowspayout_accounts × Another tenantSigned in as a member, updated own membership row to set the role to owner.
rejected · role unchangedmembers × StaffWith the anonymous key alone, selected every row of payout_accounts.
0 rows · no errorpayout_accounts × Anonymous keySigned in as the owner, deleted a row from audit_log.
rejectedaudit_log × OwnerSigned in as staff of the same organisation, read the orders assigned to them.
42 rowsorders × StaffIn the database, not the screen
From outside, as each role
Denied until a rule says otherwise
A policy table and its assertions, drawn in code in this palette. Press Try now and be the attacker: choose a role, attempt something against the policy, and read what came back. No client policy is reproduced here and no record is real.
What a row is, what makes it unique, and which facts are allowed to be missing — decided against the records the business actually keeps.
Keys, uniqueness and checks live in the database, so a rule cannot be skipped by whatever writes next.
Every row carries the tenant it belongs to, and that column is what every later rule is written against.
Access is written per table and per role, in the same migration that creates the table. Default deny: a new table is readable by nobody.
Each rule is attempted from outside the application, as the role it is meant to stop — including the anonymous key.
Schema and policy move together as reviewed migrations, forward and back, run the same way in every environment.
Who may see a price, who may see a bank account, who may change a role — those are your decisions, and they are written down before anything is built. They then live in the database rather than in each screen, so a second application, a new endpoint or a rushed release cannot go around them.
A row-level rule authorises the identity making the request. It does nothing about a leaked key being used as the identity it belongs to — so keys are scoped to one job, rotated, and every change to a sensitive table is logged.
A dump, a diagram, or the screens if that is all there is. We will tell you which rules are enforced where, and which ones hold only because nobody has tried them yet.