Tenancy & isolation
Every envelope, every row, and every action in Fibric belongs to exactly one tenant, and the database itself refuses to show one tenant's rows to another. Isolation is not an application-layer convention; it is enforced by Postgres row-level security under a non-superuser role, and it fails closed. This page explains the tenancy hierarchy, the columns that carry it, the policy that enforces it, and how to prove it holds.
The tenancy spine
Fibric models three levels of ownership: a reseller may own many tenants, and a tenant may contain many workspaces. The hierarchy comes from db/migrations/0001_tenancy.sql and is the same in every environment.
| Level | What it is | Key columns |
|---|---|---|
resellers |
A partner that runs its own branded tenants on Fibric. Carries a branding jsonb column (brand name, color, logo, domain) so white-labeling is data, not a fork. |
id, slug, name, branding |
tenants |
One customer organization. reseller_id is nullable: null means the tenant is Fibric-direct, with no reseller in between. Tenants also carry branding jsonb, and slug is a human label unique per reseller. |
id, reseller_id, slug, name, branding |
workspaces |
A working area inside a tenant: a team, a site, a project. Workspaces are tenant-scoped rows themselves, so the same isolation policy applies to them. | id, reseller_id, tenant_id, name |
Two boundaries are worth distinguishing. The tenant boundary is the hard isolation wall: it is what RLS enforces and what this page is about. The workspace boundary is organizational: workspaces partition work inside one tenant, and workspace_id travels on envelopes and rows so views and operators can be scoped to a site or a team, but the security guarantee lives at the tenant level. A workspace never weakens the wall; it only subdivides what is already inside it.
The hierarchy exists so that one deployment of Fibric can serve direct customers and reseller-branded customers side by side, with the same wall between every pair of tenants regardless of who sells to them. The event envelope carries the same pair of identifiers, so an event is born tenant-scoped before it ever reaches the database.
Every row carries the pair
The rule is uniform: every envelope and every tenant-scoped row carries both reseller_id and tenant_id. There is no table where tenancy is inferred from a join at query time; the columns are physically present so the row-level policy can evaluate them directly. The event log is the exemplar every tenant table follows. This is its real DDL from the migration:
-- an example tenant-scoped table (the event log) — every tenant table follows this shape
CREATE TABLE IF NOT EXISTS event_log (
id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
reseller_id uuid,
tenant_id uuid NOT NULL REFERENCES tenants(id),
workspace_id uuid,
source text NOT NULL,
event_type text NOT NULL,
correlation_id uuid NOT NULL,
payload jsonb NOT NULL DEFAULT '{}',
created_at timestamptz NOT NULL DEFAULT now()
);
CREATE INDEX IF NOT EXISTS event_log_tenant_created_idx ON event_log (tenant_id, created_at DESC);
The columns mirror the EventEnvelope exactly: reseller_id, tenant_id, workspace_id, source, event_type, correlation_id, payload. When the kernel persists an envelope, it writes those fields straight through with no translation layer. A row that reaches disk without a tenant cannot exist: tenant_id is NOT NULL and references the tenants table.
A tenant id alone would isolate tenants from each other, but not resellers from each other. Carrying reseller_id on every row lets the policy assert both facts at once: this row belongs to this tenant, and this tenant belongs to this reseller (or to no reseller, for Fibric-direct tenants). The null case is handled explicitly in the policy below.
Postgres row-level security
Isolation is enforced where the data lives. The migration enables row-level security on every tenant-scoped table and adds a policy that compares the row's tenancy columns against two request-scoped settings, app.tenant_id and app.reseller_id. This is the real policy from the migration, verbatim:
-- RLS: default-closed, enforced even for the table owner (so CI tests are honest)
ALTER TABLE event_log ENABLE ROW LEVEL SECURITY;
ALTER TABLE event_log FORCE ROW LEVEL SECURITY;
CREATE POLICY event_log_tenant_isolation ON event_log
USING (
tenant_id = nullif(current_setting('app.tenant_id', true), '')::uuid
AND reseller_id IS NOT DISTINCT FROM nullif(current_setting('app.reseller_id', true), '')::uuid
);
Three details in this policy carry the guarantee.
ENABLEplusFORCE. Enabling RLS constrains ordinary roles; forcing it constrains the table owner too. That means the isolation test in CI runs against the same rules production runs against, and there is no privileged code path where the policy quietly does not apply.nullif(current_setting(..., true), ''). The second argument tocurrent_settingmakes a missing setting return an empty string instead of raising, andnullifturns that empty string into null. A null on the left oftenant_id = …matches no row. So a session that never set its tenant context sees an empty table, not an error and not everything: fail closed.IS NOT DISTINCT FROMfor the reseller. Ordinary equality treatsnull = nullas unknown, which would lock Fibric-direct tenants (whosereseller_idis null) out of their own rows.IS NOT DISTINCT FROMtreats two nulls as equal, so a direct tenant with no reseller context matches its own null-reseller rows and nothing else.
The application connects as fibric_app, a non-superuser role created by the same migration with no BYPASSRLS attribute. It holds SELECT, INSERT, UPDATE, DELETE on the tenant tables and read-only access to resellers and tenants. There is no role in the request path that can see across the wall.
Application queries against tenant tables carry no WHERE tenant_id = … clause. The policy supplies it, on every statement, for every role without BYPASSRLS. That removes the class of bug where one handler forgets the filter: there is no filter to forget.
How the tenant context is set
The two settings the policy reads are transaction-local. They are set at the start of each transaction with set_config(…, …, true), where the final true scopes the value to the current transaction. When the transaction ends, the context evaporates. There is no session-level state to leak between requests sharing a pooled connection.
Critically, the value comes from a verified source, a validated JWT claim in production, never from a client-supplied header. A caller cannot name its own tenant. This is the withTenant pattern from the kernel's PgEventStore (packages/kernel/src/store-pg.ts):
private async withTenant<T>(ctx: TenantCtx, fn: (client: PgClientLike) => Promise<T>): Promise<T> {
const client = await this.pool.connect();
try {
await client.query('begin');
// transaction-local GUC from a VERIFIED context (here the envelope; in prod a JWT claim)
await client.query("select set_config('app.reseller_id', $1, true)", [ctx.reseller_id ?? '']);
await client.query("select set_config('app.tenant_id', $1, true)", [ctx.tenant_id]);
const out = await fn(client);
await client.query('commit');
return out;
} catch (e) {
await client.query('rollback').catch(() => {});
throw e;
} finally {
client.release();
}
}
Every read and write in the store runs inside this wrapper. There is no code path that queries a tenant table outside a tenant transaction, and if one existed, the policy would return it zero rows anyway. The wrapper and the policy are redundant on purpose: the application sets the context correctly, and the database refuses to cooperate if it does not.
A request that arrives without a valid tenant context does not fall back to a default tenant, a shared view, or an error page with data in it. It reads nothing. An empty result from a missing context is the designed behavior, and it is what the isolation test asserts.
What isolation buys you
Row-level tenancy is not only a data-protection measure. It is the substrate that lets the rest of the platform make per-tenant promises.
- Per-tenant data walls. Two tenants on the same deployment share hardware and nothing else. A query bug, an injection, or a misrouted request surfaces zero foreign rows, because the database evaluates the policy on every row it would return.
- Operators are tenant-bounded. An operator senses through the same store, inside the same tenant transaction, so it only ever sees and proposes against its own tenant's data. Its
ExecutionPlancarries the tenant, and the executor acts within it. There is no capability an operator can request that reaches across the wall. - Per-tenant credentials. Connector credentials live in the secret store keyed by tenant. The connector context (
ConnectorCtx) carriestenant_idandreseller_id, and its HTTP client resolves credentials for that tenant alone. One tenant's API key never serves another tenant's request. - Per-tenant model policy. The ModelRouter seam accepts a
ModelPolicywith aperTenantoverride, so requirements like a US-hosted model for one tenant are configuration, not code. The routing decision keys off the same verified tenant identity the database uses.
Proving it
An isolation claim you cannot test is a hope. The kernel's store ships visibility checks, countVisible(ctx) and distinctTenantsVisible(ctx), that run an unfiltered query inside a given tenant context and report what the policy actually let through:
async countVisible(ctx: TenantCtx): Promise<number> {
return this.withTenant(ctx, async (client) => {
const r = await client.query('select count(*)::int as n from event_log');
return r.rows[0].n as number;
});
}
async distinctTenantsVisible(ctx: TenantCtx): Promise<string[]> {
return this.withTenant(ctx, async (client) => {
const r = await client.query('select distinct tenant_id::text as t from event_log');
return r.rows.map((x: any) => x.t as string);
});
}
Note what those queries do not contain: a WHERE tenant_id = … clause. The filtering is the policy's job. The isolation test the kernel ships is built on that fact: write rows as tenant A, then run the same unfiltered count under tenant B's context. The expected results are exact.
Because RLS is FORCEd, this test is honest even when CI connects as the table owner. A cross-tenant read under a foreign context returning zero rows is not a mocked assertion; it is Postgres executing the same policy production executes. Every governed action additionally leaves a tenant-scoped receipt, so the audit trail itself lives behind the same wall it documents.
What Enterprise adds
The isolation model above is identical on every plan. Enterprise adds operational and contractual surfaces on top of it, not a different wall.
| Capability | What it is |
|---|---|
| Audit exports | Scheduled exports of receipts and events to an S3 bucket you own, so your auditors work from your copy of the ledger, on your retention schedule, without querying the platform. |
| Contractual retention windows | Agreed retention periods for events and receipts, expressed in the contract and enforced in the platform, rather than the default retention. |
| Reseller multi-tenancy | Run your own branded tenants under your reseller_id. Your tenants get the same RLS wall between each other that all Fibric tenants get; the branding jsonb on your reseller and tenant rows carries the white-label. |
| SSO / SAML | Single sign-on against your identity provider, so the verified claim that becomes app.tenant_id originates from your own directory. Preview: label configuration in the console. |
For how tenancy interacts with the rest of the governed loop, continue with the event envelope (where the pair of identifiers originates), governance & trust (how proposals are policed within a tenant), and receipts & audit (the tenant-scoped record of everything that ran).