CI (SIAX Cloud) / sonar (pull_request) Skipped
masterplan-lock / check (push) Successful in 4s
masterplan-lock / check (pull_request) Successful in 8s
CI (SIAX Cloud) / security (pull_request) Successful in 35s
Security Scan (SIAX Cloud) / security (pull_request) Successful in 44s
CI (SIAX Cloud) / integration (pull_request) Successful in 9m48s
CI (SIAX Cloud) / quality (pull_request) Successful in 10m14s
Den delade X-Internal-Token identifierar EN tjänsteprocess, inte flera oberoende principaler. En allowlist med flera tenants skulle låta varje tokeninnehavare välja mellan dem — samma klientstyrda auktoritet som guarden ska ta bort. Multi-tenant-tjänsteåtkomst kräver verifierad workload-identity-binding (CL0UD-delegation); tills den finns failar en ambig konfiguration stängt (401) och ingen identitet forwardas. Tester: multi-tenant nekas utan side effect, en tenant fungerar, 75/75 i auth+ cross-tenant-sviterna, tsc ren.
158 lines
6.2 KiB
TypeScript
158 lines
6.2 KiB
TypeScript
/**
|
|
* 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.
|
|
*/
|
|
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_TENANT_SCOPE_HEADER,
|
|
resolveInternalServiceTenants,
|
|
} from '@/src/auth/internal-service-tenant';
|
|
|
|
assertAuthConfigSafe();
|
|
|
|
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 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$/,
|
|
/^\/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,
|
|
] as const;
|
|
|
|
function stripVerifiedHeaders(req: NextRequest): Headers {
|
|
const forwarded = new Headers(req.headers);
|
|
for (const name of VERIFIED_HEADERS) forwarded.delete(name);
|
|
return forwarded;
|
|
}
|
|
|
|
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 },
|
|
);
|
|
}
|
|
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 } });
|
|
}
|
|
|
|
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}`,
|
|
);
|
|
}
|
|
|
|
// 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 (
|
|
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) } });
|
|
}
|