---
title: Tenant isolation
description: 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`.

:::security `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.

:::note 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

```ini
DATABASE_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.

:::security 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

```text
Organization                 ← 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.

:::warning 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](/concepts/tenancy)
:::

## Scope headers

```http
GET /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.

:::security 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 `PENDING` and 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.

[→ A2A](/build/a2a)

## 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

```bash
npm 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=true` in production
- [ ] One organization per tenant
- [ ] Role grants scoped to a workspace where the team only needs one
- [ ] `TENANT_MISMATCH` in the audit trail reviewed, not ignored
- [ ] A2A connections registered with an out-of-band key, and re-verified on a schedule

## Related

:::cards

:::card Tenancy | /concepts/tenancy
The hierarchy and policy binding scope.
:::

:::card Database | /operate/database
Enabling RLS, and what migration `0009` does.
:::

:::card Threat model | /secure/threat-model
Cross-tenant attacker goals.
:::

:::card Tenant isolation ADR | /architecture/adr/0011-tenant-isolation
Why two layers, with the alternatives.
:::

:::
