feat(security): multi-tenant identity registry, host resolution and tenant context (#434 stage 1) - #441
Merged
Merged
Conversation
…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
Contributor
Automated code review (gate-code-review)Credit balance is too low Report-only — this check never blocks merge. |
This was referenced Jul 29, 2026
Merged
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
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, readingAUTHENTIK_*straight fromprocess.envat ~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 viaAsyncLocalStorage, and providesassertTenantMatchesfor cross-tenant session rejection.config.ts—appBaseUrl()andgoogleBrokeredEnabled()read the ambient tenant instead ofprocess.env. Signatures unchanged, so no call site churns.Two modes, and the split is the point
SECURITY_TENANTSunset)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,localhostand 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.envper 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
AsyncLocalStorageover 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-Hostis 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. Usesreq.hostname(which honours trust-proxy) falling back toHost.Verification
tsc --noEmitclean for these files. (One pre-existing unrelated error ineventPublisher.ts, an artifact of a partial workspace install.)google-brokered-signinextra-arg assertion,authentik-provideremail verification) reproduce on cleanmasterand are pre-existing, not caused here — worth a separate look.Not done (why #434 stays open)
oidc.ts,authentikPassword.ts,machine-identity.ts,accountApi.tsand the routes still readprocess.env.AUTHENTIK_*directly, so the "noprocess.env.AUTHENTIK_*outside the registry" criterion is unmet, and the middleware is not yet mounted on the auth routers.oidc.tsis a stateful singleton with discovery caching and background retry that must become one instance per tenant — and that is where theiss-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