Merge pull request 'feat(auth): dual-accept CL0UD workload identity on the machine routes' (#525) from feat/workload-identity-dual-accept-20261108 into main
CI (SIAX Cloud) / Component conformance (blocking) (push) Successful in 9s
CI (SIAX Cloud) / security (push) Successful in 14s
Security Scan (SIAX Cloud) / security (push) Successful in 31s
CI (SIAX Cloud) / sonar (push) Successful in 1m38s
Release chain (G17, ADR-W16-003) / Build, push, G17 (SBOM/scan/attest/verify/evidence) (push) Successful in 1m58s
Deploy to Coolify / deploy (push) Successful in 2m2s
CI (SIAX Cloud) / quality (push) Successful in 10m14s
CI (SIAX Cloud) / integration (push) Successful in 10m18s
CI (SIAX Cloud) / sonar (pull_request) Skipped
masterplan-lock / check (pull_request) Successful in 8s
masterplan-lock / check (push) Successful in 11s
CI (SIAX Cloud) / Component conformance (blocking) (pull_request) Successful in 16s
CI (SIAX Cloud) / security (pull_request) Successful in 27s
Security Scan (SIAX Cloud) / security (pull_request) Successful in 33s
CI (SIAX Cloud) / integration (pull_request) Successful in 9m58s
CI (SIAX Cloud) / quality (pull_request) Successful in 10m43s

This commit was merged in pull request #525.
This commit is contained in:
2026-11-09 17:40:31 +00:00
5 changed files with 330 additions and 121 deletions
+74 -72
View File
@@ -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<NextResponse> {
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<NextResponse> {
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);
+79
View File
@@ -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<string, ReturnType<typeof createRemoteJWKSet>>();
let resolverOverride: ((protectedHeader: unknown, token: unknown) => Promise<CryptoKey | Uint8Array>) | 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<typeof cl0udWorkloadConfig>) {
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<WorkloadPrincipal> {
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; }
}
+7
View File
@@ -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 [];
+19 -49
View File
@@ -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;
}
@@ -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<string, string> = {}): 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<string, unknown>, audience = '0pt'): Promise<string> {
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();
});
});