Files
0pt/src/auth/internal-token.ts
T
Simon AxelssonandClaude Sonnet 5 dc6aaa0e10
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
fix(data-os): P014 — close tenant-isolation + idempotency gaps, execute real restore drill
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>
2026-09-03 12:00:43 +02:00

74 lines
3.4 KiB
TypeScript

/**
* `src/auth/internal-token.ts` -- verification for the ONE machine-to-machine
* credential 0pt already uses for engine (Python) -> edge (Next.js) calls:
* the `X-Internal-Token` header, checked against the `INTERNAL_TOKEN` env var
* (the same shared secret `lib/engine.ts` and `lib/health-checks.ts` already
* send on the opposite-direction edge -> engine calls).
*
* P014 FIX: `services/api/workers/quality_bridge.py` has always called
* `POST /api/0pt/quality/bridge-run` with `X-Internal-Token`, never an
* `Authorization: Bearer` header. But this route sits under
* `/api/0pt/:path*`, which `middleware.ts` guards unconditionally with real
* Zitadel Bearer verification -- and nothing anywhere checked
* `X-Internal-Token` as an alternative credential. The result: in any
* environment with the default `AUTH_ENFORCE` posture (enforced), the
* Python engine's ONLY call into this bridge was rejected with 401 by
* middleware before the route handler ever ran. `OPT_QUALITY_BRIDGE_ENABLED=true`
* therefore could never actually succeed against a real deployment --
* quality_bridge.py's fail-open design (see its own docstring) silently
* absorbed the 401 as `status: "unavailable"`, so nothing outwardly broke,
* but the whole point of building the bridge (an advisory second opinion
* from the TS QualityService) was unreachable. Neither test suite caught
* this: the TS test (tests/p0/quality-bridge.test.ts) calls the route's
* POST function directly, bypassing middleware.ts entirely; the Python test
* (services/api/tests/test_quality_bridge.py) mocks the HTTP layer and never
* exercises the edge's real auth gate.
*
* This module verifies `X-Internal-Token` in constant time. middleware.ts
* accepts it ONLY for the one route the engine actually calls
* (`/api/0pt/quality/bridge-run`) -- not a blanket bypass for the rest of
* `/api/0pt/*`. Fails closed: an unset/empty `INTERNAL_TOKEN` env var never
* authenticates, even against an empty or missing header (no accidental
* "unset equals unset, so let it through").
*
* Uses only Web APIs (`TextEncoder`), not `node:crypto` -- Next.js
* middleware runs on the Edge runtime, which does not expose Node's
* `crypto.timingSafeEqual`.
*/
/** Minimal header accessor -- Web `Headers` and `NextRequest.headers` satisfy this. */
export interface HeaderLookup {
get(name: string): string | null | undefined;
}
export const INTERNAL_TOKEN_HEADER = "x-internal-token";
/** Constant-time-ish byte comparison (Edge-runtime safe: no node:crypto). */
function timingSafeEqualStr(a: string, b: string): boolean {
const enc = new TextEncoder();
const aBytes = enc.encode(a);
const bBytes = enc.encode(b);
const len = Math.max(aBytes.length, bBytes.length);
let diff = aBytes.length ^ bBytes.length;
for (let i = 0; i < len; i++) {
diff |= (aBytes[i] ?? 0) ^ (bBytes[i] ?? 0);
}
return diff === 0;
}
/**
* True only when `X-Internal-Token` is present AND matches a non-empty
* configured `INTERNAL_TOKEN`. Fails closed on every other combination
* (missing header, missing/empty env var, mismatch).
*/
export function verifyInternalServiceToken(
headers: HeaderLookup,
env: { INTERNAL_TOKEN?: string } = process.env,
): boolean {
const expected = env.INTERNAL_TOKEN?.trim();
if (!expected) return false;
const provided = headers.get(INTERNAL_TOKEN_HEADER);
if (!provided) return false;
return timingSafeEqualStr(provided, expected);
}