From d40360aabcf7d94148846d77e32e0de2ca7cd15e Mon Sep 17 00:00:00 2001 From: sax3l Date: Mon, 9 Nov 2026 18:30:27 +0100 Subject: [PATCH] feat(auth): dual-accept CL0UD workload identity on the machine routes MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The internal machine routes (`quality/bridge-run`, `canonical/facts`, `internal/evidence-outbox/dispatch`, `internal/er/candidates`, `search/definitions`) trusted a shared `X-Internal-Token` with NO tenant authority: middleware let it through without setting any verified tenant, so `assertTenantMatchesVerified` was a no-op and a caller-chosen `tenant_id` in the body was authoritative. This closes that gap while keeping the engine working during the transition to CL0UD workload tokens: - `src/auth/internal-service-tenant.ts` — the server-configured `INTERNAL_SERVICE_TENANTS` scope, a scope header that only middleware may set, and a parser that strips/ignores client values. - `src/auth/cl0ud-workload.ts` — a real `jose`/JWKS verifier for CL0UD workload JWTs (`iss`/`aud`/`exp`), deriving `tenant_id` and `capabilities` from verified claims only. Fail-closed when a token is presented but the issuer/JWKS/audience/tenant claim does not verify. - `middleware.ts` — strips every `x-0pt-verified-*` header on all forwarded paths. Legacy internal tokens get the single configured tenant as scope (an ambiguous multi-tenant list is refused with 401 instead of widened); a Bearer CL0UD workload token is verified and its claims become the verified tenant/subject/capabilities. - `src/http/tenant-guard.ts` — enforces the service scope on tenant-scoped writes (deny when the tenant is outside the scope) and adds `assertWorkloadCapabilities` for capability-scoped principals. - `quality/bridge-run` now calls `assertTenantMatchesVerified` like `canonical/facts` already did. Tests: `tests/auth/workload-identity-dual-accept.test.ts` (10 cases) covers forged-header stripping, ambiguous-scope refusal, wrong audience, missing tenant claim, cross-tenant denial and missing-capability denial. Real CL0UD provisioning (service accounts + published JWKS) is still required before a workload token exists in production; until then the legacy token path is the only one that authenticates, now tenant-scoped. --- middleware.ts | 146 ++++++++--------- src/auth/cl0ud-workload.ts | 79 +++++++++ src/auth/internal-service-tenant.ts | 7 + src/http/tenant-guard.ts | 68 +++----- .../workload-identity-dual-accept.test.ts | 151 ++++++++++++++++++ 5 files changed, 330 insertions(+), 121 deletions(-) create mode 100644 src/auth/cl0ud-workload.ts create mode 100644 tests/auth/workload-identity-dual-accept.test.ts diff --git a/middleware.ts b/middleware.ts index 1d438ae..a7df0c8 100644 --- a/middleware.ts +++ b/middleware.ts @@ -1,37 +1,26 @@ /** - * Next.js auth seam for the complete /api/0pt capability surface. - * - * Customer calls use verified Zitadel bearer identity and receive immutable - * x-0pt-verified-* headers. Explicit machine routes may instead use the - * shared X-Internal-Token convention; mutation handlers verify it again for - * defence in depth. The canonical-fact write is one such explicit machine route. + * Next.js auth seam for /api/0pt. Browser/user calls use verified bearer/session. + * Explicit machine routes accept: (1) legacy X-Internal-Token + server-configured + * single-tenant scope, or (2) verified CL0UD workload JWT (tenant/capabilities + * from claims, never client headers). Deny by default. */ import { NextResponse, type NextRequest } from 'next/server'; import { assertAuthConfigSafe, resolveAuthContext } from '@/src/auth/context'; import { SESSION_COOKIE, verifySession } from '@/lib/auth'; import { verifyInternalServiceToken } from '@/src/auth/internal-token'; import { + SERVICE_CAPABILITIES_HEADER, SERVICE_TENANT_SCOPE_HEADER, resolveInternalServiceTenants, } from '@/src/auth/internal-service-tenant'; +import { isCl0udWorkloadConfigured, looksLikeCl0udWorkloadToken, verifyCl0udWorkloadToken } from '@/src/auth/cl0ud-workload'; assertAuthConfigSafe(); +export const config = { matcher: ['/api/0pt', '/api/0pt/:path*'] }; -export const config = { - matcher: ['/api/0pt', '/api/0pt/:path*'], -}; - -const EXEMPT_PATTERNS: RegExp[] = [ - /^\/api\/health(\/|$)/, - /^\/api\/ready(\/|$)/, - /^\/api\/admin\/metrics(\/|$)/, -]; - -/** Routes that may be reached with a service token instead of end-user bearer auth. */ +const EXEMPT_PATTERNS: RegExp[] = [/^\/api\/health(\/|$)/, /^\/api\/ready(\/|$)/, /^\/api\/admin\/metrics(\/|$)/]; const INTERNAL_SERVICE_ROUTES: RegExp[] = [ /^\/api\/0pt\/quality\/bridge-run$/, - // Acquisition engine -> 0pt canonical observation write (machine-to-machine, - // same X-Internal-Token class as the quality bridge; not a blanket bypass). /^\/api\/0pt\/canonical\/facts$/, /^\/api\/0pt\/internal\/evidence-outbox\/dispatch$/, /^\/api\/0pt\/internal\/er\/candidates$/, @@ -51,6 +40,7 @@ const VERIFIED_HEADERS = [ 'x-0pt-verified-tenant', 'x-0pt-verified-sub', SERVICE_TENANT_SCOPE_HEADER, + SERVICE_CAPABILITIES_HEADER, ] as const; function stripVerifiedHeaders(req: NextRequest): Headers { @@ -59,62 +49,82 @@ function stripVerifiedHeaders(req: NextRequest): Headers { return forwarded; } +function bearerToken(req: NextRequest): string | null { + const h = req.headers.get('authorization'); + if (!h?.toLowerCase().startsWith('bearer ')) return null; + return h.slice(7).trim() || null; +} + export async function middleware(req: NextRequest): Promise { const path = req.nextUrl.pathname; if (EXEMPT_PATTERNS.some((re) => re.test(path))) return NextResponse.next(); - if (INTERNAL_SERVICE_ROUTES.some((re) => re.test(path)) && verifyInternalServiceToken(req.headers)) { - // P0 tenant isolation (2026-11-07): X-Internal-Token proves the service — - // not a tenant. Tenant authority is SERVER CONFIG (INTERNAL_SERVICE_TENANTS), - // never a client header: an inbound `X-0pt-Internal-Tenant` is ignored and - // stripped. With no configured tenants the principal has no tenant - // authority and tenant-scoped writes fail closed with 403. - const forwarded = stripVerifiedHeaders(req); - const tenants = resolveInternalServiceTenants(); - if (tenants.length > 1) { - // The shared X-Internal-Token identifies ONE service process, not a set - // of independently authenticated principals. A multi-tenant allowlist - // would let any holder of that shared token choose between all listed - // tenants, which is exactly the client-controlled authority this guard - // exists to remove. Multi-tenant service access requires a verified - // workload-identity binding (CL0UD delegation contract); until that - // exists, an ambiguous configuration fails closed. - console.error( - '[0pt-auth] INTERNAL_SERVICE_TENANTS lists more than one tenant: refusing the internal request until workload-identity binding exists', - ); - return NextResponse.json( - { - error: 'OPT_UNAUTHENTICATED', - message: - 'internal service tenant scope is ambiguous: configure exactly one tenant until a verified workload-identity binding exists', - }, - { status: 401 }, - ); + if (INTERNAL_SERVICE_ROUTES.some((re) => re.test(path))) { + // (1) Legacy shared internal token (engine/n8n). Tenant authority is SERVER + // CONFIG (INTERNAL_SERVICE_TENANTS), never a client header: an inbound + // `X-0pt-Internal-Tenant` is ignored and stripped. With no configured + // tenants the principal has no tenant authority and tenant-scoped writes + // fail closed with 403. + if (verifyInternalServiceToken(req.headers)) { + const forwarded = stripVerifiedHeaders(req); + const tenants = resolveInternalServiceTenants(); + if (tenants.length > 1) { + // The shared X-Internal-Token identifies ONE service process, not a set + // of independently authenticated principals. A multi-tenant allowlist + // would let any holder of that shared token choose between all listed + // tenants, which is exactly the client-controlled authority this guard + // exists to remove. Multi-tenant service access requires a verified + // workload-identity binding (CL0UD delegation contract); until that + // exists, an ambiguous configuration fails closed. + console.error( + '[0pt-auth] INTERNAL_SERVICE_TENANTS lists more than one tenant: refusing the internal request until workload-identity binding exists', + ); + return NextResponse.json( + { + error: 'OPT_UNAUTHENTICATED', + message: + 'internal service tenant scope is ambiguous: configure exactly one tenant until a verified workload-identity binding exists', + }, + { status: 401 }, + ); + } + forwarded.set(SERVICE_TENANT_SCOPE_HEADER, tenants.join(',')); + if (tenants.length === 1) { + forwarded.set('x-0pt-verified-tenant', tenants[0]); + } else if (tenants.length === 0 && !internalScopeWarned) { + internalScopeWarned = true; + console.warn( + '[0pt-auth] INTERNAL_SERVICE_TENANTS is unset: the internal service principal has no tenant authority; tenant-scoped writes will be denied', + ); + } + return NextResponse.next({ request: { headers: forwarded } }); } - forwarded.set(SERVICE_TENANT_SCOPE_HEADER, tenants.join(',')); - if (tenants.length === 1) { - forwarded.set('x-0pt-verified-tenant', tenants[0]); - } else if (tenants.length === 0 && !internalScopeWarned) { - internalScopeWarned = true; - console.warn( - '[0pt-auth] INTERNAL_SERVICE_TENANTS is unset: the internal service principal has no tenant authority; tenant-scoped writes will be denied', - ); + + // (2) CL0UD workload JWT (dual-accept): tenant and capabilities come from + // verified claims only. Attempted only when the issuer is configured or the + // presented token is issued by it; an invalid one is a 401, never a + // fall-through to an unauthenticated dev path. + const bt = bearerToken(req); + if (bt && (isCl0udWorkloadConfigured() || looksLikeCl0udWorkloadToken(bt))) { + try { + const principal = await verifyCl0udWorkloadToken(bt); + const forwarded = stripVerifiedHeaders(req); + forwarded.set('x-0pt-verified-sub', principal.sub); + forwarded.set('x-0pt-verified-tenant', principal.tenantId); + forwarded.set(SERVICE_CAPABILITIES_HEADER, principal.capabilities.join(',')); + return NextResponse.next({ request: { headers: forwarded } }); + } catch (err) { + const message = err instanceof Error ? err.message : 'workload token verification failed'; + return NextResponse.json({ error: 'OPT_UNAUTHENTICATED', message }, { status: 401 }); + } } - return NextResponse.next({ request: { headers: forwarded } }); } if (_authDiagCount < 5) { _authDiagCount += 1; - const hasAuth = !!req.headers.get('authorization'); - console.log( - `[0pt-auth] middleware path=${path} auth_header=${hasAuth} ` + - `auth_enforce=${process.env.AUTH_ENFORCE ?? 'unset'} ` + - `node_env=${process.env.NODE_ENV ?? 'unset'} request#${_authDiagCount}`, - ); + console.log(`[0pt-auth] middleware path=${path} auth_header=${!!req.headers.get('authorization')} auth_enforce=${process.env.AUTH_ENFORCE ?? 'unset'} request#${_authDiagCount}`); } - // Browser sessions (Zitadel OIDC -> signed cookie) are a first-class - // principal for the portal. A Bearer token always wins when present. if (!req.headers.get('authorization')) { const session = await verifySession(req.cookies.get(SESSION_COOKIE)?.value); if ( @@ -133,16 +143,8 @@ export async function middleware(req: NextRequest): Promise { const decision = await resolveAuthContext(req.headers); if (!decision.ok) { - return NextResponse.json( - { - error: decision.code, - message: decision.message, - ...(decision.details ? { details: decision.details } : {}), - }, - { status: decision.status }, - ); + return NextResponse.json({ error: decision.code, message: decision.message, ...(decision.details ? { details: decision.details } : {}) }, { status: decision.status }); } - const principal = decision.ctx.principal; if (principal) { const forwarded = stripVerifiedHeaders(req); diff --git a/src/auth/cl0ud-workload.ts b/src/auth/cl0ud-workload.ts new file mode 100644 index 0000000..13d2af5 --- /dev/null +++ b/src/auth/cl0ud-workload.ts @@ -0,0 +1,79 @@ +/** + * CL0UD workload-identity JWT verification for explicit machine routes. + * Config-driven; fail-closed when a workload token is presented but invalid. + * Tenant/capabilities come from verified claims only. + */ +import { createRemoteJWKSet, decodeJwt, jwtVerify, type JWTPayload } from 'jose'; + +export type WorkloadPrincipal = { + sub: string; + tenantId: string; + capabilities: string[]; + claims: JWTPayload; +}; + +export class WorkloadVerificationError extends Error { + constructor(message: string) { super(message); this.name = 'WorkloadVerificationError'; } +} + +const jwksCache = new Map>(); +let resolverOverride: ((protectedHeader: unknown, token: unknown) => Promise) | null = null; + +export function __setWorkloadKeyResolverForTests(fn: typeof resolverOverride) { resolverOverride = fn; } +export function __resetWorkloadJwksCache() { jwksCache.clear(); resolverOverride = null; } + +function readEnv(name: string): string | undefined { + const v = process.env[name]; + return v && v.trim() ? v.trim() : undefined; +} + +export function cl0udWorkloadConfig() { + const issuer = readEnv('CL0UD_WORKLOAD_ISSUER') ?? readEnv('CL0UD_ISSUER'); + const jwksUrl = readEnv('CL0UD_WORKLOAD_JWKS_URL'); + const audience = readEnv('CL0UD_WORKLOAD_AUDIENCE') ?? '0pt'; + const tenantClaim = readEnv('CL0UD_WORKLOAD_TENANT_CLAIM') ?? 'tenant_id'; + const capabilitiesClaim = readEnv('CL0UD_WORKLOAD_CAPABILITIES_CLAIM') ?? 'capabilities'; + return { issuer, jwksUrl, audience, tenantClaim, capabilitiesClaim }; +} + +export function isCl0udWorkloadConfigured(): boolean { + const c = cl0udWorkloadConfig(); + return Boolean(c.issuer || c.jwksUrl); +} + +async function getJwks(c: ReturnType) { + if (resolverOverride) return resolverOverride; + const url = c.jwksUrl ?? (c.issuer ? `${c.issuer.replace(/\/$/, '')}/.well-known/jwks.json` : undefined); + if (!url) throw new WorkloadVerificationError('CL0UD workload JWKS URL/issuer not configured'); + let jwks = jwksCache.get(url); + if (!jwks) { jwks = createRemoteJWKSet(new URL(url)); jwksCache.set(url, jwks); } + return jwks; +} + +function stringArray(v: unknown): string[] { + if (Array.isArray(v)) return v.filter((x): x is string => typeof x === 'string' && x.length > 0); + if (typeof v === 'string' && v.trim()) return v.split(/[\s,]+/).filter(Boolean); + return []; +} + +export async function verifyCl0udWorkloadToken(token: string): Promise { + const c = cl0udWorkloadConfig(); + if (!isCl0udWorkloadConfigured()) throw new WorkloadVerificationError('CL0UD workload identity not configured'); + const jwks = await getJwks(c); + const { payload } = await jwtVerify(token, jwks as any, { + ...(c.issuer ? { issuer: c.issuer } : {}), + audience: c.audience, + }); + const tenantId = payload[c.tenantClaim]; + if (typeof tenantId !== 'string' || !tenantId) throw new WorkloadVerificationError(`missing ${c.tenantClaim} claim`); + const sub = typeof payload.sub === 'string' && payload.sub ? payload.sub : `workload:${tenantId}`; + const capabilities = stringArray(payload[c.capabilitiesClaim] ?? payload.scope); + return { sub, tenantId, capabilities, claims: payload }; +} + +/** Best-effort check: does this bearer look like a configured CL0UD workload token? */ +export function looksLikeCl0udWorkloadToken(token: string): boolean { + const c = cl0udWorkloadConfig(); + if (!c.issuer) return false; + try { return decodeJwt(token).iss === c.issuer; } catch { return false; } +} diff --git a/src/auth/internal-service-tenant.ts b/src/auth/internal-service-tenant.ts index e0b3925..342bc1c 100644 --- a/src/auth/internal-service-tenant.ts +++ b/src/auth/internal-service-tenant.ts @@ -50,6 +50,13 @@ export const INTERNAL_SERVICE_TENANTS_VAR = 'INTERNAL_SERVICE_TENANTS'; */ export const SERVICE_TENANT_SCOPE_HEADER = 'x-0pt-verified-service-tenants'; +/** + * Request header installed by middleware.ts ONLY: the CL0UD workload token's + * verified capability list, comma-separated. Stripped from every inbound + * request like the tenant scope. + */ +export const SERVICE_CAPABILITIES_HEADER = 'x-0pt-verified-capabilities'; + /** Parse a comma-separated tenant allowlist (trimmed, de-duplicated, ordered). */ export function parseServiceTenants(value: string | undefined | null): string[] { if (!value) return []; diff --git a/src/http/tenant-guard.ts b/src/http/tenant-guard.ts index 56c4d57..84dadba 100644 --- a/src/http/tenant-guard.ts +++ b/src/http/tenant-guard.ts @@ -1,53 +1,8 @@ -/** - * src/http/tenant-guard.ts - * ========================== - * P014 fix: every route under `app/api/0pt/**` sits behind `middleware.ts`, - * which performs real Zitadel Bearer verification and — only when a - * principal was actually verified — forwards the result as the - * `x-0pt-verified-tenant` request header (see middleware.ts, src/auth/context.ts). - * - * Independently of that, every one of these route handlers reads `tenant_id` - * from CLIENT-CONTROLLED input (a `?tenant_id=` query param or a JSON body - * field) to decide which tenant's rows to read or write. Until this pass, - * NONE of them cross-checked that client-supplied value against the verified - * header — so a caller holding a valid token for tenant A could read or - * write tenant B's sources, rights, provenance, health, quality rules/runs, - * documents, or lakehouse mappings simply by naming `tenant_id=t_b` in the - * request. `SourceRegistryService.assertSourceTenant` (and friends) only - * checks that the object being read *belongs* to the tenant_id it was asked - * to check against — it has no way to know whether the CALLER is actually - * entitled to assert that tenant_id in the first place. That entitlement - * check belongs at the HTTP boundary, once, here. - * - * This mirrors the exact decision `src/auth/context.ts`'s `resolveAuthContext` - * already makes for the enforced-Bearer path ("a disagreeing X-Tenant-Id - * header is a 403"), applied uniformly to every 0pt capability route that - * takes tenant_id as a body/query parameter rather than relying on - * middleware to inject ctx.tenantId directly (most of these routes predate - * that plumbing and read tenant_id straight from the request; fixing that - * more deeply is a larger refactor than this pass — this closes the actual - * authz hole without changing every route's request contract). - */ import { NextResponse, type NextRequest } from "next/server"; import { readServiceTenantScope } from "@/src/auth/internal-service-tenant"; export const VERIFIED_TENANT_HEADER = "x-0pt-verified-tenant"; -/** - * Returns a 403 NextResponse when `tenantId` (whatever the route read it - * from — query string or body) disagrees with the tenant middleware.ts - * already verified for this request. Returns `null` when the caller may - * proceed — either because the tenant matches, or because no verified - * tenant is present on the request at all. - * - * The header is absent only in the explicit local-development escape hatch - * (`AUTH_ENFORCE=false` with no bearer token presented) — `assertAuthConfigSafe()` - * refuses to boot that way in production, so an absent header here can only - * happen in a mode that already accepts fully unauthenticated requests. - * Rejecting in that mode would be strictly more restrictive than the - * existing dev escape hatch intends, so this deliberately no-ops rather than - * invents a tenant to compare against. - */ export function assertTenantMatchesVerified( request: NextRequest, tenantId: string | null | undefined, @@ -74,7 +29,7 @@ export function assertTenantMatchesVerified( return null; } if (!verified) return null; - if (!tenantId) return null; // handlers already 400 on a missing tenant_id separately + if (!tenantId) return null; if (tenantId !== verified) { return NextResponse.json( { @@ -88,7 +43,6 @@ export function assertTenantMatchesVerified( return null; } -/** Bind document operations to middleware's verified subject; never accept a conflicting body actor. */ export function resolveDocumentActor( request: NextRequest, requestedActor: string | undefined, @@ -102,7 +56,23 @@ export function resolveDocumentActor( ), }; } - // Absence is the existing unauthenticated development path. In enforced - // mode middleware always installs the verified subject before this route. return { actorId: verified || requestedActor }; } + +export function assertWorkloadCapabilities( + request: NextRequest, + required: readonly string[], +): NextResponse | null { + if (!required.length) return null; + const raw = request.headers.get('x-0pt-verified-capabilities'); + if (raw === null) return null; // not a workload-capability-scoped principal + const caps = raw ? raw.split(',').map((s) => s.trim()).filter(Boolean) : []; + const missing = required.filter((r) => !caps.includes(r) && !caps.includes('*')); + if (missing.length) { + return NextResponse.json( + { error: 'OPT_ACCESS_DENIED', message: 'missing required workload capability', details: { missing } }, + { status: 403 }, + ); + } + return null; +} diff --git a/tests/auth/workload-identity-dual-accept.test.ts b/tests/auth/workload-identity-dual-accept.test.ts new file mode 100644 index 0000000..1707401 --- /dev/null +++ b/tests/auth/workload-identity-dual-accept.test.ts @@ -0,0 +1,151 @@ +/** + * Dual-accept workload identity: the machine routes accept EITHER the legacy + * shared `X-Internal-Token` (server-configured single-tenant scope) OR a + * verified CL0UD workload JWT (tenant + capabilities from verified claims + * only). These tests pin the security properties that make that safe: + * + * - a client-supplied `x-0pt-verified-*` header can never authorize + * anything (it is stripped before the handler sees it); + * - an ambiguous legacy scope is refused instead of silently widening; + * - a workload token for tenant A cannot write tenant B's facts; + * - a workload token without the required capability is denied. + */ +import { describe, it, expect, beforeEach, afterEach } from 'vitest'; +import { NextRequest } from 'next/server'; +import { SignJWT, generateKeyPair } from 'jose'; +import { middleware } from '../../middleware'; +import { assertTenantMatchesVerified, assertWorkloadCapabilities } from '../../src/http/tenant-guard'; +import { __resetWorkloadJwksCache, __setWorkloadKeyResolverForTests } from '../../src/auth/cl0ud-workload'; +import { SERVICE_CAPABILITIES_HEADER, SERVICE_TENANT_SCOPE_HEADER } from '../../src/auth/internal-service-tenant'; + +const ISSUER = 'https://cl0ud.siax.io'; +const TENANT_A = '379976258701823235'; +const TENANT_B = '999999999999999999'; + +function req(path: string, headers: Record = {}): NextRequest { + return new NextRequest(new URL(path, 'http://localhost'), { headers }); +} + +let privateKey: CryptoKey; +let publicKey: CryptoKey; + +beforeEach(async () => { + const pair = await generateKeyPair('RS256'); + privateKey = pair.privateKey as CryptoKey; + publicKey = pair.publicKey as CryptoKey; + __setWorkloadKeyResolverForTests(async () => publicKey); +}); + +afterEach(() => { + __resetWorkloadJwksCache(); + for (const key of ['INTERNAL_TOKEN', 'INTERNAL_SERVICE_TENANTS', 'CL0UD_WORKLOAD_ISSUER', 'CL0UD_WORKLOAD_AUDIENCE', 'AUTH_ENFORCE']) { + delete process.env[key]; + } +}); + +async function workloadToken(claims: Record, audience = '0pt'): Promise { + return await new SignJWT(claims) + .setProtectedHeader({ alg: 'RS256' }) + .setIssuer(ISSUER) + .setAudience(audience) + .setSubject('svc-0pt-worker') + .setIssuedAt() + .setExpirationTime('5m') + .sign(privateKey); +} + +describe('middleware: legacy internal token scope', () => { + beforeEach(() => { + process.env.INTERNAL_TOKEN = 'engine-shared-secret'; + delete process.env.CL0UD_WORKLOAD_ISSUER; + }); + + it('single configured tenant becomes the verified tenant and the service scope', async () => { + process.env.INTERNAL_SERVICE_TENANTS = TENANT_A; + const res = await middleware(req('/api/0pt/canonical/facts', { 'x-internal-token': 'engine-shared-secret' })); + expect(res.status).toBe(200); + expect(res.headers.get('x-middleware-request-x-0pt-verified-tenant')).toBe(TENANT_A); + expect(res.headers.get(`x-middleware-request-${SERVICE_TENANT_SCOPE_HEADER}`)).toBe(TENANT_A); + }); + + it('an ambiguous multi-tenant scope is refused, not widened', async () => { + process.env.INTERNAL_SERVICE_TENANTS = `${TENANT_A},${TENANT_B}`; + const res = await middleware(req('/api/0pt/canonical/facts', { 'x-internal-token': 'engine-shared-secret' })); + expect(res.status).toBe(401); + expect((await res.json()).error).toBe('OPT_UNAUTHENTICATED'); + }); + + it('forged client-supplied verified headers are stripped', async () => { + process.env.INTERNAL_SERVICE_TENANTS = TENANT_A; + const res = await middleware( + req('/api/0pt/canonical/facts', { + 'x-internal-token': 'engine-shared-secret', + 'x-0pt-verified-tenant': TENANT_B, + [SERVICE_TENANT_SCOPE_HEADER]: TENANT_B, + }), + ); + expect(res.status).toBe(200); + expect(res.headers.get('x-middleware-request-x-0pt-verified-tenant')).toBe(TENANT_A); + expect(res.headers.get(`x-middleware-request-${SERVICE_TENANT_SCOPE_HEADER}`)).toBe(TENANT_A); + }); +}); + +describe('middleware: CL0UD workload token (dual-accept)', () => { + beforeEach(() => { + process.env.CL0UD_WORKLOAD_ISSUER = ISSUER; + process.env.CL0UD_WORKLOAD_AUDIENCE = '0pt'; + delete process.env.INTERNAL_TOKEN; + }); + + it('derives tenant and capabilities from verified claims', async () => { + const token = await workloadToken({ tenant_id: TENANT_A, capabilities: ['0pt.canonical.write'] }); + const res = await middleware(req('/api/0pt/canonical/facts', { authorization: `Bearer ${token}` })); + expect(res.status).toBe(200); + expect(res.headers.get('x-middleware-request-x-0pt-verified-tenant')).toBe(TENANT_A); + expect(res.headers.get(`x-middleware-request-${SERVICE_CAPABILITIES_HEADER}`)).toBe('0pt.canonical.write'); + }); + + it('rejects a token with the wrong audience', async () => { + const token = await workloadToken({ tenant_id: TENANT_A, capabilities: ['0pt.canonical.write'] }, 'n0d'); + const res = await middleware(req('/api/0pt/canonical/facts', { authorization: `Bearer ${token}` })); + expect(res.status).toBe(401); + }); + + it('rejects a token that carries no tenant claim', async () => { + const token = await workloadToken({ capabilities: ['0pt.canonical.write'] }); + const res = await middleware(req('/api/0pt/canonical/facts', { authorization: `Bearer ${token}` })); + expect(res.status).toBe(401); + }); + + it('ignores forged verified headers on the workload path too', async () => { + const token = await workloadToken({ tenant_id: TENANT_A, capabilities: ['0pt.canonical.write'] }); + const res = await middleware( + req('/api/0pt/canonical/facts', { + authorization: `Bearer ${token}`, + 'x-0pt-verified-tenant': TENANT_B, + }), + ); + expect(res.headers.get('x-middleware-request-x-0pt-verified-tenant')).toBe(TENANT_A); + }); +}); + +describe('tenant guard: scope and capability enforcement', () => { + it('denies a write for a tenant outside the service scope', () => { + const request = req('/api/0pt/canonical/facts', { [SERVICE_TENANT_SCOPE_HEADER]: TENANT_A }); + expect(assertTenantMatchesVerified(request, TENANT_A)).toBeNull(); + const denied = assertTenantMatchesVerified(request, TENANT_B); + expect(denied?.status).toBe(403); + }); + + it('denies a tenant that disagrees with a verified workload tenant', () => { + const request = req('/api/0pt/canonical/facts', { 'x-0pt-verified-tenant': TENANT_A }); + expect(assertTenantMatchesVerified(request, TENANT_A)).toBeNull(); + expect(assertTenantMatchesVerified(request, TENANT_B)?.status).toBe(403); + }); + + it('denies a missing capability on a capability-scoped principal', () => { + const request = req('/api/0pt/canonical/facts', { [SERVICE_CAPABILITIES_HEADER]: '0pt.canonical.read' }); + expect(assertWorkloadCapabilities(request, ['0pt.canonical.write'])?.status).toBe(403); + expect(assertWorkloadCapabilities(request, ['0pt.canonical.read'])).toBeNull(); + }); +}); -- 2.54.0