CI (SIAX Cloud) / sonar (pull_request) Skipped
masterplan-lock / check (pull_request) Successful in 7s
masterplan-lock / check (push) Successful in 7s
CI (SIAX Cloud) / Component conformance (blocking) (pull_request) Successful in 13s
Security Scan (SIAX Cloud) / security (pull_request) Successful in 17s
CI (SIAX Cloud) / security (pull_request) Successful in 18s
CI (SIAX Cloud) / integration (pull_request) Successful in 27s
CI (SIAX Cloud) / quality (pull_request) Successful in 10m5s
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.
160 lines
7.0 KiB
TypeScript
160 lines
7.0 KiB
TypeScript
/**
|
|
* 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*'] };
|
|
|
|
const EXEMPT_PATTERNS: RegExp[] = [/^\/api\/health(\/|$)/, /^\/api\/ready(\/|$)/, /^\/api\/admin\/metrics(\/|$)/];
|
|
const INTERNAL_SERVICE_ROUTES: RegExp[] = [
|
|
/^\/api\/0pt\/quality\/bridge-run$/,
|
|
/^\/api\/0pt\/canonical\/facts$/,
|
|
/^\/api\/0pt\/internal\/evidence-outbox\/dispatch$/,
|
|
/^\/api\/0pt\/internal\/er\/candidates$/,
|
|
/^\/api\/0pt\/search\/definitions$/,
|
|
];
|
|
|
|
let _authDiagCount = 0;
|
|
let internalScopeWarned = false;
|
|
|
|
/**
|
|
* Headers that only middleware may set. Anything a client sends under these
|
|
* names is a forgery attempt and is removed before forwarding — including on
|
|
* the internal-token path, which previously passed the original header map
|
|
* through unchanged.
|
|
*/
|
|
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 {
|
|
const forwarded = new Headers(req.headers);
|
|
for (const name of VERIFIED_HEADERS) forwarded.delete(name);
|
|
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))) {
|
|
// (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 } });
|
|
}
|
|
|
|
// (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 });
|
|
}
|
|
}
|
|
}
|
|
|
|
if (_authDiagCount < 5) {
|
|
_authDiagCount += 1;
|
|
console.log(`[0pt-auth] middleware path=${path} auth_header=${!!req.headers.get('authorization')} auth_enforce=${process.env.AUTH_ENFORCE ?? 'unset'} request#${_authDiagCount}`);
|
|
}
|
|
|
|
if (!req.headers.get('authorization')) {
|
|
const session = await verifySession(req.cookies.get(SESSION_COOKIE)?.value);
|
|
if (
|
|
session &&
|
|
typeof session.sub === 'string' &&
|
|
typeof session.tenantId === 'string' &&
|
|
typeof session.exp === 'number' &&
|
|
session.exp > Date.now()
|
|
) {
|
|
const forwarded = stripVerifiedHeaders(req);
|
|
forwarded.set('x-0pt-verified-sub', session.sub);
|
|
forwarded.set('x-0pt-verified-tenant', session.tenantId);
|
|
return NextResponse.next({ request: { headers: forwarded } });
|
|
}
|
|
}
|
|
|
|
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 });
|
|
}
|
|
const principal = decision.ctx.principal;
|
|
if (principal) {
|
|
const forwarded = stripVerifiedHeaders(req);
|
|
forwarded.set('x-0pt-verified-sub', principal.sub);
|
|
forwarded.set('x-0pt-verified-tenant', principal.tenantId);
|
|
return NextResponse.next({ request: { headers: forwarded } });
|
|
}
|
|
|
|
// No verified principal (explicit AUTH_ENFORCE=false development mode).
|
|
// Never forward a client-named verified header, even in dev.
|
|
return NextResponse.next({ request: { headers: stripVerifiedHeaders(req) } });
|
|
}
|