Customer-Tenant Gate for docs.zation.io
Date: 2026-07-02 Issue: zation-docs#58 (follows #53) Status: Design approved
Problem
docs.zation.io's Static Web App uses openIdIssuer: https://login.microsoftonline.com/common/v2.0. The common issuer accepts any Microsoft account — work/school accounts from any tenant, and personal accounts (@outlook.com, etc.). Every such account receives the built-in authenticated role after sign-in.
The /private/* route rule requires only authenticated. Therefore the platform documentation tier — /private/platform/* (FinOps algorithms, advanced cost strategy, release notes, Azure/M365 platform internals) — is readable by anyone on the internet who has any Microsoft account. It is not restricted to Zation customers or employees.
The /private/internal/* tier is separately gated by the custom zation-employee role, which GetRoles assigns from the login's email domain. That gate works correctly (verified live: /.auth/me returns zation-employee, internal pages load for employees — see #53). This spec does not change the internal tier.
Goal
Replace the authenticated requirement on /private/* with a custom customer role. GetRoles grants customer only when the login's home tenant (tid claim) belongs to an active Zation customer, verified against the Platform's tenant registry. Non-customer Microsoft accounts receive no role and are denied.
Non-Goals (out of scope)
- Content re-tiering. Whether
platform/algorithms(Zation IP) should be visible to customers at all, or moved into theinternal(employee-only) tier, is a separate content decision. This spec only changes who counts as authorized for/private/*(customers + employees instead of any MS account). - Immediate revocation on offboarding. SWA bakes roles into the session at login; a churned customer keeps access until their session expires (~8h). Accepted.
- Changing the
commonissuer. We keepcommon(customers sign in with their own tenant accounts) and gate via role instead.
Architecture
Flow (at each sign-in, SWA invokes rolesSource)
Login (common issuer, any MS account)
→ GetRoles (docs SWA managed function)
tid == ZATION_TENANT_ID → ["zation-employee"]
tid ∈ active customer tenants → ["customer"]
otherwise / any error → [] (fail-closed)
→ SWA route rules
/private/internal/* → ["zation-employee"] (unchanged)
/private/* → ["customer","zation-employee"]
Component 1 — Platform endpoint (finops-platform)
GET /api/internal/customer-tenant-ids → { "tenantIds": ["<guid>", …], "generatedAt": "<iso>" }
- Payload is GUIDs only — no customer names, no
customerId, no PII. This keeps the response low-sensitivity and consistent with the platform's customer-isolation and anonymisation rules. - Query: active customers from
customers(accountStatus IN ("Contract Signed","Active Trial","Demo Partner") AND isActive = true) → theircustomerId→tenantswherestatus = "active"→ collecttenantId. De-duplicate. Exclude the Zation tenant (handled separately in GetRoles). Thetenantsquery across all active customers is cross-partition, but the endpoint is called rarely (once per docs login, cached client-side), so this is acceptable. - Auth (machine-to-machine): a new app-token path. The caller presents an Entra token obtained via the OAuth client-credentials flow using the shared App Registration. The endpoint validates the token's
appid/azpequals the shared App Registration's client ID (and that it is an app-only token).requireZationAdminis unchanged and still governs user-interactive calls; the app-token branch is additive. - Timestamped stamping (
generatedAt) is done by the caller/response formatting, not viaDate.now()in a workflow context.
Component 2 — GetRoles rewrite (zation-docs/api/GetRoles)
- Read the
tidclaim from the SWA-provided claims payload. tid === ZATION_TENANT_ID→ return["zation-employee"]. (Tenant-ID match replaces the current email-domainendsWith('@zation.io')check — more robust against guests/B2B.)- Otherwise: acquire an app-only token via client-credentials (using
AAD_CLIENT_ID+AAD_CLIENT_SECRET, already present as app settings, againstPLATFORM_API_SCOPE),GETthe customer-tenant-id list fromPLATFORM_API_BASE_URL, and checktidmembership. On match →["customer"]. - In-memory cache: module-level
Map/variable with a ~5-minute TTL, so warm function instances don't call the Platform on every login. - Fail-closed: any error, timeout, or non-200 → return
[]. Log viacontext.log(to App Insights). No role on uncertainty.
Component 3 — SWA config (staticwebapp.config.json)
/private/*and the three/private/...redirect routes:["authenticated"]→["customer","zation-employee"]./private/internal/*: unchanged (["zation-employee"]).- 403 loop fix: the current
responseOverrides.403redirects to/.auth/login/..., which loops forever for a signed-in non-customer (login → still no role → 403 → login). Add a public/no-accesspage ("Your account isn't linked to a Zation customer — contact …") and point the403override at/no-accessinstead.
Component 4 — Config / secrets (zation-docs/infra, Bicep)
New SWA app settings, wired via infra/main.bicep (same param→setting pattern as the existing AAD settings):
PLATFORM_API_BASE_URL— base URL of the finops-platform functions.ZATION_TENANT_ID— Zation's own Entra tenant GUID.PLATFORM_API_SCOPE— the App-ID-URI /.defaultscope used for the client-credentials token.
AAD_CLIENT_ID / AAD_CLIENT_SECRET already exist and are reused.
Edge cases
- Session caching: roles are fixed in the session cookie at login (~8h). Offboarded customers retain access until expiry. Accepted and documented.
- Multi-tenant customers: a customer with N tenants has all N
tenantIds in the list (thetenantscontainer is Phase-2-ready for this). tenants.status: onlyactivetenants are returned;pending/error(permission check not passed) are excluded, so a half-onboarded tenant gets no access.- Platform API down: GetRoles fails closed (no role). Since roles are cached in the session, a transient outage only affects logins during the outage window.
- Fail-open is forbidden: no code path may grant a role on uncertainty — a fail-open bug re-creates today's exposure.
Testing
- Platform endpoint: unit tests for the query (active-status filter,
tenants.status=activefilter, GUID-only projection, Zation-tenant exclusion, de-dup) and for the app-token auth (accept correctappid, reject user tokens / wrongappid/ missing token). - GetRoles: unit tests for each branch (Zation tenant → employee; customer tenant → customer; unknown tenant →
[]; platform error/timeout →[]; cache hit avoids re-fetch). - Integration: deploy to a test hostname, real Entra login with (a) a @zation.io account →
zation-employee+ internal pages load; (b) a customer-tenant account →customer+/private/*loads,/private/internal/*denied; (c) a non-customer MS account → no role,/private/*→/no-access.
Cross-repo sequencing
The Platform endpoint (Component 1) must be deployed before GetRoles depends on it. Build order: Platform endpoint → verify reachable with an app-token → GetRoles + config + infra in zation-docs → integration test on a test hostname → ship.