> ## Documentation Index
> Fetch the complete documentation index at: https://docs.vinmake.com/llms.txt
> Use this file to discover all available pages before exploring further.

# Tenancy, RLS and derived identity

> Org-scoped row-level security, read-only database sessions the engine cannot talk its way out of, and an acting identity the browser cannot choose.

# 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:

```sql theme={null}
select set_config('app.current_org', %s, true);
set local role ws_reader;
```

So a cross-organisation caller sees **zero**, not an error and not a filtered subset assembled by the application.
The filtering is a property of the database session.

Writes lock `for update`, filter by `org_id`, and enforce [separation of duties](/greatbook/capabilities/maker-checker) on top.

<Note>
  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.
</Note>

### 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 with `read-only transaction`.
Getting that to be true through the deployed transport took four mechanisms, not one, and the [Auditor page walks all four](/greatbook/agents/auditor#read-only-enforced-by-the-database):
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.

<Warning>
  **`psycopg.connect(dsn, read_only=True)` is not the mechanism, and this documentation used to say it was.**

  On an autocommit connection that attribute is inert: psycopg applies it when *it* begins a transaction, and autocommit never begins one.
  The session reports `transaction_read_only = off` and executes an INSERT.
  It is still set, as belt, so a future non-autocommit caller emits `BEGIN READ ONLY`, but nothing rests on it.
</Warning>

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.

<Warning>
  The GL's hash chain and a stable read want the **session-mode** DSN (port 5432), not the transaction pooler (6543).
  The advisory lock and the checkpointer both need a stable session.

  For the assurance channel this is not a preference but a **refusal**: a session-scoped `SET` on a transaction pooler would persist onto a backend the pooler later hands to a writer, so the connection is refused rather than used.
</Warning>

## 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 becomes `journal_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**, and `X-Actor` is ignored whenever such an identity exists.

Two authenticated callers, matching the two authentication branches:

| Caller                                              | How the actor is derived                                                                                            | `X-Actor`                                                                                               |
| --------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------- |
| a logged-in human through the UI proxy              | the proxy validates the Supabase session, **strips any client-sent identity headers**, and sets the identity itself | **discarded**, and a mismatch is logged as an attempted spoof                                           |
| a server-to-server caller holding the service token | no browser is in the loop and the bearer is a server secret, so this caller may name the human it is acting for     | **honoured**, but it must still name someone. An unattributed money decision is rejected, not defaulted |

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 Supabase `auth.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.

<Warning>
  If that SELECT is ever re-granted, the policies must be org-scoped **first**.
  Nothing static fails if it is not, which is exactly why this is written down here.
</Warning>

## Where it is enforced

| Concern                                    | Code                                                              |
| ------------------------------------------ | ----------------------------------------------------------------- |
| org-scoped RLS on every GL table           | migration `0002_gl_guards_fx_rls`                                 |
| the intake queue's RLS                     | migration `0012_intake_queue`                                     |
| Typewriter RLS, and the `anon` revocation  | migrations `0023_typewriter_rls`, `0030_typewriter_no_anon_reads` |
| the derived actor, and both identity forms | `langgraph_chat/devcenter/identity.py`                            |
| the RLS-scoped read role                   | `intake_service`, `set local role ws_reader`                      |
| read-only sessions                         | `agents/auditor/gl_read.py`, `agents/accountant/gl_read.py`       |
| the browser-side org-header strip          | GreatBook web repository, the DevCenter proxy                     |

<Note>
  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.
</Note>

## The tests that would fail if it broke

| Suite                               | What it proves                                                                                                                               |
| ----------------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------- |
| `devcenter/tests/test_identity.py`  | the derivation, both callers, and that no API module reads `x_actor`                                                                         |
| `backend/tests/test_gl_security.py` | RLS actually returns zero rows cross-organisation, and posted rows reject UPDATE                                                             |
| `backend/tools/live_acceptance.py`  | the RLS cross-organisation assertion against the **live** book                                                                               |
| the Auditor's eval cases            | `read_only_guarantee` - entry, line and intake counts plus the chain tail are fingerprinted before and after every run and must be identical |

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](/greatbook/capabilities/maker-checker) - what the derived actor is compared against
* [The Auditor](/greatbook/agents/auditor) - the agent the read-only guarantee is built for
* [The posting mandate](/greatbook/capabilities/posting-mandate) - the other half of the money path's trust boundary
* [Composition and the render seam](/greatbook/capabilities/composition-and-rendering) - where the second identity form is used
