Skip to main content

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 the internal (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 common issuer. We keep common (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) → their customerIdtenants where status = "active" → collect tenantId. De-duplicate. Exclude the Zation tenant (handled separately in GetRoles). The tenants query 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/azp equals the shared App Registration's client ID (and that it is an app-only token). requireZationAdmin is unchanged and still governs user-interactive calls; the app-token branch is additive.
  • Timestamped stamping (generatedAt) is done by the caller/response formatting, not via Date.now() in a workflow context.

Component 2 — GetRoles rewrite (zation-docs/api/GetRoles)

  • Read the tid claim from the SWA-provided claims payload.
  • tid === ZATION_TENANT_ID → return ["zation-employee"]. (Tenant-ID match replaces the current email-domain endsWith('@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, against PLATFORM_API_SCOPE), GET the customer-tenant-id list from PLATFORM_API_BASE_URL, and check tid membership. 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 via context.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.403 redirects to /.auth/login/..., which loops forever for a signed-in non-customer (login → still no role → 403 → login). Add a public /no-access page ("Your account isn't linked to a Zation customer — contact …") and point the 403 override at /no-access instead.

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 / .default scope 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 (the tenants container is Phase-2-ready for this).
  • tenants.status: only active tenants 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=active filter, GUID-only projection, Zation-tenant exclusion, de-dup) and for the app-token auth (accept correct appid, reject user tokens / wrong appid / 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.