fix(auth): en (1) tenant per intern serviceprincipal — multi-tenant nekas
CI (SIAX Cloud) / sonar (pull_request) Skipped
masterplan-lock / check (push) Successful in 4s
masterplan-lock / check (pull_request) Successful in 8s
CI (SIAX Cloud) / security (pull_request) Successful in 35s
Security Scan (SIAX Cloud) / security (pull_request) Successful in 44s
CI (SIAX Cloud) / integration (pull_request) Successful in 9m48s
CI (SIAX Cloud) / quality (pull_request) Successful in 10m14s
CI (SIAX Cloud) / sonar (pull_request) Skipped
masterplan-lock / check (push) Successful in 4s
masterplan-lock / check (pull_request) Successful in 8s
CI (SIAX Cloud) / security (pull_request) Successful in 35s
Security Scan (SIAX Cloud) / security (pull_request) Successful in 44s
CI (SIAX Cloud) / integration (pull_request) Successful in 9m48s
CI (SIAX Cloud) / quality (pull_request) Successful in 10m14s
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.
This commit is contained in:
+4
-1
@@ -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=
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
@@ -71,6 +71,26 @@ export async function middleware(req: NextRequest): Promise<NextResponse> {
|
||||
// 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]);
|
||||
|
||||
@@ -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 () => {
|
||||
|
||||
Reference in New Issue
Block a user