Part 1
Tenants, separated by the database
Study first
- The threat model for an agent: a confused deputy uses its caller's authority on instructions embedded in data, a ticket, a page, a document. Treat everything the model reads that a person did not type as untrusted input, and enforce permissions in code: a prompt cannot. The OWASP Top 10 for LLM applications lists the risks to assess.
- Isolation in every store: rows, the vector index, caches, traces. Filter by tenant before ranking, never after.
- Postgres row-level security: a policy on a table, forced so it binds the table's owner too, and a connection setting that names the tenant. A query that omits its filter still sees only its tenant's rows. A superuser, and any role with
BYPASSRLS, ignores every policy, so the app must query as a role that owns nothing and bypasses nothing.
Build
- Free the word. WorkBot's traces and cache had a
tenantcolumn that held the caller, a visitor, the terminal or the system; rename itcaller, in the code and with a migration that renames the column only where it is still missing, sotenantcan mean the customer whose deployment wrote a row. - The last statements of
src/lib/schema.ts: createworkbot_app, then loop over every table to add atenantcolumn that defaults to the connection'sapp.tenant, enable and force row-level security, and add one policy. A table added later is covered without a statement of its own. - Add the tenant to every natural key (
tickets,contacts,chunks,prompts,cache,syncs,filings) and to theon conflicttargets that name them. src/lib/db.ts: the owner applies the schema; every other connection starts with-c role=workbot_app -c app.tenant=<customer>. An embedded database has one connection, so the owner steps down to the role on it.- Tests: two tenants on one database read only their own rows and keep separate copies of the same issue number; a write as another tenant is refused; a connection with no tenant reads and writes nothing;
workbot_appowns no table and bypasses nothing; every table forces its policy; the whole schema applies twice to the same database.
Done when
- The suite passes with the app querying as
workbot_app, and every local row carries the deployment's tenant.
Technical notes
- The rename's first guard looked for a
tenantcolumn, which step 2 adds, so the second start would have renamed the new column and failed; the schema-twice test catches that class of mistake. - An empty
app.tenantfirst counted as a tenant named "", so a write without one succeeded; the default and the policy usenullif(current_setting('app.tenant', true), ''), and such a connection fails closed. - Knowledge-base search runs on the same connection, so the policy restricts its candidate rows before ranking.
- The separation holds against the app's own queries, not against someone holding the database credentials, who can set
app.tenantor connect as the owner. A customer who needs that gets a database login of its own, or a database.






