rithik.ghantasala
← Work/Full-Stack2026

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
01The problem

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.

02How it works

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.

POSTGRES DECIDES, NOT THE MODELOkta · AcmeOIDC + PKCEKeycloak / EntraSAML 2.0Auth bridgetenant from issuer onlyAccess tokenRS256 · 15 min · scopesFastAPI /chatlive user re-checkaudit_logINSERT onlyLangGraph agentone tool: {query}Claudenever sees identityPostgres · FORCE RLStenant AND role policyIndependent re-checkforbidden chunks = 0
Gatekeeper, drawn from the source, not the README. Scroll to trace the flow.
03Decisions worth defending

Why it is built this way

  1. 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.

  2. 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.

  3. 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.

  4. 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.

  5. 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.

04What I took from it
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.
05Stack
PythonFastAPIPostgreSQL 16pgvectorRow-level securityOIDCSAML 2.0SCIM 2.0LangGraphClaudeNext.jsKeycloak

Next project

Gatekeeper Enclave

The same assistant, sealed into a GCP network with no route to the internet