Customer-Tenant Gate Implementation Plan
For agentic workers: REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (
- [ ]) syntax for tracking.
Goal: Gate docs.zation.io/private/* on a custom customer role granted only to logins whose home tenant is an active Zation customer, instead of the built-in authenticated role (which any Microsoft account satisfies via the common issuer).
Architecture: A new read-only endpoint in finops-platform returns the GUIDs of active customer tenants. The zation-docs GetRoles rolesSource function calls it (client-credentials app token, cached), and returns customer, zation-employee, or [] (fail-closed). SWA route rules move /private/* from authenticated to customer+zation-employee.
Tech Stack: Azure Functions v4 (TypeScript, finops-platform) · Node node:test via tsx · Azure Static Web Apps managed function (plain JS v3, zation-docs) · jose (JWT/JWKS) · Bicep · MSAL client-credentials.
Repos & phase order: Phase A (finops-platform) must ship and be reachable before Phase B (zation-docs) can be integration-tested. Do A fully, verify, then B.
File Structure
Phase A — finops-platform (branch nbo/58-customer-tenant-ids-endpoint)
- Create
apps/functions/src/utils/docsAppToken.ts— pure claim-check for the docs client-credentials token. - Create
apps/functions/src/utils/docsAppToken.test.ts— unit tests for the claim-check. - Create
apps/functions/src/utils/customerTenantList.ts— pure builder: active customers + tenants → deduped active tenant GUIDs. - Create
apps/functions/src/utils/customerTenantList.test.ts— unit tests for the builder. - Create
apps/functions/src/functions/getCustomerTenantIds.ts— the HTTP function (thin wiring).
Phase B — zation-docs (branch nbo/58-customer-tenant-gate)
- Create
api/GetRoles/roles.js— pureresolveRoles(tid, ctx). - Create
api/GetRoles/roles.test.js— unit tests (node --test). - Modify
api/GetRoles/index.js— token acquisition + fetch + cache + delegate toresolveRoles. - Modify
static/staticwebapp.config.json— route roles + 403 override. - Create
src/pages/no-access.tsx— public "not a customer" page. - Modify
infra/main.bicep— new app settings.
PHASE A — finops-platform endpoint
Task A1: Docs app-token claim check (pure)
Files:
-
Create:
apps/functions/src/utils/docsAppToken.ts -
Test:
apps/functions/src/utils/docsAppToken.test.ts -
Step 1: Write the failing test
// apps/functions/src/utils/docsAppToken.test.ts
import { test } from "node:test";
import assert from "node:assert/strict";
import { checkDocsAppTokenClaims } from "./docsAppToken";
const cfg = { expectedAppId: "shared-app-guid", zationTenantId: "zation-tid" };
const base = { iss: "https://login.microsoftonline.com/zation-tid/v2.0", tid: "zation-tid", azp: "shared-app-guid", appid: "shared-app-guid", idtyp: "app" };
test("accepts an app-only token from the shared app registration", () => {
assert.equal(checkDocsAppTokenClaims(base, cfg).valid, true);
});
test("rejects a token whose app id does not match", () => {
assert.equal(checkDocsAppTokenClaims({ ...base, azp: "other", appid: "other" }, cfg).valid, false);
});
test("rejects a user (delegated) token — idtyp not app / has oid without app match", () => {
assert.equal(checkDocsAppTokenClaims({ ...base, idtyp: undefined, azp: "other", appid: "other" }, cfg).valid, false);
});
test("rejects a wrong-issuer token", () => {
assert.equal(checkDocsAppTokenClaims({ ...base, iss: "https://evil/zation-tid/v2.0" }, cfg).valid, false);
});
- Step 2: Run test to verify it fails
Run: cd apps/functions && npx tsx --test src/utils/docsAppToken.test.ts
Expected: FAIL — checkDocsAppTokenClaims is not exported / module not found.
- Step 3: Write minimal implementation
// apps/functions/src/utils/docsAppToken.ts
// Claim-level validation for the app-only (client-credentials) token that
// zation-docs' GetRoles presents. Signature verification is done separately
// via jose JWKS in requireDocsAppToken(); this pure function checks the
// authorization claims so it can be unit-tested without network access.
export interface DocsAppTokenConfig {
expectedAppId: string; // shared App Registration client id (AUTH_CLIENT_ID)
zationTenantId: string; // Zation Entra tenant GUID
}
export interface AppTokenClaims {
iss?: string;
tid?: string;
azp?: string;
appid?: string;
idtyp?: string;
}
export function checkDocsAppTokenClaims(
claims: AppTokenClaims,
cfg: DocsAppTokenConfig
): { valid: true } | { valid: false; reason: string } {
const appId = claims.azp || claims.appid;
if (appId !== cfg.expectedAppId) return { valid: false, reason: "appid mismatch" };
if (claims.tid !== cfg.zationTenantId) return { valid: false, reason: "tid mismatch" };
const expectedIss = `https://login.microsoftonline.com/${cfg.zationTenantId}/v2.0`;
if (claims.iss !== expectedIss) return { valid: false, reason: "issuer mismatch" };
if (claims.idtyp !== "app") return { valid: false, reason: "not an app-only token" };
return { valid: true };
}
- Step 4: Run test to verify it passes
Run: cd apps/functions && npx tsx --test src/utils/docsAppToken.test.ts
Expected: PASS (4/4).
- Step 5: Commit
git add apps/functions/src/utils/docsAppToken.ts apps/functions/src/utils/docsAppToken.test.ts
git commit -m "feat(auth): docs app-token claim check (#58)"
Task A2: requireDocsAppToken — JWKS-verify wrapper
Files:
-
Modify:
apps/functions/src/utils/docsAppToken.ts -
Step 1: Add the verifier (uses jose JWKS, mirrors verifyWithJwks in userAuth.ts)
// append to apps/functions/src/utils/docsAppToken.ts
import type { HttpRequest, HttpResponseInit } from "@azure/functions";
import { createRemoteJWKSet, jwtVerify } from "jose";
let jwks: ReturnType<typeof createRemoteJWKSet> | null = null;
function zationJwks(tenantId: string) {
if (!jwks) {
jwks = createRemoteJWKSet(
new URL(`https://login.microsoftonline.com/${tenantId}/discovery/v2.0/keys`)
);
}
return jwks;
}
// Returns null when the caller is a valid docs app token; otherwise a 401 response.
export async function requireDocsAppToken(
req: HttpRequest,
cfg: DocsAppTokenConfig
): Promise<HttpResponseInit & { status: number } | null> {
const header = req.headers.get("Authorization") || "";
const token = header.startsWith("Bearer ") ? header.slice(7) : "";
if (!token) return { status: 401, jsonBody: { error: "missing bearer token" } };
try {
const { payload } = await jwtVerify(token, zationJwks(cfg.zationTenantId), {
issuer: `https://login.microsoftonline.com/${cfg.zationTenantId}/v2.0`,
});
const check = checkDocsAppTokenClaims(payload as AppTokenClaims, cfg);
if (!check.valid) return { status: 401, jsonBody: { error: check.reason } };
return null;
} catch (e) {
return { status: 401, jsonBody: { error: "token verification failed" } };
}
}
- Step 2: Typecheck
Run: cd apps/functions && npx tsc --noEmit
Expected: PASS (no type errors). The A1 tests still pass (unchanged export).
- Step 3: Commit
git add apps/functions/src/utils/docsAppToken.ts
git commit -m "feat(auth): JWKS-verified requireDocsAppToken wrapper (#58)"
Task A3: Active-customer-tenant list builder (pure)
Files:
-
Create:
apps/functions/src/utils/customerTenantList.ts -
Test:
apps/functions/src/utils/customerTenantList.test.ts -
Step 1: Write the failing test
// apps/functions/src/utils/customerTenantList.test.ts
import { test } from "node:test";
import assert from "node:assert/strict";
import { buildCustomerTenantIds, ACTIVE_ACCOUNT_STATUSES } from "./customerTenantList";
const customers = [
{ customerId: "c1", accountStatus: "Contract Signed", isActive: true },
{ customerId: "c2", accountStatus: "Active Trial", isActive: true },
{ customerId: "c3", accountStatus: "Churned", isActive: false }, // excluded
{ customerId: "c4", accountStatus: "Contract Signed", isActive: false }, // excluded (isActive)
];
const tenants = [
{ customerId: "c1", tenantId: "t1", status: "active" },
{ customerId: "c1", tenantId: "t1b", status: "active" }, // multi-tenant customer
{ customerId: "c2", tenantId: "t2", status: "pending" }, // excluded (status)
{ customerId: "c3", tenantId: "t3", status: "active" }, // excluded (churned customer)
];
test("returns active tenants of active customers, deduped, excluding zation tenant", () => {
const out = buildCustomerTenantIds(customers, tenants, "zation-tid");
assert.deepEqual([...out].sort(), ["t1", "t1b"]);
});
test("excludes the zation tenant even if present", () => {
const out = buildCustomerTenantIds(
[{ customerId: "cz", accountStatus: "Contract Signed", isActive: true }],
[{ customerId: "cz", tenantId: "zation-tid", status: "active" }],
"zation-tid"
);
assert.deepEqual([...out], []);
});
test("ACTIVE_ACCOUNT_STATUSES matches the spec set", () => {
assert.deepEqual([...ACTIVE_ACCOUNT_STATUSES].sort(), ["Active Trial", "Contract Signed", "Demo Partner"]);
});
- Step 2: Run test to verify it fails
Run: cd apps/functions && npx tsx --test src/utils/customerTenantList.test.ts
Expected: FAIL — module not found.
- Step 3: Write minimal implementation
// apps/functions/src/utils/customerTenantList.ts
export const ACTIVE_ACCOUNT_STATUSES = new Set(["Contract Signed", "Active Trial", "Demo Partner"]);
interface CustomerRow { customerId: string; accountStatus: string; isActive: boolean }
interface TenantRow { customerId: string; tenantId: string; status: string }
export function buildCustomerTenantIds(
customers: CustomerRow[],
tenants: TenantRow[],
zationTenantId: string
): string[] {
const activeCustomerIds = new Set(
customers.filter((c) => c.isActive && ACTIVE_ACCOUNT_STATUSES.has(c.accountStatus)).map((c) => c.customerId)
);
const ids = new Set<string>();
for (const t of tenants) {
if (t.status !== "active") continue;
if (!activeCustomerIds.has(t.customerId)) continue;
if (t.tenantId === zationTenantId) continue;
ids.add(t.tenantId);
}
return [...ids];
}
- Step 4: Run test to verify it passes
Run: cd apps/functions && npx tsx --test src/utils/customerTenantList.test.ts
Expected: PASS (3/3).
- Step 5: Commit
git add apps/functions/src/utils/customerTenantList.ts apps/functions/src/utils/customerTenantList.test.ts
git commit -m "feat: active-customer-tenant list builder (#58)"
Task A4: The HTTP function (wiring)
Files:
-
Create:
apps/functions/src/functions/getCustomerTenantIds.ts -
Step 1: Write the function
// apps/functions/src/functions/getCustomerTenantIds.ts
// GET /api/internal/customer-tenant-ids — returns GUIDs of active customer
// tenants (no PII). Called by zation-docs' GetRoles at login. Auth: app-only
// client-credentials token from the shared App Registration (requireDocsAppToken).
import { app, HttpRequest, HttpResponseInit, InvocationContext } from "@azure/functions";
import { getContainer } from "../utils/cosmosClient";
import { requireDocsAppToken } from "../utils/docsAppToken";
import { buildCustomerTenantIds } from "../utils/customerTenantList";
const CFG = {
expectedAppId: process.env.AUTH_CLIENT_ID || process.env.EXPECTED_CLIENT_ID || "",
zationTenantId: process.env.ZATION_TENANT_ID || "",
};
export async function getCustomerTenantIds(
request: HttpRequest,
context: InvocationContext
): Promise<HttpResponseInit> {
const unauth = await requireDocsAppToken(request, CFG);
if (unauth) return unauth;
const customersC = getContainer("customers");
const tenantsC = getContainer("tenants");
const { resources: customers } = await customersC.items
.query({ query: "SELECT c.customerId, c.accountStatus, c.isActive FROM c" })
.fetchAll();
const { resources: tenants } = await tenantsC.items
.query({ query: "SELECT c.customerId, c.tenantId, c.status FROM c" })
.fetchAll();
const tenantIds = buildCustomerTenantIds(customers, tenants, CFG.zationTenantId);
context.log(`customer-tenant-ids: ${tenantIds.length} active customer tenants`);
return {
status: 200,
jsonBody: { tenantIds, generatedAt: new Date().toISOString() },
};
}
app.http("getCustomerTenantIds", {
methods: ["GET"],
authLevel: "anonymous", // auth enforced by requireDocsAppToken (JWKS app token)
route: "internal/customer-tenant-ids",
handler: getCustomerTenantIds,
});
- Step 2: Typecheck + full util test suite
Run: cd apps/functions && npx tsc --noEmit && npx tsx --test src/utils/docsAppToken.test.ts src/utils/customerTenantList.test.ts
Expected: tsc clean; tests PASS.
- Step 3: Commit
git add apps/functions/src/functions/getCustomerTenantIds.ts
git commit -m "feat(api): GET /api/internal/customer-tenant-ids (#58)"
- Step 4: Deploy to DEV + verify reachable (per finops-platform deploy workflow). Acquire an app token for the shared App Reg (
az account get-access-token --resource <PLATFORM_API_SCOPE>or a client-credentials call) and:
Run: curl -s -o /dev/null -w "%{http_code}\n" -H "Authorization: Bearer $APP_TOKEN" https://<dev-functions-host>/api/internal/customer-tenant-ids
Expected: 200. Without the header → 401.
PHASE B — zation-docs
Task B1: resolveRoles (pure)
Files:
-
Create:
api/GetRoles/roles.js -
Test:
api/GetRoles/roles.test.js -
Step 1: Write the failing test
// api/GetRoles/roles.test.js
const { test } = require("node:test");
const assert = require("node:assert/strict");
const { resolveRoles } = require("./roles");
const ctx = { zationTenantId: "zation-tid", customerTenantIds: ["t1", "t2"] };
test("zation tenant → zation-employee", () => {
assert.deepEqual(resolveRoles("zation-tid", ctx), ["zation-employee"]);
});
test("customer tenant → customer", () => {
assert.deepEqual(resolveRoles("t2", ctx), ["customer"]);
});
test("unknown tenant → [] (fail-closed)", () => {
assert.deepEqual(resolveRoles("nope", ctx), []);
});
test("missing tid → [] (fail-closed)", () => {
assert.deepEqual(resolveRoles(undefined, ctx), []);
});
- Step 2: Run test to verify it fails
Run: cd api/GetRoles && node --test roles.test.js
Expected: FAIL — cannot find ./roles.
- Step 3: Write minimal implementation
// api/GetRoles/roles.js
function resolveRoles(tid, { zationTenantId, customerTenantIds }) {
if (!tid) return [];
if (tid === zationTenantId) return ["zation-employee"];
if (Array.isArray(customerTenantIds) && customerTenantIds.includes(tid)) return ["customer"];
return [];
}
module.exports = { resolveRoles };
- Step 4: Run test to verify it passes
Run: cd api/GetRoles && node --test roles.test.js
Expected: PASS (4/4).
- Step 5: Commit
git add api/GetRoles/roles.js api/GetRoles/roles.test.js
git commit -m "feat(GetRoles): pure resolveRoles with fail-closed default (#58)"
Task B2: GetRoles handler — token, fetch, cache
Files:
-
Modify:
api/GetRoles/index.js(replace body) -
Step 1: Rewrite index.js
// api/GetRoles/index.js
// rolesSource for SWA custom auth. Invoked server-side after each sign-in.
// tid == Zation tenant → zation-employee; tid ∈ active customer tenants → customer;
// otherwise [] (fail-closed). Customer-tenant list is fetched from the Platform
// (client-credentials app token) and cached in-memory with a short TTL.
const { resolveRoles } = require("./roles");
const ZATION_TENANT_ID = (process.env.ZATION_TENANT_ID || "").trim();
const PLATFORM_API_BASE_URL = (process.env.PLATFORM_API_BASE_URL || "").replace(/\/$/, "");
const PLATFORM_API_SCOPE = process.env.PLATFORM_API_SCOPE || "";
const AAD_CLIENT_ID = process.env.AAD_CLIENT_ID || "";
const AAD_CLIENT_SECRET = process.env.AAD_CLIENT_SECRET || "";
const CACHE_TTL_MS = 5 * 60 * 1000;
let cache = { ids: null, expires: 0 };
async function getAppToken() {
const url = `https://login.microsoftonline.com/${ZATION_TENANT_ID}/oauth2/v2.0/token`;
const body = new URLSearchParams({
client_id: AAD_CLIENT_ID,
client_secret: AAD_CLIENT_SECRET,
grant_type: "client_credentials",
scope: PLATFORM_API_SCOPE,
});
const r = await fetch(url, { method: "POST", body });
if (!r.ok) throw new Error(`token endpoint ${r.status}`);
return (await r.json()).access_token;
}
async function getCustomerTenantIds(context) {
const now = Date.now();
if (cache.ids && now < cache.expires) return cache.ids;
const token = await getAppToken();
const r = await fetch(`${PLATFORM_API_BASE_URL}/api/internal/customer-tenant-ids`, {
headers: { Authorization: `Bearer ${token}` },
});
if (!r.ok) throw new Error(`platform ${r.status}`);
const ids = (await r.json()).tenantIds || [];
cache = { ids, expires: now + CACHE_TTL_MS };
return ids;
}
module.exports = async function (context, req) {
const body = req.body || {};
const claims = Array.isArray(body.claims) ? body.claims : [];
const tidClaim = claims.find((c) => c.typ === "tid" || c.typ === "http://schemas.microsoft.com/identity/claims/tenantid");
const tid = tidClaim && tidClaim.val;
let customerTenantIds = [];
if (tid && tid !== ZATION_TENANT_ID) {
try {
customerTenantIds = await getCustomerTenantIds(context);
} catch (e) {
context.log(`GetRoles: customer-tenant lookup failed (fail-closed): ${e.message}`);
customerTenantIds = []; // fail-closed
}
}
const roles = resolveRoles(tid, { zationTenantId: ZATION_TENANT_ID, customerTenantIds });
context.res = { status: 200, headers: { "Content-Type": "application/json" }, body: { roles } };
};
- Step 2: Re-run pure tests (unchanged) + lint-parse index.js
Run: cd api/GetRoles && node --test roles.test.js && node --check index.js
Expected: tests PASS; node --check prints nothing (syntax OK).
- Step 3: Commit
git add api/GetRoles/index.js
git commit -m "feat(GetRoles): tid-based roles via cached Platform customer-tenant list (#58)"
Task B3: SWA route roles + no-access page
Files:
-
Modify:
static/staticwebapp.config.json -
Create:
src/pages/no-access.tsx -
Step 1: Change the four route rules from
["authenticated"]to["customer","zation-employee"]
In static/staticwebapp.config.json, for the routes /private, /private/platform/release-notes/latest, /private/platform/release-notes/release-notes, and /private/*, replace "allowedRoles": ["authenticated"] with "allowedRoles": ["customer", "zation-employee"]. Leave /private/internal/* as ["zation-employee"].
- Step 2: Point the 403 override at the no-access page (avoid login loop)
In the same file, change responseOverrides.403 from:
"403": { "redirect": "/.auth/login/aad?post_login_redirect_uri=/private/", "statusCode": 302 }
to:
"403": { "rewrite": "/no-access/index.html", "statusCode": 403 }
Keep 401 as-is (401 = not logged in → still redirect to login).
- Step 3: Create the public no-access page
// src/pages/no-access.tsx
import React from "react";
import Layout from "@theme/Layout";
export default function NoAccess(): JSX.Element {
return (
<Layout title="Access restricted" description="Zation customer access required">
<main style={{ maxWidth: 640, margin: "4rem auto", padding: "0 1rem" }}>
<h1>Access restricted</h1>
<p>
Your Microsoft account isn't linked to an active Zation customer, so the
private documentation isn't available. If you believe this is a mistake,
contact <a href="mailto:info@zation.io">info@zation.io</a>.
</p>
<p><a href="/">← Back to public docs</a></p>
</main>
</Layout>
);
}
- Step 4: Build to verify the page compiles and the config is valid JSON
Run: npm run build && python3 -c "import json; json.load(open('build/staticwebapp.config.json'))" && ls build/no-access/index.html
Expected: build succeeds; JSON parses; build/no-access/index.html exists.
- Step 5: Commit
git add static/staticwebapp.config.json src/pages/no-access.tsx
git commit -m "feat(swa): gate /private/* on customer role + /no-access page (#58)"
Task B4: Bicep app settings
Files:
-
Modify:
infra/main.bicep -
Step 1: Add params (near the existing aad params)*
@description('Zation Entra tenant GUID — used by GetRoles to grant zation-employee.')
param zationTenantId string
@description('Base URL of the finops-platform functions app (for the customer-tenant-ids lookup).')
param platformApiBaseUrl string
@description('OAuth scope (App-ID-URI/.default) for the client-credentials token to the Platform API.')
param platformApiScope string
- Step 2: Add them to the SWA
appsettingsresource
In the swaAppSettings resource properties, add:
ZATION_TENANT_ID: zationTenantId
PLATFORM_API_BASE_URL: platformApiBaseUrl
PLATFORM_API_SCOPE: platformApiScope
- Step 3: Add non-secret values to
infra/main.parameters.json(tenant id + base url + scope are not secrets):
"zationTenantId": { "value": "<zation-tenant-guid>" },
"platformApiBaseUrl": { "value": "https://<platform-functions-host>" },
"platformApiScope": { "value": "api://<platform-app-id>/.default" }
- Step 4: Compile
Run: cd /Users/nicolas/code/Zation/Platform/zation-docs && az bicep build --file infra/main.bicep --stdout > /dev/null
Expected: no errors.
- Step 5: Commit
git add infra/main.bicep infra/main.parameters.json
git commit -m "feat(infra): SWA app settings for customer-tenant gate (#58)"
Task B5: Integration test (real logins on a test hostname)
- Deploy Phase B to a test SWA hostname (or the docs SWA after Phase A is on PROD). Add
https://<test-host>/.auth/login/aad/callbackto the shared App Registration reply URLs first (Christoffer/Michael). Then verify:- @zation.io login →
/.auth/meuserRolescontainszation-employee;/private/internal/...loads. - customer-tenant login →
userRolescontainscustomer;/private/platform/...loads;/private/internal/...→/no-access(403). - non-customer MS account (e.g. personal @outlook.com) → no custom role;
/private/*→/no-access(403), no login loop.
- @zation.io login →
- Remove the temporary reply URL if a throwaway hostname was used.
Self-Review
- Spec coverage: Component 1 → A1–A4; Component 2 → B1–B2; Component 3 → B3; Component 4 → B4; edge cases (fail-closed, multi-tenant, status=active, zation-tenant exclusion) → A3 tests + B1 tests + B2 catch; testing section → A/B unit tests + B5 integration. Covered.
- Placeholders: angle-bracket values in Bicep params / integration hosts are real deployment values to fill at execution (tenant GUID, hosts) — not code placeholders. All code steps contain complete code.
- Type/name consistency:
checkDocsAppTokenClaims,requireDocsAppToken,buildCustomerTenantIds,ACTIVE_ACCOUNT_STATUSES,resolveRoles({ zationTenantId, customerTenantIds })used consistently across tasks and callers.
Notes / risks to confirm during execution
- App-token
idtyp/azp: confirm the client-credentials token from the shared App Reg actually carriesidtyp: "app"andazp==clientId (v2.0 endpoint). Ifidtypis absent, relax the check toazp/appidmatch + absence of user claims (oid/preferred_username). A3/A1 tests pin the intended contract. PLATFORM_API_SCOPE: the platform functions must be exposed as an app (App-ID-URI) so.defaultworks; if not yet, add an Application ID URI to the platform app registration (separate infra step, note in the finops-platform issue).- Cross-partition query cost on
customers/tenantsis acceptable at current scale + 5-min cache; revisit with a materialized view only if RU cost shows up.