CI (SIAX Cloud) / quality (push) Successful in 51s
Security Scan (SIAX Cloud) / security (push) Successful in 26s
CI (SIAX Cloud) / security (push) Successful in 28s
CI (SIAX Cloud) / integration (push) Successful in 1m1s
CI (SIAX Cloud) / integration (pull_request) Successful in 30s
CI (SIAX Cloud) / security (pull_request) Successful in 24s
CI (SIAX Cloud) / quality (pull_request) Successful in 48s
Security Scan (SIAX Cloud) / security (pull_request) Successful in 22s
Tenant isolation (security-critical): every app/api/0pt/** route reading
tenant_id from client-controlled query/body never cross-checked it against
the token middleware.ts already verified (x-0pt-verified-tenant). Adds a
shared assertTenantMatchesVerified guard and wires it into all 20+ affected
routes. Also closes two full-leak-by-omission bugs found in this pass
(documents/capture and lakehouse/mappings GETs returned every tenant's rows
when tenant_id was simply omitted) and two "no tenant check at all" reads
(rate-policies/[id], lakehouse/mappings/[id]) reachable by ID alone.
Duplicate-delivery idempotency (correctness-critical): POST /api/orders now
honors Idempotency-Key (sql/006, replay-or-409-on-conflict, Postgres +
in-memory); the Python orchestrator's POST /orders/{id}/run — the actual
delivery trigger — is now idempotent under an asyncio lock, closing a real
concurrent-request race, not just the sequential-retry case.
Engine-duplication resolution: adaptive_router.py's docstring wrongly
invited a bypass ("see b0ts for scraping") contradicting the product's own
compliance stance; corrected, and given real (additive, not escalating)
block-signal tracking so a rate-limited/blocked source is excluded from the
plan instead of hammered. Also fixes a previously-undiscovered production
bug: the Python engine's only call into the TS quality bridge
(X-Internal-Token) was unconditionally 401'd by middleware.ts, which never
checked that credential — the advisory quality bridge was unreachable in any
environment enforcing auth.
Restore drill: executed for real against throwaway Docker Postgres
containers (not simulated). Found and fixed two real blockers along the way:
sql/004 referenced opt_datasets, which no migration ever created (chain was
never actually runnable end to end); lib/db.ts hardcoded ssl:"require",
making any non-TLS Postgres (incl. compose.dev.yaml's own local-dev
container) unreachable. After both fixes: full migration chain applies
cleanly, pg_dump/pg_restore round-trip is byte-identical (row counts +
content checksums), and real application code (lib/orders-store.ts,
SourceRegistryService's Postgres repository) reads the restored data back
correctly, including cross-tenant isolation still holding.
82 new tests (55 TS + 27 Python), all passing; full existing suite (291 TS +
55 Python) still green; typecheck clean on every changed/added file.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
113 lines
5.0 KiB
TypeScript
113 lines
5.0 KiB
TypeScript
/**
|
|
* `middleware.ts` -- Next.js request middleware = 0pt's framework-level auth
|
|
* seam (the Next analog of a Fastify `onRequest` hook).
|
|
*
|
|
* This runs the Zitadel verification (src/auth/context.ts ->
|
|
* src/auth/zitadel.ts) in front of every 0pt capability route (/api/0pt/*).
|
|
* It does NOT touch the v0-generated UI routes or the unrelated
|
|
* payment/order APIs (see the matcher below and the note in the PR that
|
|
* introduced the fail-closed default).
|
|
*
|
|
* AUTH_ENFORCE unset, or anything except "false" (DEFAULT, enforced):
|
|
* a valid verified token is required; 401 on missing/invalid, 403 when
|
|
* X-Tenant-Id disagrees with the token tenant. On success the verified
|
|
* principal is forwarded to the handler as `x-0pt-verified-sub` /
|
|
* `x-0pt-verified-tenant` request headers.
|
|
* AUTH_ENFORCE == "false" (explicit local-dev escape hatch): never rejects.
|
|
* REFUSED in production -- `assertAuthConfigSafe()` below throws at module
|
|
* load, so a production instance cannot boot with auth switched off.
|
|
*
|
|
* Health/metrics are exempt (they are also outside the matcher below, but the
|
|
* exemption is kept explicit for robustness).
|
|
*
|
|
* FIX (2026-08-31): matcher now covers BOTH /api/0pt (exact) and /api/0pt/:path*
|
|
* to close the gap where a bare GET /api/0pt (no trailing path) was routed around
|
|
* the middleware on Vercel's Edge runtime.
|
|
*
|
|
* FIX (P014): one route under this matcher, /api/0pt/quality/bridge-run, is
|
|
* not called by a customer's Zitadel-authenticated session at all -- it is
|
|
* called BY the Python engine (services/api/workers/quality_bridge.py) using
|
|
* the pre-existing X-Internal-Token shared-secret convention, never a Bearer
|
|
* token. Before this fix, that meant middleware always rejected the engine's
|
|
* real call with 401 (see src/auth/internal-token.ts's doc comment for the
|
|
* full story) -- the quality bridge was permanently unreachable in any
|
|
* environment enforcing auth. INTERNAL_ONLY_ROUTES lets a request to that
|
|
* ONE path through on a verified X-Internal-Token instead of a Bearer token;
|
|
* every other /api/0pt/* route is untouched and still requires the normal
|
|
* Zitadel flow below. A request to that route WITHOUT a valid internal token
|
|
* still falls through to the ordinary Bearer check (e.g. an operator testing
|
|
* it manually with their own token), it is not a blanket bypass.
|
|
*/
|
|
|
|
import { NextResponse, type NextRequest } from 'next/server';
|
|
import { assertAuthConfigSafe, resolveAuthContext } from '@/src/auth/context';
|
|
import { verifyInternalServiceToken } from '@/src/auth/internal-token';
|
|
|
|
// STARTUP GATE: fail to boot rather than serve /api/0pt/* openly in production.
|
|
assertAuthConfigSafe();
|
|
|
|
// Only guard the 0pt capability surface. The matcher keeps this middleware off
|
|
// the UI, auth, and payment routes entirely.
|
|
export const config = {
|
|
matcher: ['/api/0pt', '/api/0pt/:path*'],
|
|
};
|
|
|
|
const EXEMPT_PATTERNS: RegExp[] = [
|
|
/^\/api\/health(\/|$)/,
|
|
/^\/api\/ready(\/|$)/,
|
|
/^\/api\/admin\/metrics(\/|$)/,
|
|
];
|
|
|
|
/** The one 0pt route reached by a service (the Python engine), not a user session. */
|
|
const INTERNAL_ONLY_ROUTES: RegExp[] = [/^\/api\/0pt\/quality\/bridge-run$/];
|
|
|
|
// Module-level diagnostics counter — first 5 auth requests in a process lifetime
|
|
// log their outcome so the production auth gap (S1) can be traced to root cause.
|
|
let _authDiagCount = 0;
|
|
|
|
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_ONLY_ROUTES.some((re) => re.test(path)) && verifyInternalServiceToken(req.headers)) {
|
|
// Machine-to-machine call, verified via the shared X-Internal-Token
|
|
// secret rather than a user's Bearer token. Deliberately does NOT set
|
|
// x-0pt-verified-* headers -- there is no per-user principal here, and
|
|
// the route requires+validates tenant_id from its own request body
|
|
// (qualityBridgeRunSchema), which is the actual tenant the engine is
|
|
// running the order for.
|
|
return NextResponse.next();
|
|
}
|
|
|
|
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}`,
|
|
);
|
|
}
|
|
|
|
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 = new Headers(req.headers);
|
|
forwarded.set('x-0pt-verified-sub', principal.sub);
|
|
forwarded.set('x-0pt-verified-tenant', principal.tenantId);
|
|
return NextResponse.next({ request: { headers: forwarded } });
|
|
}
|
|
|
|
return NextResponse.next();
|
|
}
|