Skip to content

feat(security): multi-tenant identity registry, host resolution and tenant context (#434 stage 1) - #441

Merged
izzywdev merged 1 commit into
masterfrom
feat/security-multitenant-broker
Jul 29, 2026
Merged

feat(security): multi-tenant identity registry, host resolution and tenant context (#434 stage 1)#441
izzywdev merged 1 commit into
masterfrom
feat/security-multitenant-broker

Conversation

@izzywdev

Copy link
Copy Markdown
Owner

Stage 1 of #434. Lays the foundation for security-service to front several identity tenants, each backed by its own Authentik instance and therefore its own account directory.

This does not yet make a MendysRobotics login work — see Not done below. #434 stays open.

Why

security-service is the single front door for identity; Authentik is an implementation detail behind it (config.ts: "no vendor name leaks past this boundary into the API surface"). But the implementation was single-tenant, reading AUTHENTIK_* straight from process.env at ~12 sites with a FuzeFront default at each.

What's here

  • providers/authentik/tenants.ts — the registry. Host → Authentik instance, carrying per-tenant issuer, in-cluster base URL, client credentials, admin token and enrollment flow slug.
  • middleware/tenant-context.ts — resolves the tenant from the request host, binds it via AsyncLocalStorage, and provides assertTenantMatches for cross-tenant session rejection.
  • config.tsappBaseUrl() and googleBrokeredEnabled() read the ambient tenant instead of process.env. Signatures unchanged, so no call site churns.

Two modes, and the split is the point

LEGACY (SECURITY_TENANTS unset) MULTI (set)
Tenants one, synthesised from today's env vars as declared
Unknown host served (today's behaviour) REJECTED
Caching none — rebuilt per read memoised

Fail-closed engages only in MULTI, deliberately. Applying it in legacy mode would break FuzeFront immediately, because requests legitimately arrive there on app.fuzefront.com, fuzefront.dev.local, localhost and in-cluster service DNS. Once you declare tenants you are asserting the full host list — and at that point falling back to a default would authenticate a user against the wrong directory, the single failure this whole split exists to prevent. So: no fallback, no default tenant, reject.

Legacy is not memoised, also deliberately. The functions it replaces each read process.env per call, so a late env change took effect immediately; caching would silently change that for every existing deployment and for tests that set env per case. There's a test pinning this.

Design choices worth reviewing

  • AsyncLocalStorage over threading a tenant parameter. The deep callers (authentikPassword, machine-identity, accountApi) are also invoked from provisioning scripts and seed jobs with no request in scope. runWithTenant() serves both without a sprawling diff across a live auth path.
  • X-Forwarded-Host is deliberately NOT read. Behind the ingress it is caller-supplied; letting it pick the tenant would let a client choose which directory to authenticate against. Uses req.hostname (which honours trust-proxy) falling back to Host.
  • Misconfiguration fails at BOOT, not at request time: duplicate host claims, duplicate tenant ids, missing required fields and malformed JSON all throw on load.

Verification

  • 28 new tests pass — legacy parity, host routing, fail-closed rejection, boot validation, host normalisation, cross-tenant session rejection.
  • tsc --noEmit clean for these files. (One pre-existing unrelated error in eventPublisher.ts, an artifact of a partial workspace install.)
  • No regression, measured rather than asserted: the 8 Authentik/OIDC suites give 96 passed / 2 failed both with and without this change — identical. I ran the same set against a stashed, pristine tree to confirm. Those 2 failures (google-brokered-signin extra-arg assertion, authentik-provider email verification) reproduce on clean master and are pre-existing, not caused here — worth a separate look.

Not done (why #434 stays open)

oidc.ts, authentikPassword.ts, machine-identity.ts, accountApi.ts and the routes still read process.env.AUTHENTIK_* directly, so the "no process.env.AUTHENTIK_* outside the registry" criterion is unmet, and the middleware is not yet mounted on the auth routers. oidc.ts is a stateful singleton with discovery caching and background retry that must become one instance per tenant — and that is where the iss-rewriting subtlety lives, so it deserves its own reviewable change rather than being bolted on here.

Also still open from #434: per-tenant Google client credentials (shared client vs one per tenant — a consent-screen branding decision), tenant-scoped session claims at mint time, and the chart wiring.

Related: #428, #433, #439, izzywdev/FuzeInfra#421, izzywdev/MendysRobotics#253.

🤖 Generated with Claude Code

…enant context

Stage 1 of #434. security-service is the single front door for identity across
every tenant, with Authentik an implementation detail behind it — but the
implementation was single-tenant, reading AUTHENTIK_* straight from process.env
with a FuzeFront default at each site. This lays the foundation for resolving a
tenant per request without yet moving those call sites.

Adds:
  - providers/authentik/tenants.ts — the registry. Host -> Authentik instance,
    with per-tenant issuer, in-cluster base URL, client credentials, admin
    token and enrollment flow slug.
  - middleware/tenant-context.ts — resolves the tenant from the request host
    and binds it via AsyncLocalStorage, plus assertTenantMatches for
    cross-tenant session rejection.
  - config.ts — appBaseUrl() and googleBrokeredEnabled() now read the ambient
    tenant instead of process.env. Signatures are unchanged, so no call site
    churns.

Two modes, and the split is the point. LEGACY (SECURITY_TENANTS unset)
synthesises one tenant from today's env vars and serves EVERY host, so existing
deployments are unchanged. MULTI matches declared hosts and REJECTS anything
unclaimed. Fail-closed engages only in MULTI deliberately: applying it in
legacy mode would break FuzeFront at once, because requests legitimately arrive
there on app.fuzefront.com, fuzefront.dev.local, localhost and in-cluster
service DNS. Once tenants are declared you are asserting the full host list,
and falling back would authenticate against the WRONG directory — the single
failure this whole split exists to prevent.

Legacy mode is rebuilt from the environment on every read rather than
memoised, because the functions it replaces each read process.env per call; a
cache would silently change that for existing deployments and for tests that
set env per case. MULTI is memoised, since that config is static.

AsyncLocalStorage rather than threading a tenant parameter: the deep callers
(authentikPassword, machine-identity, accountApi) are also invoked from
provisioning scripts and seed jobs with no request in scope, and runWithTenant
serves both without a sprawling diff across a live auth path.

Host handling: X-Forwarded-Host is deliberately NOT read — behind the ingress
it is caller-supplied, and letting it choose the tenant would let a client pick
which directory to authenticate against. Duplicate host claims, duplicate ids,
missing fields and malformed JSON are rejected at BOOT, not at request time.

Verified:
  - 28 new tests pass, covering legacy parity, host routing, fail-closed
    rejection, boot validation, host normalisation and cross-tenant session
    rejection.
  - tsc --noEmit clean for these files (one pre-existing unrelated error in
    eventPublisher.ts from a partial workspace install).
  - The 8 Authentik/OIDC suites give 96 passed / 2 failed BOTH with and without
    this change — byte-identical results. Those 2 failures reproduce on
    pristine master and are pre-existing, not caused here.

NOT yet done, and #434 stays open: oidc.ts, authentikPassword.ts,
machine-identity.ts, accountApi.ts and the routes still read
process.env.AUTHENTIK_* directly, so the "no process.env.AUTHENTIK_* outside
the registry" criterion is unmet. oidc.ts in particular is a stateful singleton
with discovery caching and background retry that must become one instance per
tenant, and that is where the iss-rewriting subtlety lives — it deserves its
own reviewable change.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session-Id: 55a394fa-d5ba-4da4-b41f-b0ef56fa6ddf
@github-actions
github-actions Bot enabled auto-merge (squash) July 29, 2026 05:34
@github-actions

Copy link
Copy Markdown
Contributor

Automated code review (gate-code-review)

Credit balance is too low

Report-only — this check never blocks merge.

@izzywdev
izzywdev merged commit 3ef1130 into master Jul 29, 2026
63 of 65 checks passed
@izzywdev
izzywdev deleted the feat/security-multitenant-broker branch July 29, 2026 07:41
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant