Skip to content

Multi-tenancy

When one deployment serves many customers, three questions that sound alike are actually distinct — and Forze keeps them apart:

  • Who is calling — the authenticated principal (AuthnIdentity).
  • Which tenant the request belongs to — TenantIdentity.
  • How that tenant's data stays isolated from everyone else's.

Keeping these separate is what lets the same handler serve every tenant without a line of tenant-handling code in it.

Binding the tenant to a request

At the boundary — HTTP middleware, typically — a request is authenticated, its tenant resolved, and both bound to the execution context:

  1. Authenticate into an AuthnIdentity (plus an optional hint, such as a JWT tid claim).
  2. Resolve the TenantIdentity. A resolver validates any hints against the principal's actual memberships — it is authoritative, so a hint can never grant access the principal doesn't have.
  3. Bind both with ctx.inv_ctx.bind(...), so every adapter can call ctx.inv_ctx.get_tenant() on its own.

From there, adapters read the tenant themselves. A handler never threads a tenant_id through its arguments — it just asks for the orders port and gets this tenant's orders.

Don't authenticate against tenant-scoped data

Credential checks run before the tenant is resolved. If an authentication route reads a tenant-scoped store, bootstrap can deadlock. Keep the document routes used during authentication tenant-unaware.

Where to draw the boundary

Isolation is layered — you choose where one tenant's data ends and the next's begins. The same TenantIdentity can decide a marker on each row, the namespace it lives in, or the whole connection. Forze names these three tiers, weakest to strongest, and every integration reports which one its wiring actually reaches:

A bound TenantIdentity can isolate with a tenant marker, a per-tenant namespace, or a dedicated instance A bound TenantIdentity can isolate with a tenant marker, a per-tenant namespace, or a dedicated instance

Tier What tenants share Reached by
tagged one container — every record carries its tenant, a filter a forgotten predicate can leak past tenant_aware=True on a column store (a SQL tenant_id column, a graph property)
namespace a separate container each on one instance — a name boundary a query can't cross tenant_aware=True on a key/path store (a Redis key prefix, an object-store path prefix), or a per-tenant resolver (a Postgres schema, a Mongo collection, a dataset, a bucket, an index)
dedicated nothing — a separate instance per tenant a routed client resolving per-tenant credentials

The names are deliberately storage-agnostic — they classify by the isolation you get, not the switch you flip. The jump that matters is taggednamespace: a tagged marker is a filter a forgotten predicate can leak past (a SQL tenant_id column, a graph property — table partitioning included, since pruning still relies on the marker), whereas a namespace is a separate container — a name-resolution boundary a query cannot cross.

So tenant_aware=True lands on a different tier depending on the backend: a column store gains a filter (tagged), but a key/path store gains a separate key or path (namespace) — the prefix is the boundary. A per-tenant resolver, and routed clients, reach namespace and dedicated the same way everywhere.

The tenant marker (tagged)

The lightest cut: one connection, one shared container, a tenant marker. tenant_aware=True on a column store makes the adapter filter every read and stamp every write with the bound tenant — a tenant_id column on Postgres or Mongo, a property on a graph node. Correctness rests on the marker: a forgotten predicate leaks. Combining it with a stronger cut is redundant — acceptable as defense-in-depth, and startup warns when it spots the overlap.

Per-tenant containers (namespace)

A container per tenant — a name boundary a query can't cross. Two ways to reach it. On a key/path store, the same tenant_aware=True switch is already a namespace: the adapter prefixes each tenant's key or path (tenant:{id}:… on Redis, a path prefix on object storage, a per-tenant queue/stream key), so a read addresses a separate key, not a shared one with a filter. On a store with named containers, point a route's relation — or its named resource, a bucket / dataset / index — at a resolver instead of a static value, evaluated per request against the bound tenant:

PostgresDocumentConfig(
    read=lambda tid: (f"tenant_{tid.hex[:8]}", "orders"),
    write=lambda tid: (f"tenant_{tid.hex[:8]}", "orders"),
    bookkeeping_strategy="application",
)

Because the name is only known per request, startup schema validation (which needs fixed names) skips these routes.

Routed clients (dedicated)

A routed client resolves credentials per TenantIdentity and pools connections by fingerprint, so tenants that share an endpoint reuse pools. You swap it in at wiring time — RoutedPostgresClient for PostgresClient — and the specs and handlers don't change.

Postgres routed clients

Set introspector_cache_partition_key on the deps module so the schema catalog cache partitions by tenant — required when the client is routed.

Which integration reaches which tier

Each integration derives the tier its wiring reaches, and the backend sets the ceiling — an in-process store can't route a per-tenant connection. Three shapes cover it:

  • A column store (Postgres / Mongo / Firestore documents, most analytics) spans the whole ladder: tagged (column) → namespace (schema/collection resolver) → dedicated (routed client).
  • A key/path store (Redis, S3/GCS, queues) has no shared container to tag, so it starts at namespace — the per-tenant key or prefix is tenant_aware — and reaches dedicated with a routed client.
  • An in-process backend caps low: DuckDB analytics at tagged, the mock at namespace, with no routed-client equivalent.

The exhaustive per-port breakdown — every integration, its mechanism, and its ceiling — is the tenancy matrix.

Isolating messaging you consume

A store you query under the bound tenant scopes itself. A stream or queue you drain in the background does not: the consumer runs with no ambient tenant, so isolating it means binding the tenant on the read side — a sharded gateway/relay for realtime, or a per-tenant worker for a queue. The outbox stays tenant-global by default (rows carry their tenant for routing, not isolation; the relay binds each row's tenant as it forwards). The per-resource read-side rules are in the tenancy matrix.

Declaring a minimum

Deriving a tier is descriptive. You can also make it prescriptive: set required_tenant_isolation on any deps module and wiring refuses to assemble anything weaker — a fail-closed floor checked once, at startup, never per request.

PostgresDepsModule(
    client=RoutedPostgresClient(...),
    required_tenant_isolation="dedicated",  # nothing short of a per-tenant connection
)

Each module derives the tier it actually reaches from the config it already carries — a routed client → dedicated, a per-tenant resolver → namespace, tenant_awaretagged — and raises a clear configuration error when that's below the floor. A floor a backend can never reach (dedicated on in-process DuckDB, or on single-client Neo4j) fails as a capability mismatch rather than a silent misconfiguration, because each integration's ceiling is known. Leave it unset (the default) and nothing is enforced.

Where the floor earns its keep

Untrusted or self-scoping query paths — a raw SQL hatch, an analytics query trusted to filter itself — are only as safe as the store underneath them. Declaring required_tenant_isolation="dedicated" refuses to wire them anywhere a shared store could leak.

Provisioning per-tenant infrastructure

The stronger tiers assume the per-tenant container already exists — a schema, a dataset, a bucket. Onboarding a tenant should create it; offboarding should tear it down. TenantProvisionerPort is that seam, wired through the tenancy module:

from forze.application.integrations.storage import ObjectStorageTenantProvisioner
from forze_identity.tenancy.execution import TenancyDepsModule

TenancyDepsModule(
    tenant_management={"main"},
    tenant_provisioner=ObjectStorageTenantProvisioner(
        client=s3_client,
        bucket=lambda tid: f"tenant-{tid}",
    ),
)

TenantManagementPort.provision_tenant(...) records the tenant first, then runs the provisioner — a failure leaves the record for an idempotent retry — and deprovision_tenant(...) runs the inverse. Provisioners are idempotent and receive the onboarded TenantIdentity explicitly: it is generally not the ambient bound tenant, since an admin onboards tenant X without acting as X. Compose one per integration with CompositeTenantProvisioner, wrap a callable with FunctionTenantProvisioner, or ship nothing (NoopTenantProvisioner, the default) and provision out of band. Forze includes ObjectStorageTenantProvisioner (ensures a bucket) and, from forze_postgres, PostgresSchemaTenantProvisioner (CREATE SCHEMA IF NOT EXISTS) — teardown is opt-in wherever it would destroy data.

Isolating tenants by data boundary pairs naturally with isolating them by key — sealing each tenant's data under its own keys is Encryption.