Per-tenant authentication
Status: P0 specified, built and tested; P1 specified, not built. Production checked read-only (§1).
Branch: feat/per-tenant-auth
Date: 2026-08-20
Each tenant sets its own auth mode and, later, brings its own identity provider. Today
AUTH_MODE is deployment config, so every tenant on a deployment gets the same login page and
the same IdP — and the only lever that removes SSO from one tenant removes it from all of them.
The immediate need is smaller than the feature: Sakti should not offer SSO. That is P0. It
also closes a real cross-tenant hazard, which is why it is worth shipping ahead of
bring-your-own-IdP rather than waiting for it.
1. The hazard, exercised
The brief asked me to confirm or refute it before treating it as fact. Confirmed, by test,
not by reading — go/internal/auth/adapters/tenant_oidc_test.go, against two real tenant
schemas provisioned by the real provisioner on one process.
=== RUN TestOIDCLoginJITProvisionsIntoWhicheverTenantTheHostNamed
CONFIRMED: a token minted for hz_virtue provisioned a user into hz_sakti
(id 01a01e02-98e6-…; the home account is 01a01e02-98cf-…)
--- PASS
Nothing was exercised on production. The test reproduces production's shape rather than
production itself: two tenant schemas, one auth Service, one OIDC verifier, and the
only thing differing between the two sign-ins is the tenant bound to the context — which is
exactly and only what resolveTenant varies per request.
Why it happens
Three facts that are individually reasonable:
httpapi.resolveTenant(go/internal/httpapi/tenancy.go:310) binds the tenant from the
Host header, at the router root, before authentication./auth/oidc-loginand
/auth/oidc/exchange(go/internal/httpapi/server.go:721,724) are inside that group and
are not exempt.- The OIDC verifier is built once at boot from deployment config
(go/cmd/obscura-server/wire.go:353) and shared by every host the process answers on. It
has no idea tenants exist. ProvisionFromOIDC(go/internal/auth/app/service.go:246) gates only on
s.verifier == nil— a deployment-wide question — and then JIT-provisions into whatever
schema the context named.
So a Virtue employee's Casdoor token, presented at sakti.val.id, verifies (deployment
verifier), finds no identity in t_sakti.user_identities, and creates a user in t_sakti.
What it is and is not
The containment in the brief holds, and is now pinned by a second test
(TestCrossTenantJITArrivalHoldsNoGrants): the arrival lands with zero role_bindings and
zero acl_entries, so they see no documents. This is an unexpected row in Sakti's user
list, not a data breach.
Two things stop it being merely cosmetic:
- Sakti's admin watches a stranger from another company appear in their user directory, and
nothing in the product explains it. - It is a seat.
tenant_quotascounts users; enough stray arrivals reach a ceiling that
refuses Sakti's own next account.
The one escalation worth naming
maybeBootstrapAdmin (go/internal/httpapi/handlers_auth.go:341) grants wildcard admin to
an OIDC arrival whose email matches BOOTSTRAP_ADMIN_EMAIL. That email is deployment config;
the grant writes role_bindings in the tenant schema. Composed with the above, a
cross-tenant sign-in by the bootstrap admin becomes wildcard admin in the other tenant.
Checked on production, read-only, 2026-08-20. BOOTSTRAP_ADMIN_EMAIL is set — to
efran@val.id, read from docker inspect deploy-obscura-1, not from an env file. So the
escalation path is live, and one guard is holding it shut.
The guard is currently effective, and provably so rather than by assumption:
| Evidence | What it shows |
|---|---|
Casdoor's own /.well-known/openid-configuration lists email in claims_supported and no email_verified |
the IdP does not send the claim the guard demands |
t_dms.audit_events: auth.bootstrap_admin.refused, reason idp did not assert email_verified, once per sign-in since 2026-08-10, most recently today |
the guard is firing in production, daily |
…but auth.bootstrap_admin succeeded 20 times through 2026-08-09 |
the guard is ~10 days old. Before it, this granted |
The hazard has NOT fired. t_sakti holds 3 local users and zero OIDC users, and its audit
partition has 13 auth events, none of them auth.login.oidc* — nobody has ever attempted SSO
at sakti.val.id. That is the whole exposure to date, and it is nil.
So the accurate statement is narrower than "closed by accident" and worse than "theoretical": a
deployment-config email that matches, an IdP that happens not to send one optional claim, and a
guard that has existed for ten days are the three things between a cross-tenant sign-in and
wildcard admin in the other tenant. Two of the three are outside this codebase. Ticking
"email verified" in Casdoor's application settings removes the load-bearing one, with no change
to Obscura and nothing in Obscura that would report it.
P0 closes it in the codebase, where it can be relied on: a tenant with SSO off has no OIDC route
to arrive on at all.
2. Data model first: user_identities is keyed on the wrong thing
user_identities is (id, user_id, idp, subject) with UNIQUE (idp, subject), and every OIDC
write passes the literal constant "oidc" as idp — service.go:280, pg.go:148. The
issuer is nowhere in the key.
This is a data-model decision, not a settings decision, which is why it lands before any
settings screen. It is not a cross-tenant collision — user_identities lives in the tenant
schema — it is a within-tenant one:
A tenant switches IdP from Google to Entra. Both mint subject identifiers the tenant does not
control. An Entra subject that happens to equal a Google subject left behind in the table
resolves to that Google account — including a privileged one. Where the new IdP lets a
subject be influenced (a directory an attacker can create accounts in, a provider that
derivessubfrom a mutable attribute), that is an account takeover primitive.
It costs nothing today and is unfixable later: once two issuers' rows are mixed in one column
there is no way to say which row came from which issuer. So it goes in before the first
tenant is allowed a second issuer, not with it.
go/migrations/00214_user_identity_issuer.sql
ALTER TABLE user_identities ADD COLUMN issuer text NOT NULL DEFAULT '';
-- (no backfill in SQL — see below)
ALTER TABLE user_identities DROP CONSTRAINT user_identities_idp_subject_key;
CREATE UNIQUE INDEX user_identities_idp_issuer_subject ON user_identities (idp, issuer, subject);
Notes that are load-bearing:
- Non-OIDC rows keep
issuer = ''.local,dev,ldapandscimsubjects are already
namespaced byidpand are not issued by anyone; giving them a synthetic issuer would be a
lie in a column whose whole job is to be true. - The backfill needs the deployment's current issuer, which SQL cannot read from the
process env. Goose migrations here are plain SQL, so the value is stamped by the application
at boot instead: the migration leaves'', and a boot-time repair (StampOIDCIssuer) fills
idp='oidc' AND issuer=''rows withcfg.OIDC.Issueronce, idempotently. A deployment with
OIDC off stamps nothing, which is correct — there is nothing to attribute. ''is not a wildcard. A lookup for issuerXmust not match a row with''. Rows that
the repair could not attribute (OIDC off at the time) stay unmatched until the repair runs,
which fails toward "make a new account", not "adopt an existing one".
3. P0: per-tenant auth mode
Where the setting lives — and why not auth_settings
(Built. go/migrations/control/00023_tenant_auth.sql, internal/auth/adapters/tenant_auth_pg.go,
internal/auth/app/tenantauth.go, internal/httpapi/handlers_control_auth.go.)
The brief suggested extending auth_settings. I am overruling that, for one reason that only
shows up when you look at who writes the row.
auth_settings is written by PUT /admin/auth-settings — a tenant admin route
(handlers_auth_admin.go). Adding auth_mode there means the field is either writable by
tenant admins (which the brief argues against, correctly: a wrong issuer is an account takeover
of that tenant) or it must be preserved by a handler that does not otherwise know it exists.
"Preserve this one column on write" is precisely the kind of invariant that survives review and
dies in the next refactor — and its failure mode is a tenant locking itself out or turning its
own SSO back on.
So it goes where the operator-only property is structural rather than remembered:
control.tenant_auth, a control-plane table, reachable only behind the operator token,
exactly like control.tenant_esign_identity (control/00022) — whose reasoning transfers
verbatim. A tenant is a separate legal entity; its identity provider is its own; and an
operator, not a customer, decides which one the login page trusts.
Second benefit, which decided it as much as the first: P1's issuer and auth_mode must be
validated together (oidc with no issuer is a locked-out tenant). One row makes that
refusal atomic. Split across a tenant table and a control table, it is a race.
go/migrations/control/00023_tenant_auth.sql
CREATE TABLE control.tenant_auth (
tenant_id text NOT NULL PRIMARY KEY REFERENCES control.tenants (id) ON DELETE CASCADE,
auth_mode text NOT NULL CHECK (auth_mode IN ('local', 'oidc', 'local+oidc')),
updated_at timestamptz NOT NULL DEFAULT now(),
updated_by text NOT NULL DEFAULT ''
);
Absent row = inherit the deployment's AUTH_MODE. Same contract as
tenant_esign_identity: a deployment that writes no rows behaves exactly as it did before the
table existed. Enterprise has no control plane at all, so the table does not exist there and
the code path is skipped — per-tenant auth is a Cloud concept and Enterprise's one tenant is
already served by AUTH_MODE.
auth_mode is NOT NULL with no default. A row exists only because an operator wrote one.
There is no such thing as a row that says nothing — that state is spelled "no row", and
DELETE is how a tenant returns to inheriting.
The spelling is the same three values AUTH_MODE accepts — local | oidc | local+oidc —
enforced by a CHECK. Not both. both is refused at config load and crash-loops the app;
it 502'd prod on 08-18. The CHECK is the same refusal one layer down, where a hand-written
UPDATE also meets it.
Resolution
Service.authModeFor(ctx) answers (local, oidc bool) for the calling tenant:
- No tenant bound, or Enterprise → the deployment policy (
s.localLogin, verifier presence).
Unchanged behaviour, and the only answer available for background work. - Row present → that row.
- No row, or control plane unreadable → the deployment policy.
Case 3 inherits rather than denies, deliberately. The alternative — fail closed to "no sign-in
method" — turns a control-plane blip into a total outage for every tenant that had no row, i.e.
almost all of them. The thing that must never happen is answering with another tenant's
policy, and keying by tenant makes that impossible. This is the same reasoning
tenantAuthSettingsCache.get already documents.
Cached per tenant with the same shape as tenantAuthSettingsCache (60s TTL, dropped on write).
Enforcement points
Every place the deployment mode was read becomes a per-tenant question, all of them routed
through one function (Service.authModeFor) so the login page and the endpoints cannot
disagree:
| Where | Today | After |
|---|---|---|
AuthMethods (/auth/methods) |
s.cfg.AuthModes |
tenant's modes — this is the login page |
ProvisionFromOIDC |
s.verifier == nil |
+ tenant OIDC on |
LoginOIDCCode |
s.exchanger == nil |
+ tenant OIDC on |
VerifyPassword / register |
s.localLogin |
+ tenant local on |
FinishPasskeyLogin |
s.localLogin |
+ tenant local on |
isBreakGlassLogin (audit label) |
cfg.AuthModes |
+ tenant mode — see below |
One more that was not on the original list and turned out to matter: isBreakGlassLogin
decides whether a local sign-in is audited as auth.login or auth.login.break_glass. It read
cfg.AuthModes, so a tenant set to oidc on a local+oidc deployment would have its
break-glass admin sign-ins filed as ordinary password logins — the one event that label exists
to make impossible to miss, under the one name nobody alerts on.
/auth/methods alone is not the fix. It shapes the login page, and a login page is a
suggestion — the OIDC POST routes are public and answer to anyone who calls them directly. The
server-side gate on ProvisionFromOIDC is what actually closes §1; the page is what stops
Sakti's staff seeing a button that would fail.
Break-glass stays deployment-level. A tenant set to oidc inherits the deployment's
AUTH_BREAK_GLASS. It is an operator's escape hatch for when the IdP is down, and the operator
is the one who set the tenant's mode in the first place.
Refuse the lockout state at WRITE time
The two states that produce a tenant nobody can enter:
auth_mode=local(orlocal+oidc) for a tenant with zero usable local credentials —
no user with a password hash and no user with a passkey.auth_mode=oidc(orlocal+oidc) when the deployment has no OIDC configured, so
there is no verifier to accept anyone. (P1 replaces this with "no issuer resolves for this
tenant".)
Both are refused by the write path with a message naming the tenant and the reason.
At write time, not at boot. The deployment already has two boot-refusal incidents from
config that looked fine (AUTH_MODE=both, PERURI_ADAPTER_ENV=PRODUCTION), and the instinct
to refuse loudly is right — but a boot refusal over one tenant's row takes down every other
tenant on the box. The operator who typed it is standing right there; tell them then.
The local-credential count is users with a non-empty password hash, or a webauthn_credentials
row, excluding soft-deleted accounts. Counted in the tenant's schema at write time, which is
why the write path binds the tenant it is writing about.
Surface
GET /api/v1/control/tenants/{id}/auth → {"auth_mode": "local", "inherited": false}
PUT /api/v1/control/tenants/{id}/auth ← {"auth_mode": "local"}
DELETE /api/v1/control/tenants/{id}/auth → back to inheriting
Operator console only. GET reports inherited: true and the deployment's mode when no row
exists, so the operator sees what is in force rather than an empty field.
What Sakti gets
PUT /api/v1/control/tenants/sakti/auth {"auth_mode": "local"} — Sakti's login page offers
password only, its OIDC endpoints refuse, and Virtue is untouched.
4. P1: bring your own IdP (specified, not built)
Extends the same row rather than introducing a second place to look:
ALTER TABLE control.tenant_auth
ADD COLUMN oidc_issuer text NOT NULL DEFAULT '',
ADD COLUMN oidc_client_id text NOT NULL DEFAULT '',
ADD COLUMN oidc_client_secret_cipher bytea;
Resolution mirrors TenantESignStore: a per-tenant verifier built from the tenant's own
credentials, cached by a fingerprint of those credentials so an edited — or revoked — IdP
takes effect without a restart. A half-filled row resolves to inherit, never to a broken
verifier, for the same reason TenantESignIdentity.configured() exists: a saved draft must not
cost a tenant their sign-in.
Four things that must not be forgotten:
- The client secret is encrypted under the deployment master key, like
smtp_settings.password_cipherandwhatsapp_settings.api_key_cipher. With no master key
configured the write path refuses rather than storing a live IdP credential in the clear. - 🔴
masterKeyColumns(cmd/obscura-server/rotatesecrets.go:41) must gain the column, or
a key rotation silently leaves it behind. The drift guard
(TestMasterKeyColumnsCoverEveryCipherCallSite) counts.encrypt(call sites in the tree
and will fail until it is classified — it measures the code, so it cannot be satisfied by
bumping a number. - 🔴 …and adding the row is not enough.
runRotateDBSecretsopens one connection to
cfg.DatabaseURLand probes withto_regclass, which on Cloud reaches onlypublic— not
the control schema and not the tenant schemas.tenant_esign_identity.client_secretis
already in this hole and the test says so in a comment (rotatesecrets_test.go:55-72)
rather than fixing it. P1 must teach the rotation about the control plane, or it adds a
second uncovered secret and makes an open gap into a pattern. - Per-tenant redirect URIs. The SPA's PKCE redirect is built from
/auth/methods, which
would now answer per tenant; each tenant's IdP must have that tenant's own origin registered
as a redirect URI. That is customer-side configuration and belongs in the operator runbook,
because a missing entry fails at the IdP with an error Obscura never sees.
(issuer, subject) from §2 is the precondition for all of this, and is why it ships in P0.
5. Testing
A green demo proves nothing here. valbox is single-tenant and VM2's tenant has no SSO, so
neither exercises the interesting path. Prod is the only live two-tenant deployment and is not
a test environment.
So the two-tenant case is built in the worktree: real Postgres, real control migrations, two
schemas from the real provisioner, one process. That harness is now
go/internal/auth/adapters/tenant_oidc_test.go and is what §1 was confirmed with.
docker run -d --name pta-pg -e POSTGRES_USER=obscura -e POSTGRES_PASSWORD=obscura \
-e POSTGRES_DB=obscura -p 55999:5432 pgvector/pgvector:pg17
cd go && TEST_DATABASE_URL='postgres://obscura:obscura@localhost:55999/obscura?sslmode=disable' \
go test ./internal/auth/... -count=1
The tests that matter are the ones that could only pass by accident before. All of these are
written and green:
| Test | What it pins |
|---|---|
TestTenantWithSSOOffRefusesOIDCWhileTheOtherKeepsIt |
Sakti's SSO is refused and Virtue's still works, same process |
TestTenantWithNoOverrideInheritsTheDeployment |
no row → the deployment's AUTH_MODE, unchanged |
TestSameSubjectFromTwoIssuersAreDifferentPeople |
two issuers, one subject, two people |
TestSettingLocalOnATenantWithNoPasswordsIsRefused |
the lockout refusal, and that it leaves no row behind |
TestUnattributedIdentityIsNotAdoptedByAnyIssuer |
an empty issuer is a value, not a wildcard; the boot repair is idempotent |
Plus TestAuthMethodsIsPerTenant (the login page differs by tenant on one deployment),
TestClearingAnOverrideRestoresSSO (local is not a one-way door),
TestTenantOIDCIsIgnoredWhenTheDeploymentHasNoVerifier, and TestBothIsRefusedByName.
They were checked against the un-fixed code. Reverting the gate in ProvisionFromOIDC makes
TestTenantWithSSOOffRefusesOIDCWhileTheOtherKeepsIt fail with "SSO sign-in succeeded at a
tenant with SSO turned off" and "a refused SSO sign-in still created 1 user(s)". A test that
passes both before and after proves nothing, which is the whole risk with a guard like this.
Full suite
Run on a fresh Postgres, -p 1, against both origin/main and this branch:
main: 13 failures
branch: 13 failures — the same 13
They are pre-existing and unrelated (dms/adapters, esign/adapters, workflow/adapters);
origin/main fails them identically on a clean database. This branch adds none and fixes
none of them.
6. The console, and what auditing it found
P0 first shipped with no UI. The control routes existed and nothing could call them.
The seam nobody was testing
The operator console is a separate binary that reaches the control API only through routes
named in its own Router(). Adding a route to obscura-server does not make it callable. An
unlisted path is not an error — it falls through to the SPA handler and answers 200 with
HTML, which the browser then parses as JSON.
go build, go test and tsc --noEmit were all green. In a real browser the tenant page
white-screened: data was truthy (the HTML placeholder), data.modes was undefined, and with
no error boundary above the bento one panel took down the whole page.
Fixed in three parts, and the third is the one that matters:
internal/operator/httpapi_auth_policy.go— the proxy handlers, at the tiers the e-sign
identity uses: read for viewers (the shape of a customer's login page is not a secret),
writes owner-only.- The panel refuses to render on a body without a string
auth_mode, so it cannot be the
thing that unmounts the page again. TestAuthPolicyRoutesReachTheControlAPI— asserts each console route reaches the control
API, at the right path and method. Nothing covered that seam; the existing viewer test proves
routes are refused, which an unregistered route also does, for the wrong reason. Verified
to fail without the proxy.
The operator audit entry lives in the console, not the control plane: the control API
authenticates with one shared deployment token and cannot name a person. The console can.
Four UI findings, all from the rendered panel
| Finding | Fix |
|---|---|
Carbon Tag truncated the mode. "Deployment default · Password and single sign-on" rendered as "…Password a…", and Tag swallows title, so there is no tooltip to recover it |
the Tag carries the SOURCE only; the mode moved into the sentence below |
| Tag and Button read the same words — both "Set for this tenant". A browser driver clicked the wrong one, which is the mistake a person makes with a mouse | "Tenant-specific" / "Deployment default" on the Tag; "Set for this tenant" / "Edit methods" on the Button |
| A key/value list restating the Tag. The e-sign panel has one because it carries facts its summary cannot; this panel has one fact and stated it three times | cut — the panel went from 330px to 278px, within 20px of its sibling |
A six-line bold refusal. Carbon bolds title, and these messages end with the fix ("give an administrator a password first") |
short title, server sentence as subtitle |
Measured against the sibling panel rather than eyeballed: same 347px column, uniform 12px
vertical rhythm, no clipping ancestors, and a native <select> so there is no portal to
escape a container. The two-button row wraps — that is house behaviour (.op-actions wraps by
design so a row never escapes its panel), and Carbon's button min-width means no label short
enough would fit two in a 313px column.
Two server bugs found reviewing my own code
Both failed in the wrong direction, and neither had a UI yet to reveal them:
- An unusable override locked a tenant out.
oidcon a deployment with no verifier had its
SSO half discarded (correctly) while "local is off" was kept — a login page offering nothing.
An unusable override is now discarded whole and the deployment inherited. - An unknown tenant id was answered by whatever failed first — the read path reported the
deployment's mode as if it were a real customer's, the write path bound a missing schema and
said "try again". Every entry point now checks existence and answerstenant.not_found.
(The malformed case matters too: the id becomes a schema name.kernel.SchemaNamevalidates
before interpolating — checked, no injection — but the refusal surfaced as a pool error.)
7. Rollout
P0 first, on its own. It is one column, one table, three routes and a boot-time repair; it
closes §1; and it delivers what was actually asked for.
TEST_DATABASE_URLis not set in CI, so these tests skip there rather than fail.
They must be run locally against a throwaway Postgres before merge, and this section is the
record that skipping is not passing.--pulldeploys refuse a-dirtytree:git describe --dirtystamps a tag CI never
published. Commit everything before deploying.- Demo is routine. Prod is not deployed without being asked — and prod is the deployment
where this feature has an effect at all, which is the awkward part and the reason §5 exists.
8. Open questions for Valid Deployment
- ~~Is
BOOTSTRAP_ADMIN_EMAILset on prod?~~ Answered: yes,efran@val.id— checked
read-only on 2026-08-20 (§1). Live and masked, and the mask is Casdoor's claim configuration
rather than anything in this repo. It raises P0's urgency; the design is unchanged. - Does Sakti want
localorlocal+oidc-minus-Casdoor? P0 giveslocal: no SSO button,
password only. If Sakti expects to bring their own IdP soon, they arelocalnow and
oidcat P1 — no migration either way, so this does not block P0. - Worth deciding separately: should
BOOTSTRAP_ADMIN_EMAILstill be set at all? Prod has
had its first administrator since day one, so the variable's job is done and it now only
grants — 20 successful grants are in the audit log through 2026-08-09. Unsetting it costs
nothing and removes one of the three things holding the escalation shut, independently of
this branch. Not done here: it is a deployment change, and prod is not deployed without
being asked. - Should the operator console show the effective mode per tenant in the tenant list? The
routes support it. Not built, because the console is a separate binary and a separate deploy.