Tenant isolation
Two independent layers, either of which would be sufficient — and why that is the point.
The organization is the tenant boundary. Every resource in Veefore is reachable only through the organization that owns it.
Two layers enforce it, independently.
#Layer 1 — the repository scope guard
Every query in the data layer requires a tenant scope. Not "should have" — requires. A query without one is refused before it reaches the database.
ts// Every repository method takes a scope as its first argument.
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 reported as a tenant mismatch rather than as a missing resource, so it is distinguishable in the audit trail from a typo.
That distinction is the difference between noticing enumeration and not. A
404 looks like someone mistyped an id; a stream of TENANT_MISMATCH looks
like someone is iterating.
Crossing the boundary requires an explicit, named justification in code. Background jobs use it — a job queue is inherently cross-tenant — and request handlers do not.
A real bug this caused
Every background job claim hit the scope guard, because claiming a job means reading across tenants. The error was swallowed by the poll loop, and 67 jobs wedged every queue permanently with no alert.
The fix was to make the cross-tenant intent explicit at the call site rather than to weaken the guard. Claiming now carries a justification, abandoned jobs are reclaimed and dead-lettered, and overlapping poll ticks are prevented.
Worth knowing because it illustrates the trade: a guard strict enough to catch real mistakes will also catch legitimate exceptions, and the right response is to name the exception rather than to loosen the rule.
#Layer 2 — PostgreSQL row-level security
iniDATABASE_ENABLE_RLS=true
Migration 0009_row_level_security.ts applies RLS policies to every
tenant-scoped table. With it enabled, the database itself refuses to return rows
outside the current tenant context, regardless of what the query asked for.
Enable it in production
Layer 1 already requires a tenant scope on every query and refuses without one. RLS exists so that a bug in layer 1 — a query that forgets its scope, a new repository method that does not inherit the guard, a raw query someone added in a hurry — does not become a cross-tenant data leak.
Two independent layers, each sufficient on its own. The point of defence in depth is that you do not have to be confident the first layer is perfect.
#The hierarchy
textOrganization ← the isolation boundary
└── Workspace ← scoping, not isolation
└── Environment ← scoping, not isolation
Workspaces and environments divide resources within a tenant. They are useful for scoped role grants and for separating deployment tiers, and they are not a security boundary between tenants — because they are inside one.
One organization per tenant
Do not use organizations to separate your own teams. It fragments members, roles, SSO and the audit trail across boundaries that were designed to be impermeable, and you end up fighting the isolation you asked for.
Use workspaces for teams. → Tenancy
#Scope headers
httpGET /v1/agents
Authorization: Bearer aos_sk_…
x-veefore-workspace: wsp_01JAV…
x-veefore-environment: env_01JAV…
Headers select a scope within your organization. They cannot select another organization — the tenant comes from the authenticated session, never from a header.
Supplying a workspace or environment you have no access to is refused. These are a filter over what you can already see, not a way to see more.
#Agents are bound to an environment
An agent belongs to exactly one environment. A staging identity cannot act in production — not because a policy forbids it, but because the agent is not there.
Structural beats configured
There is no rule to misconfigure and no setting to get wrong. The agent simply does not exist in the other environment.
That instinct runs through the design: prefer a guarantee that is structural over one that is configured, because configuration drifts and structure does not.
#Cross-tenant A2A
A2A is the one place agents from different organizations interact, and it is built so that interaction does not weaken isolation.
- A connection must be explicitly registered, with the remote party's public key.
- It starts
PENDINGand stays unusable until the remote passport verifies against that key. - Every invocation runs the full twelve-stage pipeline on your side.
- Blocking is immediate and applies to in-flight sessions.
A verified remote agent gets to invoke the capabilities you registered on the connection, under your policy. It gets no access to your data model, no query surface, and no visibility into anything else in your tenant.
#What is shared across tenants
Almost nothing, and it is worth being precise about the exceptions:
| Shared | Why it is safe | |
|---|---|---|
| The platform signing key | yes | Public half only; signs passports. Knowing it grants nothing |
| The gateway audience | yes | Published deliberately. It is the value you sign for |
| Rate limit buckets | no | Keyed per tenant |
| Nonce tracking | no | Keyed per agent |
| The job queue | table-level | Rows are tenant-scoped; claiming carries an explicit justification |
| Error codes and messages | yes | Static text, no tenant data |
#Verifying it
bashnpm run test:security # cross-tenant access cases
npm run test:integration # repository scope guard behaviour
npm run smoke # signup through enforcement, two organizations
The security suite includes cases that attempt to read, update and act on another organization's resources through every surface that accepts an id.
#Operational checklist
- [ ]
DATABASE_ENABLE_RLS=truein production - [ ] One organization per tenant
- [ ] Role grants scoped to a workspace where the team only needs one
- [ ]
TENANT_MISMATCHin the audit trail reviewed, not ignored - [ ] A2A connections registered with an out-of-band key, and re-verified on a schedule