think
16px
820px

PR body — feat/per-tenant-authmain

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

  1. user_identities re-keyed on (idp, issuer, subject) (migration 00214). Every OIDC
    write passed the literal constant "oidc" as idp, 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.

  2. control.tenant_auth (migration control/00023). Absent row = inherit AUTH_MODE, the
    same contract as control/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.

  3. 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 test and tsc --noEmit were both green while the tenant page
    white-screened.

  4. 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 than tenant.not_found.

  5. The spec.

Two things I overruled from the brief

  • Not auth_settings. It is written by PUT /admin/auth-settings, a tenant-admin route, so
    auth_mode there 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 masterKeyColumns is 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
    ProvisionFromOIDC makes TestTenantWithSSOOffRefusesOIDCWhileTheOtherKeepsIt fail with
    "SSO sign-in succeeded at a tenant with SSO turned off".
  • Full suite on a fresh Postgres, -p 1, against both origin/main and this branch:
    13 failures each, the same 13 (dms/esign/workflow adapters, 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
    (saktimodes:["local"], dms["local","oidc"]), the SSO endpoint answers 403
    auth.oidc.not_offered
    at 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_URL is 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

  1. BOOTSTRAP_ADMIN_EMAIL is set on prod — checked, read-only, 2026-08-20. It is
    efran@val.id (docker inspect deploy-obscura-1). maybeBootstrapAdmin grants 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