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 () => {