fix(auth): bind internal-service tenant authority to server config
CI (SIAX Cloud) / sonar (pull_request) Skipped
masterplan-lock / check (push) Successful in 4s
masterplan-lock / check (pull_request) Successful in 4s
CI (SIAX Cloud) / security (pull_request) Successful in 16s
Security Scan (SIAX Cloud) / security (pull_request) Successful in 16s
CI (SIAX Cloud) / integration (pull_request) Successful in 36s
CI (SIAX Cloud) / quality (pull_request) Successful in 10m24s
CI (SIAX Cloud) / sonar (pull_request) Skipped
masterplan-lock / check (push) Successful in 4s
masterplan-lock / check (pull_request) Successful in 4s
CI (SIAX Cloud) / security (pull_request) Successful in 16s
Security Scan (SIAX Cloud) / security (pull_request) Successful in 16s
CI (SIAX Cloud) / integration (pull_request) Successful in 36s
CI (SIAX Cloud) / quality (pull_request) Successful in 10m24s
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.
This commit is contained in:
@@ -58,6 +58,13 @@ DEV_LOGIN_KEY=
|
|||||||
# Shared token for internal/service-to-service calls; sent as X-Internal-Token
|
# Shared token for internal/service-to-service calls; sent as X-Internal-Token
|
||||||
# on every engineFetch() call (lib/engine.ts).
|
# on every engineFetch() call (lib/engine.ts).
|
||||||
INTERNAL_TOKEN=change-me-internal-token
|
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
|
# Zitadel / OIDC Bearer verification (Wave 2 hardening) -- wired in code today
|
||||||
|
|||||||
@@ -26,6 +26,7 @@ import { parseBody, qualityBridgeRunSchema } from "@/src/validation/0pt-schemas"
|
|||||||
import { qualityService } from "@/src/services/quality";
|
import { qualityService } from "@/src/services/quality";
|
||||||
import { ensureBaselineRules } from "@/src/quality/quality-bridge";
|
import { ensureBaselineRules } from "@/src/quality/quality-bridge";
|
||||||
import { siaxErrorResponse } from "@/src/http/siax-error-response";
|
import { siaxErrorResponse } from "@/src/http/siax-error-response";
|
||||||
|
import { assertTenantMatchesVerified } from "@/src/http/tenant-guard";
|
||||||
|
|
||||||
export async function POST(request: NextRequest) {
|
export async function POST(request: NextRequest) {
|
||||||
try {
|
try {
|
||||||
@@ -35,6 +36,11 @@ export async function POST(request: NextRequest) {
|
|||||||
return NextResponse.json(__parsed.body, { status: __parsed.status });
|
return NextResponse.json(__parsed.body, { status: __parsed.status });
|
||||||
}
|
}
|
||||||
const body = __parsed.data;
|
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, {
|
await ensureBaselineRules(qualityService, {
|
||||||
tenant_id: body.tenant_id,
|
tenant_id: body.tenant_id,
|
||||||
|
|||||||
+41
-18
@@ -10,6 +10,10 @@ import { NextResponse, type NextRequest } from 'next/server';
|
|||||||
import { assertAuthConfigSafe, resolveAuthContext } from '@/src/auth/context';
|
import { assertAuthConfigSafe, resolveAuthContext } from '@/src/auth/context';
|
||||||
import { SESSION_COOKIE, verifySession } from '@/lib/auth';
|
import { SESSION_COOKIE, verifySession } from '@/lib/auth';
|
||||||
import { verifyInternalServiceToken } from '@/src/auth/internal-token';
|
import { verifyInternalServiceToken } from '@/src/auth/internal-token';
|
||||||
|
import {
|
||||||
|
SERVICE_TENANT_SCOPE_HEADER,
|
||||||
|
resolveInternalServiceTenants,
|
||||||
|
} from '@/src/auth/internal-service-tenant';
|
||||||
|
|
||||||
assertAuthConfigSafe();
|
assertAuthConfigSafe();
|
||||||
|
|
||||||
@@ -35,28 +39,45 @@ const INTERNAL_SERVICE_ROUTES: RegExp[] = [
|
|||||||
];
|
];
|
||||||
|
|
||||||
let _authDiagCount = 0;
|
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<NextResponse> {
|
export async function middleware(req: NextRequest): Promise<NextResponse> {
|
||||||
const path = req.nextUrl.pathname;
|
const path = req.nextUrl.pathname;
|
||||||
if (EXEMPT_PATTERNS.some((re) => re.test(path))) return NextResponse.next();
|
if (EXEMPT_PATTERNS.some((re) => re.test(path))) return NextResponse.next();
|
||||||
|
|
||||||
if (INTERNAL_SERVICE_ROUTES.some((re) => re.test(path)) && verifyInternalServiceToken(req.headers)) {
|
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
|
// P0 tenant isolation (2026-11-07): X-Internal-Token proves the service —
|
||||||
// return next() WITHOUT a verified tenant, which made
|
// not a tenant. Tenant authority is SERVER CONFIG (INTERNAL_SERVICE_TENANTS),
|
||||||
// assertTenantMatchesVerified a no-op and let a service principal write
|
// never a client header: an inbound `X-0pt-Internal-Tenant` is ignored and
|
||||||
// ANY tenant_id (proven: POST canonical/facts with tenant_id
|
// stripped. With no configured tenants the principal has no tenant
|
||||||
// 999999999999999999 returned 201). When INTERNAL_TENANT_ID is configured
|
// authority and tenant-scoped writes fail closed with 403.
|
||||||
// the guard is now enforced exactly like a verified Bearer principal.
|
const forwarded = stripVerifiedHeaders(req);
|
||||||
const forwarded = new Headers(req.headers);
|
const tenants = resolveInternalServiceTenants();
|
||||||
const internalTenant =
|
forwarded.set(SERVICE_TENANT_SCOPE_HEADER, tenants.join(','));
|
||||||
req.headers.get('x-0pt-internal-tenant') ?? process.env.INTERNAL_TENANT_ID;
|
if (tenants.length === 1) {
|
||||||
if (internalTenant) {
|
forwarded.set('x-0pt-verified-tenant', tenants[0]);
|
||||||
forwarded.set('x-0pt-verified-tenant', internalTenant);
|
} else if (tenants.length === 0 && !internalScopeWarned) {
|
||||||
} else if (!internalTenantWarned) {
|
internalScopeWarned = true;
|
||||||
internalTenantWarned = true;
|
|
||||||
console.warn(
|
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 } });
|
return NextResponse.next({ request: { headers: forwarded } });
|
||||||
@@ -83,7 +104,7 @@ export async function middleware(req: NextRequest): Promise<NextResponse> {
|
|||||||
typeof session.exp === 'number' &&
|
typeof session.exp === 'number' &&
|
||||||
session.exp > Date.now()
|
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-sub', session.sub);
|
||||||
forwarded.set('x-0pt-verified-tenant', session.tenantId);
|
forwarded.set('x-0pt-verified-tenant', session.tenantId);
|
||||||
return NextResponse.next({ request: { headers: forwarded } });
|
return NextResponse.next({ request: { headers: forwarded } });
|
||||||
@@ -104,11 +125,13 @@ export async function middleware(req: NextRequest): Promise<NextResponse> {
|
|||||||
|
|
||||||
const principal = decision.ctx.principal;
|
const principal = decision.ctx.principal;
|
||||||
if (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-sub', principal.sub);
|
||||||
forwarded.set('x-0pt-verified-tenant', principal.tenantId);
|
forwarded.set('x-0pt-verified-tenant', principal.tenantId);
|
||||||
return NextResponse.next({ request: { headers: forwarded } });
|
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) } });
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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));
|
||||||
|
}
|
||||||
@@ -29,6 +29,7 @@
|
|||||||
* authz hole without changing every route's request contract).
|
* authz hole without changing every route's request contract).
|
||||||
*/
|
*/
|
||||||
import { NextResponse, type NextRequest } from "next/server";
|
import { NextResponse, type NextRequest } from "next/server";
|
||||||
|
import { readServiceTenantScope } from "@/src/auth/internal-service-tenant";
|
||||||
|
|
||||||
export const VERIFIED_TENANT_HEADER = "x-0pt-verified-tenant";
|
export const VERIFIED_TENANT_HEADER = "x-0pt-verified-tenant";
|
||||||
|
|
||||||
@@ -52,6 +53,26 @@ export function assertTenantMatchesVerified(
|
|||||||
tenantId: string | null | undefined,
|
tenantId: string | null | undefined,
|
||||||
): NextResponse | null {
|
): NextResponse | null {
|
||||||
const verified = request.headers.get(VERIFIED_TENANT_HEADER);
|
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 (!verified) return null;
|
||||||
if (!tenantId) return null; // handlers already 400 on a missing tenant_id separately
|
if (!tenantId) return null; // handlers already 400 on a missing tenant_id separately
|
||||||
if (tenantId !== verified) {
|
if (tenantId !== verified) {
|
||||||
|
|||||||
@@ -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<string, string> = {}): 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<string, string>): 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<string, string | undefined> = {
|
||||||
|
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;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
Reference in New Issue
Block a user