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
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:
+74
-72
@@ -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);
|
||||
|
||||
@@ -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; }
|
||||
}
|
||||
@@ -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
@@ -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();
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user