Veefore docs

ADR 0011: Tenant isolation

A repository layer that refuses an unscoped query, plus row-level security underneath it — either of which would be sufficient.

#Context

Veefore is multi-tenant, and a cross-tenant read is the worst possible bug in a security product. One organization seeing another's agents, policies, credentials metadata or audit trail is not a degradation; it is the end of the product's credibility.

The standard approach is to scope every query by organization id and review carefully. The standard failure is one query that forgets.

#Decision

Two independent layers, each sufficient on its own.

#Layer 1 — the repository scope guard

Every method in the data layer requires a tenant scope as its first argument. Not "should have" — a query without one is refused before it reaches the database.

ts
await ctx.repos.agents.findById({ organizationId: actor.organizationId }, agentId);

Passing an id from another organization returns nothing, and a request that attempts it is refused with TENANT_MISMATCH.

TENANT_MISMATCH, not 404. A cross-tenant attempt is distinguishable in the audit trail from a typo. A 404 looks like a mistyped id; a stream of TENANT_MISMATCH looks like enumeration.

Crossing the boundary requires an explicit, named justification in code. Background jobs use it; request handlers do not.

#Layer 2 — PostgreSQL row-level security

DATABASE_ENABLE_RLS=true, applied by migration 0009 to every tenant-scoped table. The database itself refuses to return rows outside the current tenant context, regardless of what the query asked for.

#Structural reinforcement

An agent belongs to exactly one environment. A staging identity cannot act in production — not because a rule forbids it, but because the agent is not there.

The tenant never comes from a header. It comes from the authenticated session. Scope headers select a scope within your organization.

#Alternatives

Application-level scoping alone, with careful review. What most products do. It works right up until one query forgets, and the consequence of that one query is the worst bug in the product.

The specific weakness is additive: a new repository method, a raw query added under time pressure, a join written by someone who did not know the convention. Every one of those is a new chance, and review catches most of them.

RLS alone. Elegant: the database is the single enforcement point. It also means the application has no way to catch the mistake before the query runs, and so no useful error — a scoping bug presents as "no rows", which looks like missing data rather than a bug. And it couples correctness to the RLS policies being applied, which is a migration that can be skipped.

A database per tenant. The strongest isolation available. Also an operational model that does not scale to many tenants: migrations across thousands of databases, connection pooling per tenant, cross-tenant reporting that requires fan-out.

A schema per tenant. Lighter than a database each, with most of the same operational problems and weaker isolation.

Trust the tenant from a header or a path parameter. Convenient for a service-to-service caller. Means a tenant id is an input rather than a derived fact, and inputs can be supplied.

#Consequences

Both layers must be maintained, and RLS must actually be enabled. DATABASE_ENABLE_RLS defaults to off for local convenience, which means a production deployment can run with one layer and look fine. Called out in Configuration, Database and the isolation checklist.

The scope guard catches legitimate exceptions too. A job queue is inherently cross-tenant: claiming a job means reading across tenants.

This caused a real outage

Every background job claim hit the scope guard. The error was swallowed by the poll loop, and 67 jobs wedged every queue permanently with no alert — webhooks stopped delivering and usage stopped rolling up, and nothing visibly broke.

The fix was to make the cross-tenant intent explicit at the call site, not to weaken the guard. Claiming now carries a justification, abandoned jobs are reclaimed and dead-lettered, and overlapping poll ticks are prevented.

The trade is real: a guard strict enough to catch mistakes will also catch legitimate exceptions, and the right response is to name the exception.

A new repository method inherits the guard by signature. The scope is the first parameter, so omitting it is a type error rather than a runtime possibility. That is the property that makes layer 1 hold as the codebase grows.

TENANT_MISMATCH has to be reviewed, not just emitted. Distinguishing it from 404 only helps if someone looks. It is in the isolation checklist for that reason.

Workspaces and environments are not isolation boundaries. They divide resources within a tenant and are useful for scoped role grants. Using organizations to separate your own teams fragments members, roles, SSO and audit across boundaries designed to be impermeable — so the guidance is one organization per tenant, and it has to be repeated.

Cross-tenant A2A is the one deliberate interaction, and it is built so the interaction does not weaken isolation: an explicitly registered connection, a verified passport, your capability list, your policy, and no visibility into anything else. ADR 0010

Identity, authority, policy and enforcement for autonomous AI agents.