Fabriq
1.x
Docs/Fabriq/Tenancy
Open

Reading13 min
Updated2 Aug 2026
Sourcev1/(concepts)/tenancy.mdx

Every access in fabriq is tenant-scoped. The tenant identity rides on context.Context, is stamped structurally into every engine, and is backstopped by a database-level hook that turns any leak into a loud, counted error. This page covers the three enforcement layers, how the tenant gets onto the context, and the one place row-level security cannot reach.

The tenant lives on context01

The tenant is carried on context.Context and nowhere else. Only auth middleware stamps it — from validated claims, never from a forwarded header — using tenant.WithTenant, which validates the id at stamp time so every name later derived from it (graph names, index names, stream keys, cache prefixes) is safe by construction:

ctx, err := tenant.WithTenant(ctx, "acme")
// ids must match ^[a-zA-Z0-9_-]{1,64}$, else WithTenant errors

Every fabric entry point calls tenant.Require(ctx) first and fails with ErrNoTenant on an unstamped context. The command executor, channel resolution, and the relational adapter all begin with this assertion.

Warning

An unstamped context is rejected before it reaches any store. f.Exec, f.Subscribe, f.Relational().Get, and WaitForProjection all return fabriq.ErrNoTenant if no tenant was stamped. There is no implicit "default tenant" — missing tenant context is a hard failure, by design. A process that serves a single customer pins its one tenant at the boundary; see Single-tenant-per-instance deployments.

How a tenant is created02

There is no provisioning step — no CreateTenant call, no tenants table, no registration or onboarding flow inside fabriq. A tenant is its identifier: a string that satisfies ^[a-zA-Z0-9_-]{1,64}$. It comes into existence implicitly the first time a stamped context writes data:

ctx, _ := tenant.WithTenant(ctx, "acme") // validates the id; no I/O, no row written
f.Exec(ctx, cmd)                          // "acme" now owns aggregate, event, and outbox rows

From that first write, the tenant's rows are isolated by every layer below — RLS, per-engine stamping, the backstop — without any registry to keep in sync. This is deliberate: minting a tenant is free and coordination-free, which is what makes sharding (route by tenant id, no catalog) and single-tenant-per-instance deployments work without a control plane.

The one exception is catalog mode: when a tenant gets its own physical database (database per tenant) or its own schema (schema per tenant), minting one is necessarily an explicit, provisioned act — fabriq tenant provision creates and migrates the database (or schema) and flips the tenant routable.

The corollary is that fabriq cannot tell you a tenant id is unknown — only that it is malformed (WithTenant errors) or unstamped (Require returns ErrNoTenant). Authorization is your auth layer's job: deciding whether a caller may act as a given tenant happens before WithTenant, in the middleware that mints the context from a validated claim. fabriq trusts the stamped id and isolates on it; it does not authenticate it.

Because there is no registry, the list of live tenants is derived, not stored. Stores.AllTenants unions every shard's outbox-known tenants — the same discovery the reconciler and rebuild --all-tenants use:

tenants, err := stores.AllTenants(ctx) // sorted, deduped across shards

A tenant with no data is therefore simply not discoverable, and a tenant is "deleted" by deleting its rows (its id stops appearing) — there is no separate lifecycle record to reap.

Layer 1 — structural stamping plus RLS03

This is the primary guarantee. Every tenant-table operation — reads included — runs inside a transaction the Postgres adapter stamps with the context tenant:

SELECT set_config('app.tenant_id', $1, true)

The true third argument scopes the setting to the transaction (SET LOCAL semantics). RLS policies on every tenant table are declared FORCE and key on that setting, so even arbitrary SQL through the raw escape hatch (Relational().Query) cannot cross tenants — an unstamped session sees zero rows. On top of the database guarantee, the adapter also adds explicit tenant predicates to generated queries.

This layer has one operational requirement: the application must connect as a non-superuser role, because RLS never constrains superusers. The integration harness provisions a fabriq_app role accordingly.

The command executor enforces the write-side half structurally. It forces id, tenant_id, and version from context — caller-provided values for id/version are ignored, and a payload carrying a foreign tenant_id is rejected outright:

// from the command plane's prepare step
if v, ok := vals[registry.ColumnTenant].(string); ok && v != "" && v != tenantID {
	return nil, fmt.Errorf("payload tenant_id %q does not match context tenant %q", v, tenantID)
}

Layer 2 — per-engine stamping04

Postgres RLS is only one engine. Each adapter stamps the tenant in the way its engine supports, all derived from the context tenant in exactly one place (core/registry/derive.go):

EngineStamping mechanism
PostgresSET LOCAL app.tenant_id + RLS FORCE policies
FalkorDB (graph)graph-per-tenant: each tenant gets its own graph keyed tenant_{id}
Elasticsearch (search)index routing: per-tenant alias fabriq_{tenant}_{base}
Redis (fan-out / cache)key prefixes; change channels are changes:{tenant}:{scope}:{id}

Because the names come from a single derivation point and the tenant id is validated at stamp time, there is no path by which one tenant's request can name another tenant's graph, index, or channel.

Layer 3 — the grove hook backstop05

The last layer is a grove pre-query/pre-mutation hook that observes every relational query on both the transaction path and the pool path. Its policy:

  • Transaction path (InTransaction == true): allow. fabriq stamped the tenant with SET LOCAL and RLS enforces isolation in the database — a stronger guarantee than any predicate inspection. The hook merely observes this path so the trip counter stays honest.

  • Pool path: deny with ErrTenantHookTripped and a metric trip. In this architecture any pool-path access to a tenant table is a bug — the structural stamping was bypassed — so denying outright is stronger and simpler than predicate-sniffing.

func (a *Adapter) Grove() *grove.DB { return a.gdb }
// Tenant tables are NOT reachable through the raw grove handle — the
// backstop denies them; use the fabric ports.

The backstop never fires in correct operation. When it does, it means fabriq itself has a bug. The trip is exported as the Prometheus counter fabriq_tenant_hook_trips_total; a non-zero value is an alertable bug signal, not a routine condition. See the runbooks for the response.

The one RLS exception: tag_readings06

TimescaleDB's columnstore refuses tables with row security — compressed telemetry and RLS are mutually exclusive on the engine. Since compression is the entire reason Timescale is in the stack (industrial tag readings at volume are unaffordable uncompressed), the tag_readings hypertable keeps compression and drops RLS.

Tenancy there is structural plus a raw-SQL guard instead of RLS:

  1. The table is reachable only through the TSQuerier port (BulkWrite / Range), which stamps tenant_id into every statement structurally and validates the series name.

  2. It is not a registry entity, so the generic relational port cannot even name it.

  3. The raw-SQL escape hatch is guarded: any SQL referencing this unprotected table without a literal tenant_id reference is rejected with ErrTenantHookTripped (counted, alertable). Operators register the table via postgres.WithGuardedTables.

  4. Cross-tenant isolation is integration-tested.

See ADR 0006 for the full reasoning and the CREATE POLICY that would restore RLS if Timescale ever lifts the restriction.

Secondary scope (optional sub-tenant partitioning)07

Within a tenant you may further partition rows into a secondary scope — for example a "project" within a "workspace". Scope is entirely optional: an unscoped context sees all rows in the tenant, and no existing entity model needs to change. Tenant remains the hard security boundary; scope is a soft read filter.

How it works

When a table carries a nullable scope_id column and the tenant_isolation policy is created via migrations.ScopeAwareTenantPolicy, the RLS predicate becomes:

USING (
    tenant_id = current_setting('app.tenant_id', true)
    AND (
        current_setting('app.scope_id', true) = ''   -- unscoped: see all rows
        OR scope_id IS NULL                           -- shared row: always visible
        OR scope_id = current_setting('app.scope_id', true)  -- scoped row: own scope only
    )
)

An unscoped read (app.scope_id = '') sees everything in the tenant. A scoped read sees its own scope plus shared (NULL scope_id) rows. This means rows can be "promoted" to shared visibility by leaving scope_id NULL, which is the default when a request has no scope stamped.

Stamping and propagation

The Postgres adapter stamps SET LOCAL app.scope_id alongside app.tenant_id at the start of every transaction. The command executor reads the scope from context and writes it into Envelope.ScopeID; projections carry it forward to every derived row, search document, graph node property, vector embedding, and spatial geometry row. Timeseries (tag_readings) uses explicit WHERE clauses instead of RLS (same restriction as tenancy — see above).

Consumer adoption

To enable secondary scoping on your own entity table:

  1. Declare the column in your Go model with db:"scope_id" (grove tag). scope_id must be TEXT NULLABLE.

  2. Apply the policy in your migration:

    stmts := migrations.ScopeAwareTenantPolicy("my_table")
    // execAll(ctx, exec, stmts) — drop + recreate tenant_isolation policy
  3. Stamp writes by calling tenant.WithScope in the request context before f.Exec:

    ctx = tenant.MustWithScope(ctx, "project-abc")
    f.Exec(ctx, cmd)  // scope_id stamped on the envelope and all derived rows
  4. Reads filter automatically for relational, vector, spatial, and Elasticsearch. Graph traversals generated by fabriq (query.Repo) auto-inject the scope predicate; raw Cypher must add (n.scope_id IS NULL OR n.scope_id = $scope) explicitly.

Note

The Document/CRDT plane does not yet support secondary scope — it is tracked as a future extension. Scope is otherwise supported uniformly across relational, timeseries, search, vector, spatial, and graph planes.

Single-tenant-per-instance deployments08

A common embedding shape is one process per customer: each deployed instance serves exactly one tenant. fabriq still has no implicit default tenant — every entry point requires a stamped context — but a single-tenant instance can satisfy that by pinning its one tenant at the boundary. The pin is an application seam, not a fabriq feature: you stamp the same id on every inbound context, and tenant.Require is satisfied uniformly.

Because tenant ids are static per instance, use tenant.MustWithTenant (the panic-on-invalid variant intended for wiring code) so a malformed id fails loudly at boot rather than on the first query. Cover both entry surfaces — request handlers and background work — since both call into the fabric and both hit tenant.Require:

// Instance carries the one tenant this process serves, read from config/secret.
type Instance struct {
	Tenant string // must match ^[a-zA-Z0-9_-]{1,64}$
}

// Request path: stamp every inbound request with the instance tenant.
func (i Instance) Middleware(next http.Handler) http.Handler {
	return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
		ctx := tenant.MustWithTenant(r.Context(), i.Tenant)
		next.ServeHTTP(w, r.WithContext(ctx))
	})
}

// Worker / cron / startup path: no request to ride on, so stamp the base ctx.
func (i Instance) Context(ctx context.Context) context.Context {
	return tenant.MustWithTenant(ctx, i.Tenant)
}

That seam is identical regardless of how the database is laid out. The deployment choice is only which Postgres the instance opens and how hard you harden it:

Shared database, many instancesOne database per instance
Config.Postgres.DSNSame cluster for all instances; they differ only in the pinned tenantUnique DSN per instance
RoleLoad-bearing non-superuser — RLS is the only thing separating customersNon-superuser still advised, but a role slip exposes only that customer's own data
Config.Postgres.PoolSizeSmall (e.g. 4–8)Can be larger; the instance owns the database
Connection fan-inFront with PgBouncer (transaction pooling)Optional
Blast radius of a stamp bugCross-customer exposureConfined to one customer
Warning

In the shared-database model RLS is the entire isolation boundary, and it silently does nothing when the connection role is a superuser (or owns the tables without FORCE). fabriq declares its policies FORCE, so the remaining requirement is operational: the runtime must connect as a non-superuser role. Assert it at boot —

var isSuper bool
_ = pool.QueryRow(ctx,
	`SELECT rolsuper FROM pg_roles WHERE rolname = current_user`).Scan(&isSuper)
if isSuper {
	log.Fatal("fabriq: refusing to start — RLS is bypassed for superusers")
}

— and refuse to start otherwise. In the database-per-instance model this is hygiene; in the shared model it is the difference between isolation and a breach.

PgBouncer's transaction pooling is compatible with fabriq's SET LOCAL app.tenant_id: the setting is transaction-scoped and resets at commit, so it never leaks across pooled clients. Because the seam is identical in both topologies, you can start on database-per-instance (simplest, strongest isolation) and migrate selected customers onto a shared cluster later by changing only the DSN and pool size — no application code changes.

Where to go next09