PR body — feat/per-tenant-auth → main
Open at: https://github.com/Virtue-Digital-Indonesia/obscura/compare/main...feat/per-tenant-auth?expand=1
Title: feat(auth): per-tenant sign-in policy — Sakti can lose SSO without Virtue losing it
What this does
Each tenant on a Cloud deployment can now be given its own auth mode. The immediate need:
sakti.val.id offers no SSO while dms.val.id keeps Casdoor, on the same process.
PUT /api/v1/control/tenants/sakti/auth {"auth_mode": "local"}
Bring-your-own-IdP is specified, not built — see the spec for the P1 shape and the two
rotation traps it has to clear first.
📄 Spec: docs/plans/2026-08-20-per-tenant-auth.md · rendered
The hazard, confirmed by test
The brief flagged a suspected cross-tenant JIT hazard, read from source and not exercised.
It is real, and the harness that proves it ships in this PR
(go/internal/auth/adapters/tenant_oidc_test.go — two real tenant schemas, one process,
one verifier):
CONFIRMED: a token minted for hz_virtue provisioned a user into hz_sakti
The tenant comes from the Host header; the OIDC verifier is built once at boot from deployment
config. So a Virtue employee's Casdoor token, presented at Sakti's address, verifies and
JIT-provisions them into Sakti. Containment holds — the arrival has zero role_bindings and
zero acl_entries, pinned by a second test — but it is a stranger in Sakti's user directory
and a seat against Sakti's quota. Nothing was exercised on production.
Turning SSO off for a tenant closes it: there is no OIDC route to arrive on.
Five commits
-
user_identitiesre-keyed on(idp, issuer, subject)(migration00214). Every OIDC
write passed the literal constant"oidc"asidp, so the lookup could not tell one issuer
from another. Within-tenant, not cross-tenant: a tenant switching Google → Entra could have a
new issuer's subject match a row the old one left behind. Costs nothing today, unfixable once
two issuers' rows are mixed — so it lands before tenants may have a second issuer.
The issuer comes from the verified token, not config. -
control.tenant_auth(migrationcontrol/00023). Absent row = inheritAUTH_MODE, the
same contract ascontrol/00022. Control-plane rather than a column on the tenant's own
auth_settings, because that table is written by a tenant-admin route — the operator-only
property needs to be structural, not remembered. Enforced in the service, not just on the
login page. -
The operator console panel + the proxy layer it needs. The console is a separate binary
that proxies a named allowlist of control paths, so the routes existed and nothing could call
them — an unlisted path falls through to the SPA handler and answers 200 with HTML. Found by
driving a real browser;go testandtsc --noEmitwere both green while the tenant page
white-screened. -
Two server bugs found reviewing my own code: an unusable override locked a tenant out
(SSO half discarded, "local off" kept), and an unknown tenant id was answered by whatever
failed first rather thantenant.not_found. -
The spec.
Two things I overruled from the brief
- Not
auth_settings. It is written byPUT /admin/auth-settings, a tenant-admin route, so
auth_modethere is either tenant-writable or a "preserve this column" invariant that dies in
the next refactor. Reasoning in §3 of the spec. - No new encrypted column in P0, so
masterKeyColumnsis untouched and the drift guard is
unaffected. P1 needs it — and needs more, see below.
Verification
- The new tests were checked against the un-fixed code: reverting the gate in
ProvisionFromOIDCmakesTestTenantWithSSOOffRefusesOIDCWhileTheOtherKeepsItfail with
"SSO sign-in succeeded at a tenant with SSO turned off". - Full suite on a fresh Postgres,
-p 1, against bothorigin/mainand this branch:
13 failures each, the same 13 (dms/esign/workflowadapters, pre-existing and
unrelated). This branch adds none. - Driven in a real browser, not screenshotted: two tenants on one Cloud-mode deployment, the
console logged in through the real TOTP flow. Verified the login page differs per tenant
(sakti→modes:["local"],dms→["local","oidc"]), the SSO endpoint answers 403
auth.oidc.not_offeredat sakti while reaching the verifier at dms, the write-time lockout
refusal fires on live data, and the set → refuse → clear round trip works from the panel. - Four UI defects fixed from the rendered panel — a truncating Carbon
Tag(which swallows
title, so no tooltip), a Tag and Button sharing wording, a redundant key/value list, and a
six-line bold refusal. Measured against the sibling panel: same 347px column, uniform 12px
rhythm, no clipping ancestors. TEST_DATABASE_URLis not set in CI, so the two-tenant tests skip there. That is stated
in the spec so skipping is never mistaken for passing.
For Valid Deployment
BOOTSTRAP_ADMIN_EMAILis set on prod — checked, read-only, 2026-08-20. It is
efran@val.id(docker inspect deploy-obscura-1).maybeBootstrapAdmingrants wildcard
admin on that deployment-wide email match, writing into the tenant schema — so composed
with the hazard above it is a cross-tenant admin grant.
Three things hold it shut, and only one of them is in this repo:
- Casdoor's /.well-known/openid-configuration lists email and no email_verified;
- t_dms.audit_events shows auth.bootstrap_admin.refused ("idp did not assert
email_verified") on every sign-in since 2026-08-10, most recently today;
- …but auth.bootstrap_admin succeeded 20 times through 2026-08-09 — the guard is ten
days old.
It has not fired. t_sakti holds 3 local users, zero OIDC users, and zero
auth.login.oidc* events ever. Nobody has attempted SSO at sakti.val.id.
Separately worth deciding: BOOTSTRAP_ADMIN_EMAIL has done its job — prod has had its first
administrator since day one — so unsetting it costs nothing and removes the one thing in
our control. Not done here; that is a deployment change.
2. Does Sakti want local, or oidc with their own IdP later? local now works either way
with no migration, so it does not block this.
3. 🔴 P1 must fix the rotation blind spot first. runRotateDBSecrets opens one connection to
cfg.DatabaseURL and probes with to_regclass, which on Cloud reaches only public — not
the control schema. tenant_esign_identity.client_secret is already in that hole (recorded in
rotatesecrets_test.go:55-72). Putting a second secret there turns an open gap into a pattern.
Also noticed while checking: prod is running the v1.0.1 images (up 18h), not the v1.0.0 the
brief mentioned.
Prod is not deployed without being asked — and prod is the only deployment where this has any
effect, which is the awkward part.
🤖 Generated with Claude Code