# Tenancy Isolation (/docs/auth/tenancy-isolation)



Ductor is multi-tenant from the ground up: every pool, rule, connection, workflow
run, and API key belongs to a tenant. Isolation is the guarantee that one tenant
can never read or mutate another's data — and it is enforced at the request
boundary, not left to application code to remember.

<Callout title="Isolation vs entitlements">
  Isolation is &#x2A;whose data is this?*; [entitlements](/docs/auth/entitlements) are
  &#x2A;how much may this tenant do?*. Isolation is what makes an entitlement limit
  meaningful. This page covers the boundary; see also
  [Concepts → Tenancy](/docs/concepts/tenancy).
</Callout>

## How the tenant is determined [#how-the-tenant-is-determined]

Every authenticated request must resolve to exactly one tenant. The tenant
interceptor resolves it from a **priority cascade**:

1. **JWT / Principal claims.** The authenticated `Principal.TenantID` (from the
   token's `tenant_id` claim, or an API key's owning tenant) is authoritative and
   checked first.
2. **`X-Tenant-ID` header.** For direct Connect/HTTP requests, the
   `X-Tenant-ID` header supplies the tenant when the credential is not itself
   tenant-scoped (for example a service token or an anonymous/dev deployment).
3. **gRPC metadata.** The `x-tenant-id` metadata equivalent, used on the
   gateway loopback path.

If none of these yields a tenant, the request is refused
(`tenant ID required: set X-Tenant-ID header or authenticate with a
tenant-scoped token`). The resolved value is then **format-validated** (it must be
a well-formed tenant ID) and the tenant is **state-checked** — a tenant that does
not resolve to an active row is rejected before the handler runs.

<Callout title="A tenant-scoped token wins over the header">
  Because claims are consulted first, a caller holding a token whose `tenant_id`
  claim is tenant A cannot escape into tenant B by adding an `X-Tenant-ID: B`
  header — the claim already fixed the tenant. The header only supplies a tenant
  when the credential does not carry one.
</Callout>

### Exempt methods [#exempt-methods]

A small, explicit set of methods runs without tenant enforcement — the health
probes (`HealthService/Health`, `HealthService/Ready`) and tenant bootstrap
(`TenantService/CreateTenant`, which by definition has no tenant yet). Everything
else requires a resolved, valid, active tenant.

## The cross-tenant boundary [#the-cross-tenant-boundary]

Resolving *a* tenant is not enough; the authorizer also verifies that the caller
may act on the **target** tenant of the request:

* **Self-match.** For a normal request, the target tenant equals the principal's
  tenant, and the boundary check passes as a same-tenant self-match.
* **Platform-admin crossing.** A principal holding the deployment-issued
  `platform-admin` role may cross tenant boundaries through the governed control
  plane. Tenant `admin` remains confined to its own tenant.
* **Violation.** Any other case where the principal's tenant does not match the
  target is a **tenant boundary violation**, logged and denied.

### Control-plane RPCs carry a target tenant in the body [#control-plane-rpcs-carry-a-target-tenant-in-the-body]

Some administrative RPCs act *on* a tenant named in the request body (not the
caller's own). For those, the interceptor resolves the **resource tenant** from the
declared body field (or a registered resolver) and runs the boundary check against
that real target — never silently against the caller's own tenant.

<Callout title="Declared-but-unresolvable fails closed">
  If a method declares that it takes a target tenant from the request but the field
  is missing or empty, the request is **denied** rather than falling back to the
  principal's tenant. A control-plane method that cannot determine its target
  tenant must not degrade into a same-tenant self-match — that would be a
  cross-tenant escalation. Streaming handlers, which cannot peek the first message,
  resolve to *unresolved* for body-tenant methods and therefore fail closed.
</Callout>

## Isolation extends to secrets [#isolation-extends-to-secrets]

Tenant isolation is not only a request-time check — it is baked into how connector
credentials are stored. Each credential blob is AEAD-encrypted with **tenant-bound
associated data** (`tenantID | providerKey`), so a ciphertext decrypted under the
wrong tenant fails loudly. That blocks a cross-tenant credential swap even if a
blob were somehow moved between tenants. See
[Hardening → Connector credential encryption](/docs/auth/hardening#connector-credential-encryption).

## Isolation guarantees, summarized [#isolation-guarantees-summarized]

* **Every** authenticated request is pinned to exactly one validated, active
  tenant, or it is refused.
* A tenant-scoped token's claim is authoritative and cannot be overridden by a
  header.
* Cross-tenant access requires the deployment-issued `platform-admin` role;
  tenant `admin` and all other mismatches are denied.
* Control-plane RPCs check against the real target tenant and fail closed when it
  cannot be resolved.
* Credential secrets are cryptographically bound to their tenant at rest.
