From 849e7ae802dcc1ccac484bf2e32a1b920a7fb0c0 Mon Sep 17 00:00:00 2001 From: SIAX Agent Date: Sat, 7 Nov 2026 14:55:59 +0100 Subject: [PATCH 1/3] =?UTF-8?q?fix(auth):=20internal-token=20requests=20f?= =?UTF-8?q?=C3=A5r=20verifierad=20tenant=20(P0=20tenant-isolering)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Bevisat: POST /api/0pt/canonical/facts med X-Internal-Token och tenant_id 999999999999999999 returnerade 201 och skrev data i annan tenant. Orsak: internal-token-grenen returnerade next() utan x-0pt-verified-tenant, så assertTenantMatchesVerified blev no-op. Fix: INTERNAL_TENANT_ID (eller X-0pt-Internal-Tenant) sätts som verifierad tenant, vilket gör guard-enforcement identisk med verifierad Bearer. Varnar om variabeln saknas. --- middleware.ts | 20 +++++++++++++++++++- 1 file changed, 19 insertions(+), 1 deletion(-) diff --git a/middleware.ts b/middleware.ts index 1af30ef..cfd89f4 100644 --- a/middleware.ts +++ b/middleware.ts @@ -35,13 +35,31 @@ const INTERNAL_SERVICE_ROUTES: RegExp[] = [ ]; let _authDiagCount = 0; +let internalTenantWarned = false; export async function middleware(req: NextRequest): Promise { 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)) { - return NextResponse.next(); + // P014/P0 tenant isolation (2026-11-07): the internal-token path used to + // return next() WITHOUT a verified tenant, which made + // assertTenantMatchesVerified a no-op and let a service principal write + // ANY tenant_id (proven: POST canonical/facts with tenant_id + // 999999999999999999 returned 201). When INTERNAL_TENANT_ID is configured + // the guard is now enforced exactly like a verified Bearer principal. + const forwarded = new Headers(req.headers); + const internalTenant = + req.headers.get('x-0pt-internal-tenant') ?? process.env.INTERNAL_TENANT_ID; + if (internalTenant) { + forwarded.set('x-0pt-verified-tenant', internalTenant); + } else if (!internalTenantWarned) { + internalTenantWarned = true; + console.warn( + '[0pt-auth] INTERNAL_TENANT_ID is unset: internal-token requests are not tenant-scoped (set it in production)', + ); + } + return NextResponse.next({ request: { headers: forwarded } }); } if (_authDiagCount < 5) { -- 2.54.0 From b97ad0b2f43c3717b67013a257bb214358e27693 Mon Sep 17 00:00:00 2001 From: SIAX Agent Date: Sat, 7 Nov 2026 15:32:51 +0100 Subject: [PATCH 2/3] fix(auth): bind internal-service tenant authority to server config The first revision of this fix copied a CLIENT header (X-0pt-Internal-Tenant) or a single INTERNAL_TENANT_ID into x-0pt-verified-tenant. That is not an authorization source: any holder of the shared internal token could still name any tenant, leaving the original cross-tenant write reachable through a different header. - INTERNAL_SERVICE_TENANTS (server env) is now the only tenant authority for the X-Internal-Token principal; empty => no authority (fail closed, 403). - Middleware strips inbound x-0pt-verified-{tenant,sub} and the service scope header on every path, including the dev escape hatch. - x-0pt-verified-service-tenants carries the allowlist; the tenant guard checks membership and denies anything outside it. - POST /api/0pt/quality/bridge-run now runs the same tenant guard as canonical/facts (it auto-provisions tenant-scoped rules). - 16 new regression tests; auth + cross-tenant + canonical + bridge suites 87/87; tsc --noEmit clean. --- .env.example | 7 + app/api/0pt/quality/bridge-run/route.ts | 6 + middleware.ts | 59 ++-- src/auth/internal-service-tenant.ts | 74 +++++ src/http/tenant-guard.ts | 21 ++ .../auth/internal-token-tenant-scope.test.ts | 252 ++++++++++++++++++ 6 files changed, 401 insertions(+), 18 deletions(-) create mode 100644 src/auth/internal-service-tenant.ts create mode 100644 tests/auth/internal-token-tenant-scope.test.ts diff --git a/.env.example b/.env.example index ea25440..7d56418 100644 --- a/.env.example +++ b/.env.example @@ -58,6 +58,13 @@ DEV_LOGIN_KEY= # Shared token for internal/service-to-service calls; sent as X-Internal-Token # on every engineFetch() call (lib/engine.ts). INTERNAL_TOKEN=change-me-internal-token +# Tenant authority for the internal-token principal. Comma-separated allowlist +# of tenants this deployment's engine is entitled to write for, e.g. +# "379976258701823235". This is the ONLY source of tenant authority for +# X-Internal-Token: a request header may never name a tenant. Empty/unset means +# the service principal has no tenant authority and tenant-scoped writes +# (canonical facts, quality bridge runs) are denied with 403. +INTERNAL_SERVICE_TENANTS= # --------------------------------------------------------------------------- # Zitadel / OIDC Bearer verification (Wave 2 hardening) -- wired in code today diff --git a/app/api/0pt/quality/bridge-run/route.ts b/app/api/0pt/quality/bridge-run/route.ts index ecdf72b..87c96a3 100644 --- a/app/api/0pt/quality/bridge-run/route.ts +++ b/app/api/0pt/quality/bridge-run/route.ts @@ -26,6 +26,7 @@ import { parseBody, qualityBridgeRunSchema } from "@/src/validation/0pt-schemas" import { qualityService } from "@/src/services/quality"; import { ensureBaselineRules } from "@/src/quality/quality-bridge"; import { siaxErrorResponse } from "@/src/http/siax-error-response"; +import { assertTenantMatchesVerified } from "@/src/http/tenant-guard"; export async function POST(request: NextRequest) { try { @@ -35,6 +36,11 @@ export async function POST(request: NextRequest) { return NextResponse.json(__parsed.body, { status: __parsed.status }); } const body = __parsed.data; + // Tenant-scoped write (auto-provisions baseline rules + stores a run): + // the internal service principal must stay inside its server-configured + // tenant allowlist, never a tenant named by the caller. + const guard = assertTenantMatchesVerified(request, body.tenant_id); + if (guard) return guard; await ensureBaselineRules(qualityService, { tenant_id: body.tenant_id, diff --git a/middleware.ts b/middleware.ts index cfd89f4..ff7f251 100644 --- a/middleware.ts +++ b/middleware.ts @@ -10,6 +10,10 @@ 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(); @@ -35,28 +39,45 @@ const INTERNAL_SERVICE_ROUTES: RegExp[] = [ ]; let _authDiagCount = 0; -let internalTenantWarned = false; +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 { 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)) { - // P014/P0 tenant isolation (2026-11-07): the internal-token path used to - // return next() WITHOUT a verified tenant, which made - // assertTenantMatchesVerified a no-op and let a service principal write - // ANY tenant_id (proven: POST canonical/facts with tenant_id - // 999999999999999999 returned 201). When INTERNAL_TENANT_ID is configured - // the guard is now enforced exactly like a verified Bearer principal. - const forwarded = new Headers(req.headers); - const internalTenant = - req.headers.get('x-0pt-internal-tenant') ?? process.env.INTERNAL_TENANT_ID; - if (internalTenant) { - forwarded.set('x-0pt-verified-tenant', internalTenant); - } else if (!internalTenantWarned) { - internalTenantWarned = true; + // 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(); + 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_TENANT_ID is unset: internal-token requests are not tenant-scoped (set it in production)', + '[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 } }); @@ -83,7 +104,7 @@ export async function middleware(req: NextRequest): Promise { typeof session.exp === 'number' && session.exp > Date.now() ) { - const forwarded = new Headers(req.headers); + 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 } }); @@ -104,11 +125,13 @@ export async function middleware(req: NextRequest): Promise { const principal = decision.ctx.principal; if (principal) { - const forwarded = new Headers(req.headers); + 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 } }); } - return NextResponse.next(); + // 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) } }); } diff --git a/src/auth/internal-service-tenant.ts b/src/auth/internal-service-tenant.ts new file mode 100644 index 0000000..e0b3925 --- /dev/null +++ b/src/auth/internal-service-tenant.ts @@ -0,0 +1,74 @@ +/** + * `src/auth/internal-service-tenant.ts` — server-side tenant scope for the + * machine-to-machine principal authenticated by `X-Internal-Token`. + * + * WHY THIS EXISTS (P0, 2026-11-07) + * -------------------------------- + * `X-Internal-Token` proves that the caller is the SIAX engine process. It + * does NOT prove which tenant that process may act for. The first attempt at + * fixing the "internal token bypasses the verified-tenant guard" hole copied a + * CLIENT HEADER (`X-0pt-Internal-Tenant`) or fell back to a single + * `INTERNAL_TENANT_ID` env var: + * + * const internalTenant = + * req.headers.get('x-0pt-internal-tenant') ?? process.env.INTERNAL_TENANT_ID; + * + * That is not an authorization source. Any holder of the shared internal token + * could still name any tenant in a request header, which is exactly the + * cross-tenant write the fix was supposed to stop (tenant_id + * 999999999999999999 returned 201 before). + * + * The correct model mirrors a verified Bearer token: the principal's tenant + * authority is a SERVER-SIDE fact, never a request field. + * + * - `INTERNAL_SERVICE_TENANTS` is a comma-separated allowlist configured on + * the 0pt deployment (env/secret), i.e. "the tenants this engine instance + * is entitled to write for". + * - When the allowlist is empty, the internal principal has NO tenant + * authority: tenant-scoped writes fail closed (403). Routes that do not + * carry a tenant (evidence outbox dispatch, ER candidates, search + * definitions) are unaffected. + * - A single allowlisted tenant is ALSO placed in `x-0pt-verified-tenant` + * by middleware so existing single-tenant checks keep working; two or more + * are carried in `x-0pt-verified-service-tenants` and the tenant guard + * checks membership. + * - Any inbound value for either header is stripped by middleware before + * forwarding, so a client can never forge tenant authority. + * + * No client header is read here. `X-0pt-Internal-Tenant` is deliberately not a + * contract. + */ + +/** Env var: comma-separated tenants this internal service principal may assert. */ +export const INTERNAL_SERVICE_TENANTS_VAR = 'INTERNAL_SERVICE_TENANTS'; + +/** + * Request header installed by middleware.ts ONLY (never accepted from a + * client, and stripped from every inbound request). Carries the full + * server-configured service tenant scope; may be the empty string, which means + * "service principal without tenant authority". + */ +export const SERVICE_TENANT_SCOPE_HEADER = 'x-0pt-verified-service-tenants'; + +/** Parse a comma-separated tenant allowlist (trimmed, de-duplicated, ordered). */ +export function parseServiceTenants(value: string | undefined | null): string[] { + if (!value) return []; + const tenants: string[] = []; + for (const part of value.split(',')) { + const tenant = part.trim(); + if (tenant && !tenants.includes(tenant)) tenants.push(tenant); + } + return tenants; +} + +/** Read the configured service tenant allowlist from the environment. */ +export function resolveInternalServiceTenants( + env: { INTERNAL_SERVICE_TENANTS?: string } = process.env, +): string[] { + return parseServiceTenants(env.INTERNAL_SERVICE_TENANTS); +} + +/** Read the scope middleware installed on the request (already stripped+set). */ +export function readServiceTenantScope(headers: { get(name: string): string | null | undefined }): string[] { + return parseServiceTenants(headers.get(SERVICE_TENANT_SCOPE_HEADER)); +} diff --git a/src/http/tenant-guard.ts b/src/http/tenant-guard.ts index 364726e..56c4d57 100644 --- a/src/http/tenant-guard.ts +++ b/src/http/tenant-guard.ts @@ -29,6 +29,7 @@ * 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"; @@ -52,6 +53,26 @@ export function assertTenantMatchesVerified( tenantId: string | null | undefined, ): NextResponse | null { const verified = request.headers.get(VERIFIED_TENANT_HEADER); + // Internal service principal: tenant authority is the SERVER-CONFIGURED + // allowlist middleware installed (never a client header). The scope header is + // present even when the allowlist is empty, which means "no tenant + // authority" -> fail closed. Routes that do not take a tenant never call + // this guard, so evidence dispatch / ER / search definitions keep working. + if (request.headers.get("x-0pt-verified-service-tenants") !== null) { + if (!tenantId) return null; // handlers already 400 on a missing tenant_id separately + const scope = readServiceTenantScope(request.headers); + if (!scope.includes(tenantId)) { + return NextResponse.json( + { + error: "OPT_ACCESS_DENIED", + message: "tenant_id is outside the internal service principal's configured tenant scope", + details: { requested_tenant: tenantId, service_tenant_scope: scope }, + }, + { status: 403 }, + ); + } + return null; + } if (!verified) return null; if (!tenantId) return null; // handlers already 400 on a missing tenant_id separately if (tenantId !== verified) { diff --git a/tests/auth/internal-token-tenant-scope.test.ts b/tests/auth/internal-token-tenant-scope.test.ts new file mode 100644 index 0000000..674bea8 --- /dev/null +++ b/tests/auth/internal-token-tenant-scope.test.ts @@ -0,0 +1,252 @@ +/** + * P0 regression (2026-11-07): tenant authority for the machine-to-machine + * (`X-Internal-Token`) principal is SERVER CONFIGURATION, never a client + * header. + * + * The first attempt at this fix read `X-0pt-Internal-Tenant` (or a single + * `INTERNAL_TENANT_ID`) and copied it into `x-0pt-verified-tenant`. Any holder + * of the shared internal token could then still name any tenant, so the + * original cross-tenant write (tenant_id 999999999999999999 -> 201) remained + * possible through a different header. These tests pin the corrected model: + * + * - `INTERNAL_SERVICE_TENANTS` (server env) is the only source of tenant + * authority for the service principal. + * - No configured scope => tenant-scoped writes are denied (fail closed). + * - Client-supplied `x-0pt-internal-tenant`, `x-0pt-verified-tenant` and + * `x-0pt-verified-service-tenants` are ignored and stripped. + * - Bearer/session principals are unchanged. + */ +import { describe, it, expect, beforeEach, afterEach } from 'vitest'; +import { NextRequest } from 'next/server'; +import { middleware } from '../../middleware'; +import { + parseServiceTenants, + resolveInternalServiceTenants, + SERVICE_TENANT_SCOPE_HEADER, +} from '@/src/auth/internal-service-tenant'; +import { assertTenantMatchesVerified } from '@/src/http/tenant-guard'; +import { POST as canonicalPOST } from '@/app/api/0pt/canonical/facts/route'; +import { POST as bridgePOST } from '@/app/api/0pt/quality/bridge-run/route'; +import { POST as evidenceDispatchPOST } from '@/app/api/0pt/internal/evidence-outbox/dispatch/route'; + +const INTERNAL_TOKEN = 'engine-shared-secret'; +const SCOPED_ROUTE = '/api/0pt/canonical/facts'; + +function req(path: string, headers: Record = {}): NextRequest { + return new NextRequest(new URL(path, 'http://localhost'), { headers }); +} + +/** Headers middleware forwarded to the route handler (Next stores them here). */ +function forwarded(res: { headers: { get(name: string): string | null } }, name: string): string | null { + return res.headers.get(`x-middleware-request-${name.toLowerCase()}`); +} + +function guardRequest(headers: Record): NextRequest { + return new NextRequest(new URL(SCOPED_ROUTE, 'http://localhost'), { + method: 'POST', + headers: { 'content-type': 'application/json', ...headers }, + }); +} + +describe('parseServiceTenants / resolveInternalServiceTenants', () => { + it('splits, trims and de-duplicates the allowlist', () => { + expect(parseServiceTenants(' t_a , t_b ,, t_a ')).toEqual(['t_a', 't_b']); + }); + + it('an unset/empty allowlist means no tenant authority', () => { + expect(parseServiceTenants(undefined)).toEqual([]); + expect(parseServiceTenants('')).toEqual([]); + expect(resolveInternalServiceTenants({})).toEqual([]); + }); +}); + +describe('middleware: internal-token tenant authority comes from server config', () => { + const saved = { + auth: process.env.AUTH_ENFORCE, + token: process.env.INTERNAL_TOKEN, + tenants: process.env.INTERNAL_SERVICE_TENANTS, + node: process.env.NODE_ENV, + }; + + beforeEach(() => { + delete process.env.AUTH_ENFORCE; // default posture: enforced + process.env.INTERNAL_TOKEN = INTERNAL_TOKEN; + delete process.env.INTERNAL_SERVICE_TENANTS; + }); + + afterEach(() => { + const values: Record = { + AUTH_ENFORCE: saved.auth, + INTERNAL_TOKEN: saved.token, + INTERNAL_SERVICE_TENANTS: saved.tenants, + NODE_ENV: saved.node, + }; + for (const [key, value] of Object.entries(values)) { + if (value === undefined) delete process.env[key]; + else process.env[key] = value; + } + }); + + it('single allowlisted tenant is installed as the verified tenant', async () => { + process.env.INTERNAL_SERVICE_TENANTS = 't_a'; + const res = await middleware(req(SCOPED_ROUTE, { 'x-internal-token': INTERNAL_TOKEN })); + expect(res.status).toBe(200); + expect(forwarded(res, 'x-0pt-verified-tenant')).toBe('t_a'); + expect(forwarded(res, SERVICE_TENANT_SCOPE_HEADER)).toBe('t_a'); + }); + + it('multiple allowlisted tenants are carried as a scope, not as one verified tenant', async () => { + process.env.INTERNAL_SERVICE_TENANTS = 't_a,t_b'; + const res = await middleware(req(SCOPED_ROUTE, { 'x-internal-token': INTERNAL_TOKEN })); + expect(forwarded(res, 'x-0pt-verified-tenant')).toBeNull(); + expect(forwarded(res, SERVICE_TENANT_SCOPE_HEADER)).toBe('t_a,t_b'); + }); + + it('with no configured scope the service principal has no tenant authority (empty scope header)', async () => { + const res = await middleware(req(SCOPED_ROUTE, { 'x-internal-token': INTERNAL_TOKEN })); + expect(res.status).toBe(200); + expect(forwarded(res, 'x-0pt-verified-tenant')).toBeNull(); + expect(forwarded(res, SERVICE_TENANT_SCOPE_HEADER)).toBe(''); + }); + + it('IGNORES a client-supplied X-0pt-Internal-Tenant (not an authority source)', async () => { + process.env.INTERNAL_SERVICE_TENANTS = 't_a'; + const res = await middleware( + req(SCOPED_ROUTE, { + 'x-internal-token': INTERNAL_TOKEN, + 'x-0pt-internal-tenant': 't_victim', + }), + ); + expect(forwarded(res, 'x-0pt-verified-tenant')).toBe('t_a'); + }); + + it('STRIPS a forged x-0pt-verified-tenant and forged scope header', async () => { + process.env.INTERNAL_SERVICE_TENANTS = 't_a'; + const res = await middleware( + req(SCOPED_ROUTE, { + 'x-internal-token': INTERNAL_TOKEN, + 'x-0pt-verified-tenant': 't_victim', + 'x-0pt-verified-sub': 'user_victim', + 'x-0pt-verified-service-tenants': 't_victim', + }), + ); + expect(forwarded(res, 'x-0pt-verified-tenant')).toBe('t_a'); + expect(forwarded(res, 'x-0pt-verified-sub')).toBeNull(); + expect(forwarded(res, SERVICE_TENANT_SCOPE_HEADER)).toBe('t_a'); + }); + + it('strips forged verified headers on the unauthenticated development path too', async () => { + process.env.AUTH_ENFORCE = 'false'; + const res = await middleware(req('/api/0pt/documents/capture', { 'x-0pt-verified-tenant': 't_victim' })); + expect(forwarded(res, 'x-0pt-verified-tenant')).toBeNull(); + }); + + it('an invalid internal token still falls through to 401', async () => { + const res = await middleware(req(SCOPED_ROUTE, { 'x-internal-token': 'wrong' })); + expect(res.status).toBe(401); + }); +}); + +describe('assertTenantMatchesVerified: service scope is enforced fail-closed', () => { + it('no service scope header behaves exactly as before (Bearer verified tenant)', () => { + const same = assertTenantMatchesVerified( + guardRequest({ 'x-0pt-verified-tenant': 't_a' }), + 't_a', + ); + expect(same).toBeNull(); + const cross = assertTenantMatchesVerified( + guardRequest({ 'x-0pt-verified-tenant': 't_a' }), + 't_victim', + ); + expect(cross?.status).toBe(403); + }); + + it('allows a tenant inside the configured service scope', () => { + const res = assertTenantMatchesVerified( + guardRequest({ [SERVICE_TENANT_SCOPE_HEADER]: 't_a,t_b' }), + 't_b', + ); + expect(res).toBeNull(); + }); + + it('DENIES a tenant outside the configured service scope (403)', async () => { + const res = assertTenantMatchesVerified( + guardRequest({ [SERVICE_TENANT_SCOPE_HEADER]: 't_a' }), + 't_victim', + ); + expect(res?.status).toBe(403); + const body = await res!.json(); + expect(body.error).toBe('OPT_ACCESS_DENIED'); + expect(body.details.service_tenant_scope).toEqual(['t_a']); + }); + + it('DENIES every tenant when the configured scope is empty (fail closed, even if a verified tenant was also set)', () => { + const res = assertTenantMatchesVerified( + guardRequest({ + [SERVICE_TENANT_SCOPE_HEADER]: '', + 'x-0pt-verified-tenant': '999999999999999999', + }), + '999999999999999999', + ); + expect(res?.status).toBe(403); + }); +}); + +describe('route handlers: internal service cannot write a foreign tenant', () => { + it('canonical facts: cross-tenant write is 403 before the service runs', async () => { + const res = await canonicalPOST( + new NextRequest(new URL(SCOPED_ROUTE, 'http://localhost'), { + method: 'POST', + headers: { 'content-type': 'application/json', [SERVICE_TENANT_SCOPE_HEADER]: 't_a' }, + body: JSON.stringify({ + tenant_id: 't_victim', + entities: [], + facts: [], + relations: [], + }), + }), + ); + expect(res.status).toBe(403); + }); + + it('quality bridge-run: cross-tenant run is 403 before rules are provisioned', async () => { + const res = await bridgePOST( + new NextRequest(new URL('/api/0pt/quality/bridge-run', 'http://localhost'), { + method: 'POST', + headers: { 'content-type': 'application/json', [SERVICE_TENANT_SCOPE_HEADER]: 't_a' }, + body: JSON.stringify({ + tenant_id: 't_victim', + project_id: 'p1', + dataset_id: 'ds_1', + rows: [], + }), + }), + ); + expect(res.status).toBe(403); + }); + + it('evidence-outbox dispatch needs no tenant and is unchanged by the scope', async () => { + // No DB in this test env: the route authenticates first, then reports a + // dependency error -- anything except 401/403 proves the scope did not + // turn a tenant-less machine route into a denial. + const previous = process.env.INTERNAL_TOKEN; + process.env.INTERNAL_TOKEN = INTERNAL_TOKEN; + try { + const res = await evidenceDispatchPOST( + new NextRequest(new URL('/api/0pt/internal/evidence-outbox/dispatch', 'http://localhost'), { + method: 'POST', + headers: { + 'content-type': 'application/json', + 'x-internal-token': INTERNAL_TOKEN, + [SERVICE_TENANT_SCOPE_HEADER]: '', + }, + body: JSON.stringify({ limit: 1 }), + }), + ); + expect([401, 403]).not.toContain(res.status); + } finally { + if (previous === undefined) delete process.env.INTERNAL_TOKEN; + else process.env.INTERNAL_TOKEN = previous; + } + }); +}); -- 2.54.0 From 1a581fde2938bb1442d41a3583ca45d87ee69853 Mon Sep 17 00:00:00 2001 From: SIAX Agent Date: Sat, 7 Nov 2026 16:39:48 +0100 Subject: [PATCH 3/3] =?UTF-8?q?fix(auth):=20en=20(1)=20tenant=20per=20inte?= =?UTF-8?q?rn=20serviceprincipal=20=E2=80=94=20multi-tenant=20nekas?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- .env.example | 5 ++++- middleware.ts | 20 +++++++++++++++++++ .../auth/internal-token-tenant-scope.test.ts | 14 +++++++++++-- 3 files changed, 36 insertions(+), 3 deletions(-) diff --git a/.env.example b/.env.example index 7d56418..d5c2bb9 100644 --- a/.env.example +++ b/.env.example @@ -63,7 +63,10 @@ INTERNAL_TOKEN=change-me-internal-token # "379976258701823235". This is the ONLY source of tenant authority for # X-Internal-Token: a request header may never name a tenant. Empty/unset means # the service principal has no tenant authority and tenant-scoped writes -# (canonical facts, quality bridge runs) are denied with 403. +# (canonical facts, quality bridge runs) are denied with 403. Exactly ONE +# tenant is supported: the shared token identifies one service process, not a +# set of principals. A multi-tenant list is refused (401) until a verified +# workload-identity binding exists. INTERNAL_SERVICE_TENANTS= # --------------------------------------------------------------------------- diff --git a/middleware.ts b/middleware.ts index ff7f251..1d438ae 100644 --- a/middleware.ts +++ b/middleware.ts @@ -71,6 +71,26 @@ export async function middleware(req: NextRequest): Promise { // 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]); diff --git a/tests/auth/internal-token-tenant-scope.test.ts b/tests/auth/internal-token-tenant-scope.test.ts index 674bea8..457ea84 100644 --- a/tests/auth/internal-token-tenant-scope.test.ts +++ b/tests/auth/internal-token-tenant-scope.test.ts @@ -95,11 +95,21 @@ describe('middleware: internal-token tenant authority comes from server config', expect(forwarded(res, SERVICE_TENANT_SCOPE_HEADER)).toBe('t_a'); }); - it('multiple allowlisted tenants are carried as a scope, not as one verified tenant', async () => { + it('REFUSES an ambiguous multi-tenant allowlist (no workload-identity binding yet)', async () => { process.env.INTERNAL_SERVICE_TENANTS = 't_a,t_b'; const res = await middleware(req(SCOPED_ROUTE, { 'x-internal-token': INTERNAL_TOKEN })); + expect(res.status).toBe(401); + const body = await res.json(); + expect(body.message).toContain('ambiguous'); expect(forwarded(res, 'x-0pt-verified-tenant')).toBeNull(); - expect(forwarded(res, SERVICE_TENANT_SCOPE_HEADER)).toBe('t_a,t_b'); + expect(forwarded(res, SERVICE_TENANT_SCOPE_HEADER)).toBeNull(); + }); + + it('a single allowlist entry is still forwarded as the service scope', async () => { + process.env.INTERNAL_SERVICE_TENANTS = 't_a'; + const res = await middleware(req(SCOPED_ROUTE, { 'x-internal-token': INTERNAL_TOKEN })); + expect(res.status).toBe(200); + expect(forwarded(res, SERVICE_TENANT_SCOPE_HEADER)).toBe('t_a'); }); it('with no configured scope the service principal has no tenant authority (empty scope header)', async () => { -- 2.54.0