From ebd6ec052e4e6efcecb7232d59504dd9309d4c24 Mon Sep 17 00:00:00 2001 From: Zeev Manilovich Date: Sun, 28 Jun 2026 23:24:27 +0300 Subject: [PATCH] fix(enforcer): send checkAllTenants payload and auth header correctly MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit checkAllTenants passed { headers, params } as the axios POST body (2nd arg), so the Authorization header was never sent and the query was nested under `params` instead of being the request body — the PDP could neither authenticate nor read the request. Mirror check(): send the normalized { user, action, resource, context } as the body and pass headers/timeout as the axios config arg. Normalize the string forms of user/resource but skip default-tenant injection, since an all-tenants query must not be pinned to a tenant. Add an AVA regression test asserting the auth header is sent, the body shape is correct, and no tenant is injected. Fixes PER-15318 Co-Authored-By: Claude Opus 4.8 (1M context) --- src/enforcement/enforcer.ts | 20 ++-- src/tests/unit/check-all-tenants.spec.ts | 115 +++++++++++++++++++++++ 2 files changed, 128 insertions(+), 7 deletions(-) create mode 100644 src/tests/unit/check-all-tenants.spec.ts diff --git a/src/enforcement/enforcer.ts b/src/enforcement/enforcer.ts index f5218da..2b0b627 100644 --- a/src/enforcement/enforcer.ts +++ b/src/enforcement/enforcer.ts @@ -362,18 +362,24 @@ export class Enforcer implements IEnforcer { context: Context = {}, // default to empty context if not provided sdk = 'node', // default to "node" if not provided ): Promise { + // checkAllTenants evaluates the request across ALL tenants, so the resource + // must NOT be pinned to a tenant. We normalize the string forms of user and + // resource to match check()'s input contract, but intentionally skip + // normalizeResource() because it injects config.multiTenancy.defaultTenant. + const input: ICheckInput = { + user: isString(user) ? { key: user } : user, + action, + resource: isString(resource) ? Enforcer.resourceFromString(resource) : resource, + context, + }; + try { - const response = await this.client.post('/allowed/all-tenants', { + const response = await this.client.post('allowed/all-tenants', input, { headers: { Authorization: `Bearer ${this.config.token}`, 'X-Permit-Sdk-Language': sdk, }, - params: { - user, - action, - resource, - context, - }, + timeout: this.config.timeout, }); return response.data.allowedTenants.map((item) => item.tenant); } catch (error) { diff --git a/src/tests/unit/check-all-tenants.spec.ts b/src/tests/unit/check-all-tenants.spec.ts new file mode 100644 index 0000000..a7e7277 --- /dev/null +++ b/src/tests/unit/check-all-tenants.spec.ts @@ -0,0 +1,115 @@ +import test from 'ava'; +import { AxiosInstance, InternalAxiosRequestConfig } from 'axios'; + +import { IResource, IUser, TenantDetails } from '../../enforcement/interfaces'; + +// Reaches the private PDP (enforcer.client) axios instance so we can swap its +// adapter and capture the outgoing request the SDK actually builds. +interface PermitInternals { + enforcer: { client: AxiosInstance }; + checkAllTenants: ( + user: IUser | string, + action: string, + resource: IResource | string, + ) => Promise; +} + +interface CapturedBody { + user: IUser; + action: string; + resource: IResource; + context: Record; + // The old bug leaked these top-level keys into the request body. + headers?: unknown; + params?: unknown; +} + +// Installs an adapter that records the request config and returns a canned +// all-tenants response, so the call resolves without touching the network. +function installCapturingAdapter( + instance: AxiosInstance, + tenants: Array<{ key: string }>, +): { config: () => InternalAxiosRequestConfig } { + let captured: InternalAxiosRequestConfig; + instance.defaults.adapter = (config: InternalAxiosRequestConfig) => { + captured = config; + return Promise.resolve({ + data: { allowedTenants: tenants.map((tenant) => ({ tenant })) }, + status: 200, + statusText: 'OK', + headers: {}, + config, + }); + }; + return { config: () => captured }; +} + +async function newPermit(): Promise { + const { Permit } = await import('../../index'); + return new Permit({ + token: 'test-token', + pdp: 'http://localhost:7766', + }) as unknown as PermitInternals; +} + +function parseBody(config: InternalAxiosRequestConfig): CapturedBody { + return typeof config.data === 'string' ? JSON.parse(config.data) : config.data; +} + +test('checkAllTenants sends an authenticated, well-formed request (string forms)', async (t) => { + const permit = await newPermit(); + const adapter = installCapturingAdapter(permit.enforcer.client, [{ key: 't1' }, { key: 't2' }]); + + const tenants = await permit.checkAllTenants('elon', 'read', 'document:doc-1'); + + const config = adapter.config(); + + // Regression guard: the Authorization header is now actually sent as a header, + // not buried in the request body as it was before the fix. + t.is(config.headers.Authorization, 'Bearer test-token'); + t.is(config.headers['X-Permit-Sdk-Language'], 'node'); + + const body = parseBody(config); + t.deepEqual(body, { + user: { key: 'elon' }, + action: 'read', + resource: { type: 'document', key: 'doc-1' }, + context: {}, + }); + + // The old bug placed `headers` and `params` in the body. + t.is(body.headers, undefined); + t.is(body.params, undefined); + + // All-tenants must never pin a default tenant on the resource. + t.is(body.resource.tenant, undefined); + + t.deepEqual(tenants, [{ key: 't1' }, { key: 't2' }] as unknown as TenantDetails[]); +}); + +test('checkAllTenants accepts object forms for user and resource', async (t) => { + const permit = await newPermit(); + const adapter = installCapturingAdapter(permit.enforcer.client, [{ key: 't1' }]); + + const tenants = await permit.checkAllTenants({ key: 'elon' }, 'read', { + type: 'document', + key: 'doc-1', + }); + + const config = adapter.config(); + t.is(config.headers.Authorization, 'Bearer test-token'); + t.is(config.headers['X-Permit-Sdk-Language'], 'node'); + + const body = parseBody(config); + t.deepEqual(body, { + user: { key: 'elon' }, + action: 'read', + resource: { type: 'document', key: 'doc-1' }, + context: {}, + }); + t.is(body.headers, undefined); + t.is(body.params, undefined); + t.is(body.resource.tenant, undefined); + + t.deepEqual(tenants, [{ key: 't1' }] as unknown as TenantDetails[]); +});