Tenancy, RLS and derived identity
Three separate controls, all answering versions of the same question: who is this, and what may they see.1 · Row-level security, org-scoped
Every GL table is org-scoped and carries a row-level security policy.anon reads zero rows.
Reads in the DevCenter run as a dedicated, RLS-scoped role:
for update, filter by org_id, and enforce separation of duties on top.
Application-level filtering and RLS are not alternatives.
Application filtering is a
where org_id = ... that one query can forget.
RLS is a policy that holds for the query somebody writes at 2am to answer a support question.The organisation context
X-WorthState-Org-Id, falling back to the configured single-organisation default.
This is a legacy internal wire identifier retained by the shipped protocol; it is not a GreatBook brand or domain claim.
The paired half of that lives in the UI: the API proxy strips x-worthstate-org-id from browser requests, so a browser cannot choose the organisation a row is created in.
Onboarding a second organisation means the proxy must set it from verified session membership.
2 · Read-only sessions, enforced by Postgres
The Auditor and the Accountant share one read channel, so the guarantee is enforced in exactly one place. The Postgres session itself rejects any INSERT, UPDATE or DELETE withread-only transaction.
Getting that to be true through the deployed transport took four mechanisms, not one, and the Auditor page walks all four:
a transaction-pooler refusal, a forced default_transaction_read_only=on startup parameter, a post-connect check of the port libpq actually reached, and a SET SESSION CHARACTERISTICS AS TRANSACTION READ ONLY whose result is read back from the server before the connection is handed out.
This is why the assurance agents were built before any further write agent.
“The Auditor is read-only” is not a claim about the code being careful; it is a property the database enforces and then confirms on request, and a channel that cannot get that confirmation fails rather than degrading into the thing it claims to prevent.
Three details:
- It is a separate read layer, not the shared pool. The shared pool commits on a clean exit, which is correct for the writers it serves and wrong here.
- The Auditor never imports a writer. It reuses the writer’s pure hash helpers so its recompute cannot drift, but never its post path.
- The DSN precedence mirrors the Bookkeeper’s, so the reader and the writer see one database. An assurance agent reading a replica would produce findings about a book that is not the book.
3 · The acting identity is derived, never client-supplied
Every separation-of-duties control in this system is enforced against one string: the actor. It decides who may approve, and it becomesjournal_entries.created_by.
That string used to arrive in a browser-supplied X-Actor header which nothing on the server re-derived.
So the approver simply chose it.
The submitter of a document could open devtools, send X-Actor: <a colleague>, and approve their own document.
Maker-checker passed, and the audit trail named someone who never touched it.
An audit trail the subject of the audit writes is not an audit trail.
The rule
The actor comes from the identity the request was authenticated with, andX-Actor is ignored whenever such an identity exists.
Two authenticated callers, matching the two authentication branches:
The second row is the path the gated posting approval uses.
Anything else is unauthenticated and never reaches a route.
It is a single module, and a test enforces that
devcenter/identity.py is the only place that answers “who is acting”.
Mutating routes take the Actor dependency.
A test fails the build if any API module reads x_actor itself.
That test is the control.
Without it, the rule is a convention that holds until the next router, and the next router is where it will not.
The second identity form
The Typewriter’s tables key a person by the Supabaseauth.users UUID, because that is what their RLS policies compare against.
So the same module derives that second form from the same verified session.
It is None for a service-token caller, and a route that writes such a column must refuse rather than invent one.
The anon read revocation
Migration 0030 revokes anon and authenticated SELECT on the three document tables.
The reason is worth spelling out, because it is a latent-exposure pattern rather than an exploited one.
The authenticated policies carried no organisation term, and one of the tables has no organisation column at all.
So a designated approver reading through the auto-generated REST layer would have seen every organisation’s documents.
The application never used those policies - it reads through the service-role connection, org-scoped in the query - so the grant itself was the exposure, not the code.
Where it is enforced
One deploy detail found the hard way: granting the read role membership is not enough on modern Postgres.
It needs
grant ws_reader to postgres with set true, or set local role fails at runtime with a permission error that reads nothing like a configuration problem.The tests that would fail if it broke
That last one is worth naming precisely.
The read-only claim is proven, not trusted.
What goes wrong without it
Without derived identity, maker-checker is decorative: the approver names whoever they like and the trail agrees. Without RLS, the first multi-organisation bug leaks another company’s ledger, and application-level filtering fails at exactly the query nobody reviewed. Without read-only sessions, “the Auditor cannot write” is a promise about code you would have to read all of to believe.Related
- Two gates, three people - what the derived actor is compared against
- The Auditor - the agent the read-only guarantee is built for
- The posting mandate - the other half of the money path’s trust boundary
- Composition and the render seam - where the second identity form is used