Skip to main content
ControlFrame

Tenant isolation

Most vendors write that customer data is logically separated and stop. That sentence describes an intention and survives no serious review. Here is the mechanism, the privileges of the role it runs under, and the command that re-checks both.

The claim

What ControlFrame is prepared to have checked

The repository migration chain accounts for every public table as forced row-level security, a named by-design exception, or an explicit pending-tenancy gap. Separately dated production observations show a non-bypass application role and a cross-tenant read returning no foreign rows.
81
tables in the public schema
53
carry forced row-level security
Row security enabled AND forced, each under exactly one policy.
18
unshielded because a predicate cannot apply
Each names the reason a tenant predicate cannot apply to it.
10
unshielded with no good reason
Hold tenant work but have no org_id column to predicate on. Unfinished, not exempt.
0
neither shielded nor ledgered
The check fails if this is not zero. A document cannot notice a new table.
0
unshielded tables reachable from a web role
Grants to anon and authenticated are revoked, including default privileges for future tables.

Measured 2026-08-24 against all repository migrations applied to an empty PostgreSQL database. The command seeds probe tenants and creates a role, so it runs against a disposable database with migrations applied — it refuses a remote one unless explicitly overridden.

Re-run the census
psql "$DATABASE_URL" -Atc "select count(*) filter (where c.relrowsecurity) || ' shielded of ' || count(*) || ' tables' from pg_class c join pg_namespace n on n.oid = c.relnamespace where n.nspname = 'public' and c.relkind = 'r'"

A pure select over the system catalog. It reads nothing but table metadata, which is why it is the one figure on this page an operator can reproduce against production in a single line.

Mechanism 1 of 4

Forced row-level security, per table, under a named policy

Each shielded table carries exactly one policy named controlframe_tenant_shield, and the verification asserts that count is exactly one rather than at least one. Every store transaction sets controlframe.org_id, controlframe.project_id and controlframe.role as transaction-local settings, so they disappear on commit or rollback and cannot leak through the connection pool. Missing context sees no rows and cannot insert rows.
ENABLE ROW LEVEL SECURITY
Applies the policy to every role except the table owner. Most PostgreSQL multi-tenant deployments stop here, and their owner-role application therefore reads every tenant while the schema looks correct.
FORCE ROW LEVEL SECURITY
Applies it to the owner as well. ControlFrame asserts both flags on every shielded table, and a table with the policy enabled but not forced fails the check rather than passing it quietly.
What still gets past both
A role holding BYPASSRLS. That is not a policy problem and no amount of forcing fixes it, which is why the second mechanism exists and why it is the one that was wrong here for part of a day.
Mechanism 2 of 4

A runtime role that cannot bypass the shield

Production connects as controlframe_app through the connection pooler on 6543. It is neither a superuser nor BYPASSRLS, holds no schema-create or role-administration privilege, and carries only the table and sequence grants the application needs.
Runtime database role privileges
PropertyValue
Connecting rolecontrolframe_app
Superuserfalse
Can bypass row-level securityfalse

Measured 2026-08-17 against the production database. The command only reads and may be pointed at production.

Ask the connection what it is
psql "$DATABASE_URL" -Atc "select current_user, rolsuper, rolbypassrls from pg_roles where rolname = current_user"

Run this as the application, not as an administrator. The answer is about the role that serves requests, which is the only role the shield is enforced against.

Mechanism 3 of 4

Closed-world verification: a document cannot notice a new table

The previous posture was an inventory in a document, and a document does not go red when somebody adds a table. The verification holds a ledger of every table that carries no tenant predicate, together with the reason each one cannot, and treats the schema as a closed world: anything in the public schema that is neither shielded nor ledgered fails the run.
It fails when a table is unaccounted for
A table with no row-level security and no recorded reason stops the run. Either shield it, or name it and say why a tenant predicate cannot apply — a new table must not arrive quietly.
It fails when an unshielded table is reachable from the web
The `anon` and `authenticated` grants are revoked on every one of them, including default privileges for future tables, so nothing reaches them from the web. The verification asserts that revocation rather than trusting it — an unshielded table granted to a web role has neither a row predicate nor a revoked grant standing between it and the public internet.
It fails when a ledger entry outlives its table
A name left in the ledger after its table is shielded or dropped is a comment pretending to be a control. It would go on excusing a table that no longer needs excusing, and hide the next one that does.
It fails before it starts if the probe role is privileged
The run asserts that the role it probes with is neither a superuser nor BYPASSRLS before it asserts anything about policies, because a privileged prober proves nothing.
Run the closed-world verification
CONTROLFRAME_TEST_DATABASE_URL="$DISPOSABLE_DATABASE_URL" npm run security:tenant-shield:verify

It seeds two probe tenants and creates a least-privileged role, so it runs against a disposable database with migrations applied. It refuses a remote database unless that refusal is explicitly overridden.

Measured 2026-08-24 against all repository migrations applied to an empty PostgreSQL database. The command seeds probe tenants and creates a role, so it runs against a disposable database with migrations applied — it refuses a remote one unless explicitly overridden.

Mechanism 4 of 4

An adversarial cross-tenant read, run against the live database

Per-table verification proves the policies exist. It does not prove that a foreign organization gets nothing back. So the same project identifier was read three times under three different organization contexts, and the rows below are what came back. Identifiers are omitted deliberately: the mechanism and the shape are publishable, the tenant census is not.
Cross-tenant read probe against the production database
Organization context setAudit runs visibleEvidence objects visible
The organization the project belongs to110
A foreign organization, same project identifier00
A second foreign organization, same project identifier00

Measured 2026-08-18 against the production database. The command only reads and may be pointed at production.

Reproduce the probe
psql "$DATABASE_URL" -v ON_ERROR_STOP=1 <<'SQL'
begin;
select set_config('controlframe.org_id',     '<org_id>',     true),
       set_config('controlframe.project_id', '<project_id>', true);
select count(*) as audit_runs        from audit_runs;
select count(*) as evidence_objects  from audit_evidence_objects;
rollback;
SQL

It sets the same transaction-local settings the application sets, counts what comes back, and rolls back, so nothing is written. One of the two foreign contexts owned that data an hour before the tenants were separated and cannot reach it now.

The exceptions, named

The 18 tables that carry no tenant predicate, and why each cannot

A tenant shield cannot be applied to a table the runtime must read in order to discover which tenant the caller belongs to: the policy would refuse the very query that establishes the context the policy needs. That is a real constraint, and it applies to exactly the identity and tenant-resolution substrate below. The reasons are reproduced from the verification ledger itself rather than paraphrased, because a paraphrase drifts silently and a byte comparison does not.

Auth and session substrate

6 of 18

Read on the request path before any tenant is known — a tenant predicate here would refuse the query that establishes the caller at all.

Auth and session substrate
TableReason a tenant predicate cannot apply
accountsauth adapter storage, read before a tenant is known
sessionsauth adapter storage, read before a tenant is known
verification_tokensauth adapter storage, read before a tenant is known
identity_sessionsthe table a session is resolved from
auth_rate_limit_statesconsulted before the caller is identified at all
auth_replay_noncesconsulted before the caller is identified at all

Identity

4 of 18

The rows a person is resolved from. An invitee has no tenant until the invitation resolves.

Identity
TableReason a tenant predicate cannot apply
user_accountsthe table a caller's identity is resolved from
identity_invitationsan invitee has no tenant until the invitation resolves
identity_security_eventswritten on auth paths that run before context
identity_password_resetsa person who needs a password reset is exactly the person who cannot sign in, so this is read before any tenant is known — the same reason as identity_invitations directly above

Tenant resolution

4 of 18

The tables a tenant is resolved FROM, and the table that answers which tenants a caller may enter. A predicate on these refuses the query that chooses the tenant.

Tenant resolution
TableReason a tenant predicate cannot apply
organizationsthe table a tenant is resolved FROM
projectsthe table a tenant is resolved FROM
project_membershipsmembership is resolved before a tenant is known
organization_membershipsthe table that answers WHICH tenants this caller may enter, so it is read before any of them is known — a tenant predicate on it would refuse the very query that chooses the tenant

Presented in order to obtain context

1 of 18

A runner presents this to obtain its context; it cannot require one first.

Presented in order to obtain context
TableReason a tenant predicate cannot apply
runner_tokensa runner presents this to obtain context; it cannot need it

Cross-organization or tenant-free by design

3 of 18

Carries no tenant column at all, either because it is infrastructure or because it is deliberately cross-organization and is owed a different predicate rather than this one.

Cross-organization or tenant-free by design
TableReason a tenant predicate cannot apply
assessor_disposition_corpus_eventsthe cross-organization corpus by design: carries neither org_id nor project_id, and is owed a different predicate rather than this one
sme_training_contributionscarries org_id but no project_id: a contribution to the shared training corpus is not scoped to one project, so this predicate does not describe it and a different one is owed — same family as assessor_disposition_corpus_events
_prisma_migrationsthe migration ledger itself; carries no tenant data
The honest consequence
Reads of these eighteen are scoped by application code alone. One of them, `projects`, is where that went wrong in August 2026. Every query against the eighteen is now treated as unguarded by default.
The exceptions with no excuse

The 10 tables that carry no tenant predicate and have no good reason

Everything above is a constraint. Nothing here is. Each of these holds or will hold per-tenant work and simply has no org_id column to predicate on, so the shield cannot be written against it. They are unfinished, not exempt. They were also invisible until 23 August 2026: the CI check that enumerates them stopped at migration 30 of 36 for three days, and a published count that cannot fall behind is the whole claim of this page.
Unshielded with no good reason
TableWhy it has no tenant predicate yet
agent_workflow_runswritten by lib/agents/run-store.ts on every workflow run and carries no tenant column at all — agent history is stored today with no tenancy
AgentRunwritten by lib/agents/run-store.ts alongside agent_workflow_runs; same missing tenant column, same consequence
Artifactlegacy pre-ControlFrame baseline, still read by the demo mapping surface
Controllegacy pre-ControlFrame baseline, still read by the demo mapping surface
Gaplegacy pre-ControlFrame baseline, still read by the demo gap surface
Repositorylegacy pre-ControlFrame baseline; retained so the migration chain replays
Mappinglegacy pre-ControlFrame baseline; retained so the migration chain replays
ActivityEventlegacy pre-ControlFrame baseline; retained so the migration chain replays
CollectorJoblegacy pre-ControlFrame baseline; carries projectId but no org_id, so the shield's predicate cannot be written against it
CollectorJobEventlegacy pre-ControlFrame baseline; child of CollectorJob, same gap
What is owed
These ten are not exempt — they are unfinished. Two of them, `agent_workflow_runs` and `AgentRun`, are written on every agent run today and carry no tenant column, so agent history is stored with no database-enforced tenancy. The other eight are the pre-ControlFrame baseline, three of which are still read by demo surfaces. Clearing an entry means adding the tenant columns and shielding the table in the same migration; until then each is protected only by revoked web grants and application-code scoping.
The break-glass arm

A role that reads every organization, and the count of who holds it

Every tenant policy carries the same escape arm: a caller whose controlframe.role setting reads super-admin is exempt from the predicate. Publishing that is counterintuitive and correct. A buyer who finds it in a penetration test has caught us concealing it; a buyer who reads it here has watched us think about it. Removing it would push the same capability to a database superuser, which is the same power with fewer gates. What makes it defensible is that somebody can say, on demand and without reading code, whether it is currently in anyone’s hands — and the answer today is nobody.
0
accounts hold the break-glass role
Counted across user_accounts, identity_invitations and project_memberships. FORCE-RLS auditor_users is migration-constrained to the non-break-glass auditor role and is not bypass-read by this production-safe census.

Measured 2026-08-17 against the production database. The command only reads and may be pointed at production.

Re-count the holders
CONTROLFRAME_VERIFY_DATABASE_URL="$DATABASE_URL" npm run security:super-admin:verify

Read-only by construction — every assertion is a pure select — which is why this is the security check in this repository that may be pointed straight at production.

Three doors could grant it. Two are closed structurally.

Break-glass grant paths
Grant pathStateWhy
The ordinary identity pathclosed structurally`lib/auth/identity-lifecycle.ts` excludes the role from every assignable role at the type level and refuses it again at runtime when an invitation is redeemed.
The environment-variable credential pathclosed structurally`lib/auth/auth-options.ts` returns null whenever APP_ENV, CONTROLFRAME_RUNTIME_MODE, ATTESTIFY_RUNTIME_MODE or NODE_ENV reads production — and a production build sets NODE_ENV.
A provisioning script that writes the role directlywatched`scripts/ensure-super-admin.ts` bypasses both of the above. It is the only door still open, which is why the census below is run rather than assumed.
Findings

What we found against our own controls, with both dates

Every reviewer knows a vendor with zero findings is a vendor with zero looking. Both of these were found by us, on the same day, and fixed the same day. They are published here with the mechanism that would now catch each one again.

The isolation control was applied, tested, and inert on the live path

found 2026-08-17fixed 2026-08-17
Found by
An internal review of the production connection string
What happened
The tenant shield was applied and verified that morning, but production connected to the database as `postgres` through the pooler — a role that holds BYPASSRLS. Every policy was in place and none of them applied to the live application. The earlier statement that the runtime reached the database as a least-privileged role had been read from the development context.
Why it mattered
Row-level security is enforced against the connecting role, so a control that is correct in the schema is still absent from the request path when the role can bypass it. Verification that reads a different environment than the one it describes is not verification.
What catches it now
The runtime role query above, and the closed-world verification, which asserts the probing role is neither a superuser nor BYPASSRLS before it asserts anything else.

An evidence-share page resolved a project without binding it to an organization

found 2026-08-17fixed 2026-08-17
Found by
An internal adversarial pass, the same night the defect shipped
What happened
Project identifiers are caller-supplied slugs with no generated default, so two organizations can hold the same one. The share page resolved the project under whatever organization the caller belonged to, so a colliding slug returned a row and the page would have rendered the client name, the target environment, the file list and the content digests to the wrong organization. The download route had been given the organization check earlier that evening; the page had not.
Why it mattered
`projects` is one of the ledgered unshielded tables. Nothing underneath the application caught it, which is the exact consequence of an unshielded table and the reason each one is enumerated on this page rather than summarized.
What catches it now
The page pins the lookup to the organization the share itself declares, and a regression test reads the page source for that binding and fails without it.
Next

The same numbers, sealed

Everything on this page is also published as an offline-verifiable receipt, sealed whenever the serving deployment holds the signing key, so a reviewer can check the measurement rather than read it.
Tenant isolation | ControlFrame