Gatekeeper
A multi-tenant AI assistant where Postgres, not the model, decides what you see
Two fictional companies share one deployment, each signing in through its own identity provider, and each employee's clearance is enforced by the database rather than by asking the model to behave. A LangGraph agent holds exactly one tool, which searches inside the signed-in user's row-level-security context, so forbidden passages are not refused by the model, they are never retrieved. Every answer carries the backend's own trace of each check, and the whole access matrix is covered by tests against a real Postgres.
Verified: each of these is checkable in the repository
- 15 → 0
- forbidden passages reaching the modeldocs/SECURITY.md: three ordinary questions sent 15 of 15 with prompt-only rules, 0 with RLS
- 225
- tests against a real Postgres, plus 3 liveCounted per file: 24 matrix, 73 identity, 28 RLS, 23 SCIM and the rest. CI runs them on a fresh pgvector
- 7
- independent layers, any one of which stops a leakIdP, bridge, token re-check, least-privilege role, RLS, tool sandbox, audit
- 0
- rows visible without a role, not even publicmigrations/005_rls_role.sql: the restrictive policy's ELSE branch is false
A user can type anything into an AI chat, including an instruction to ignore its rules and show another company's financial plan. If the only thing standing between that user and the data is the model choosing to refuse, then a good enough prompt eventually gets through, and the refusal is not even the interesting failure: by the time the model declines, the data has already left the database and been placed in its context. The question is not how to make a model refuse reliably. It is how to make sure it never holds anything it should not.
Authorization lives in Postgres. Each tenant maps to the identity provider that signed its logins, so the tenant is derived from a verified issuer rather than anything the user sends. A login becomes a 15-minute RS256 token, and the user is re-read as active and same-role on every request, which is how a deactivation takes effect on the next call instead of when a token expires. Each request opens a transaction, sets the tenant and role as transaction-local settings, and runs as a database role that is not the owner and holds no BYPASSRLS. A permissive tenant policy and a restrictive role policy then filter every read. The agent runs inside that context with one tool that accepts a query string and nothing else, so the model chooses the words it searches for and has no other influence on what comes back.
Why it is built this way
01The model is never the security guard
With the rules written only into the system prompt, three ordinary questions from an employee pulled 15 of 15 forbidden passages into the model's context, including another tenant's salary bands. The model happened to refuse all three. Moving the rule into a restrictive row-level-security policy takes the same three questions to 0 retrieved. What the model never receives, it cannot repeat, and that is a property rather than a behaviour.
02An unknown role sees nothing, not even public
The restrictive policy's final branch is false rather than a public default. A request that somehow forgot to set its role returns an empty result instead of quietly degrading to the most permissive view, which is the direction a security control should fail in.
03Derive the tenant from who signed the login
The bridge maps a verified issuer to a tenant and takes the lowest role the claims support. Nothing about the tenant comes from the request body, a header or a token the client could shape, and the OIDC issuer and SAML entity ID are pinned to configuration rather than trusted from a discovery document.
04Hold the audit log at arm's length from the app
The application role has INSERT on the audit table and not SELECT, so a compromised application can write its own trail but cannot read or erase it. Each chat records the exact chunk IDs handed to the model, which is what makes the independent re-check meaningful rather than decorative.
05Turn the tool boundary into a test
The agent's schema sets additionalProperties false, so a call carrying a tenant_id or role is rejected with an error rather than silently ignored, and a test asserts the rejection. Another asserts the tool returns exactly what a direct search returns, which is the only way to know the agent path has not grown a second opinion about access.
The thing I did not expect was the approximate index. Vector search with an HNSW index returns candidates first and lets row-level security filter them afterwards, so an employee asking about salaries got zero results rather than the five public ones they were entitled to: the index had handed back only rows the policy then removed. Enabling iterative scans fixed it. It taught me that adding an authorization layer underneath a retrieval system changes the retrieval system's behaviour, and that correctness here was not just about what leaks out but about what stops coming back.
Next project
Gatekeeper Enclave
The same assistant, sealed into a GCP network with no route to the internet