Separate tenants in the database
Separation comes first, because every later check assumes that a query sees only its own tenant's rows. Postgres can enforce that for every query, whether or not the query remembers its filter, so you put the rule there.
Study first
- An agent can become a confused deputy, which uses its caller's authority on instructions embedded in data, such as a ticket, a page or a document. So treat everything the model reads that a person did not type as untrusted input, and enforce permissions in code, because a prompt cannot. The Open Worldwide Application Security Project (OWASP) keeps a Top 10 list of the risks to assess in applications built on large language models.
- Isolation applies to every store: rows, the vector index, caches and traces. A tenant is how WorkBot keeps one customer's data and settings apart, and you filter by tenant before ranking, never after.
- Postgres row-level security puts a policy on a table, and forcing the policy makes it bind the table's owner too. A connection setting names the tenant, so 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
tenantfor the customer. WorkBot's traces and cache had atenantcolumn that held the caller, which was a visitor, the terminal or the system. Rename itcallerin the code, with a migration that renames the column only wherecalleris still missing. Thentenantcan mean the customer whose deployment wrote a row. - Add the last statements of
src/lib/schema.ts. Create the roleworkbot_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. - In
src/lib/db.ts, let the owner apply the schema, and start every other connection 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. - Write tests where 2 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, and a connection with no tenant reads and writes nothing.
workbot_appowns no table and bypasses nothing, every table forces its policy, and the whole schema applies twice to the same database.
Done when
- The suite passes with the app querying as
workbot_app. - Every local row carries the deployment's tenant.
Technical notes
- Guard the rename on
callerbeing missing, not on atenantcolumn being present. Step 2 adds atenantcolumn, so a guard that looks for one renames the new column on the second start, and the start fails. The test that applies the schema twice catches that class of mistake. - Do not let an empty
app.tenantcount as a tenant named "", or a write without a tenant succeeds. The default and the policy usenullif(current_setting('app.tenant', true), ''), so 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, but not against someone who has 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.






