From 023f1ce089eefcc8dcbc4c74bf3c7f5755c92987 Mon Sep 17 00:00:00 2001 From: Kyzgor Date: Thu, 25 Jun 2026 15:29:36 +0000 Subject: [PATCH 1/3] fix: add missing extends field to Role and ResourceRole types The Permit API and OpenAPI spec define extends (Array) on the role schemas, but it is missing from the generated TypeScript types, so consumers cannot type-safely set or read role inheritance. Add extends?: Array to RoleCreate/Read/Update and ResourceRoleCreate/Read/Update, matching the generator's emitted style and field position so a future regeneration is a no-op. Add a unit test pinning the field on all six types; it fails to compile if any addition is reverted. The canonical fix (regenerate the client) is currently blocked: the pinned generator 6.2.1 cannot read the now-OpenAPI-3.1 spec and regenerates an all-any client (see #130). --- src/openapi/types/resource-role-create.ts | 6 ++++ src/openapi/types/resource-role-read.ts | 6 ++++ src/openapi/types/resource-role-update.ts | 6 ++++ src/openapi/types/role-create.ts | 6 ++++ src/openapi/types/role-read.ts | 6 ++++ src/openapi/types/role-update.ts | 6 ++++ src/tests/unit/role-extends.spec.ts | 36 +++++++++++++++++++++++ 7 files changed, 72 insertions(+) create mode 100644 src/tests/unit/role-extends.spec.ts diff --git a/src/openapi/types/resource-role-create.ts b/src/openapi/types/resource-role-create.ts index a66bbad3..1257d6a6 100644 --- a/src/openapi/types/resource-role-create.ts +++ b/src/openapi/types/resource-role-create.ts @@ -52,6 +52,12 @@ export interface ResourceRoleCreate { * @memberof ResourceRoleCreate */ attributes?: object; + /** + * list of role keys that define what roles this role extends. In other words: this role will automatically inherit all the permissions of the given roles in this list. + * @type {Array} + * @memberof ResourceRoleCreate + */ + extends?: Array; /** * * @type {GrantedTo1} diff --git a/src/openapi/types/resource-role-read.ts b/src/openapi/types/resource-role-read.ts index f175f30d..506459ec 100644 --- a/src/openapi/types/resource-role-read.ts +++ b/src/openapi/types/resource-role-read.ts @@ -46,6 +46,12 @@ export interface ResourceRoleRead { * @memberof ResourceRoleRead */ attributes?: object; + /** + * list of role keys that define what roles this role extends. In other words: this role will automatically inherit all the permissions of the given roles in this list. + * @type {Array} + * @memberof ResourceRoleRead + */ + extends?: Array; /** * * @type {GrantedTo2} diff --git a/src/openapi/types/resource-role-update.ts b/src/openapi/types/resource-role-update.ts index 421c7aa7..230be018 100644 --- a/src/openapi/types/resource-role-update.ts +++ b/src/openapi/types/resource-role-update.ts @@ -46,6 +46,12 @@ export interface ResourceRoleUpdate { * @memberof ResourceRoleUpdate */ attributes?: object; + /** + * list of role keys that define what roles this role extends. In other words: this role will automatically inherit all the permissions of the given roles in this list. + * @type {Array} + * @memberof ResourceRoleUpdate + */ + extends?: Array; /** * * @type {GrantedTo1} diff --git a/src/openapi/types/role-create.ts b/src/openapi/types/role-create.ts index 2e5fbc7d..0f9bbca8 100644 --- a/src/openapi/types/role-create.ts +++ b/src/openapi/types/role-create.ts @@ -52,6 +52,12 @@ export interface RoleCreate { * @memberof RoleCreate */ attributes?: object; + /** + * list of role keys that define what roles this role extends. In other words: this role will automatically inherit all the permissions of the given roles in this list. + * @type {Array} + * @memberof RoleCreate + */ + extends?: Array; /** * * @type {GrantedTo1} diff --git a/src/openapi/types/role-read.ts b/src/openapi/types/role-read.ts index dd47e7ce..26863dfa 100644 --- a/src/openapi/types/role-read.ts +++ b/src/openapi/types/role-read.ts @@ -46,6 +46,12 @@ export interface RoleRead { * @memberof RoleRead */ attributes?: object; + /** + * list of role keys that define what roles this role extends. In other words: this role will automatically inherit all the permissions of the given roles in this list. + * @type {Array} + * @memberof RoleRead + */ + extends?: Array; /** * * @type {GrantedTo} diff --git a/src/openapi/types/role-update.ts b/src/openapi/types/role-update.ts index eea11de1..23ca51da 100644 --- a/src/openapi/types/role-update.ts +++ b/src/openapi/types/role-update.ts @@ -46,6 +46,12 @@ export interface RoleUpdate { * @memberof RoleUpdate */ attributes?: object; + /** + * list of role keys that define what roles this role extends. In other words: this role will automatically inherit all the permissions of the given roles in this list. + * @type {Array} + * @memberof RoleUpdate + */ + extends?: Array; /** * * @type {GrantedTo1} diff --git a/src/tests/unit/role-extends.spec.ts b/src/tests/unit/role-extends.spec.ts new file mode 100644 index 00000000..7faabacc --- /dev/null +++ b/src/tests/unit/role-extends.spec.ts @@ -0,0 +1,36 @@ +import test from 'ava'; + +import { + ResourceRoleCreate, + ResourceRoleRead, + ResourceRoleUpdate, + RoleCreate, + RoleRead, + RoleUpdate, +} from '../../openapi/types'; + +// Compile-time guard for the `extends` role-inheritance field on the six generated role types. +// Removing `extends?: Array` from any of them makes this file fail to compile under +// `yarn build` (build:types -> tsc): TS2322 on the create/update object literals (excess property) +// and TS2339 on the read member access. The runtime assertions below exist only so the test:unit +// ava glob exercises the file; the substantive check is that it type-checks. +const roleCreate: RoleCreate = { key: 'editor', name: 'Editor', extends: ['viewer'] }; +const roleUpdate: RoleUpdate = { extends: ['viewer'] }; +const roleReadExtends: Array | undefined = ({} as RoleRead).extends; + +const resourceRoleCreate: ResourceRoleCreate = { + key: 'editor', + name: 'Editor', + extends: ['viewer'], +}; +const resourceRoleUpdate: ResourceRoleUpdate = { extends: ['viewer'] }; +const resourceRoleReadExtends: Array | undefined = ({} as ResourceRoleRead).extends; + +test('Role and ResourceRole types expose the `extends` inheritance field', (t) => { + t.deepEqual(roleCreate.extends, ['viewer']); + t.deepEqual(roleUpdate.extends, ['viewer']); + t.is(roleReadExtends, undefined); + t.deepEqual(resourceRoleCreate.extends, ['viewer']); + t.deepEqual(resourceRoleUpdate.extends, ['viewer']); + t.is(resourceRoleReadExtends, undefined); +}); From 182cfba37418db8e98f01352a22a12e7e0b7e751 Mon Sep 17 00:00:00 2001 From: Zeev Manilovich Date: Tue, 29 Sep 2026 22:36:31 +0300 Subject: [PATCH 2/3] test: verify role inheritance contracts and API behavior Co-Authored-By: Codex --- src/tests/unit/role-extends.spec.ts | 174 ++++++++++++++++++++++------ 1 file changed, 140 insertions(+), 34 deletions(-) diff --git a/src/tests/unit/role-extends.spec.ts b/src/tests/unit/role-extends.spec.ts index 7faabacc..ada216d4 100644 --- a/src/tests/unit/role-extends.spec.ts +++ b/src/tests/unit/role-extends.spec.ts @@ -1,36 +1,142 @@ import test from 'ava'; -import { - ResourceRoleCreate, - ResourceRoleRead, - ResourceRoleUpdate, - RoleCreate, - RoleRead, - RoleUpdate, -} from '../../openapi/types'; - -// Compile-time guard for the `extends` role-inheritance field on the six generated role types. -// Removing `extends?: Array` from any of them makes this file fail to compile under -// `yarn build` (build:types -> tsc): TS2322 on the create/update object literals (excess property) -// and TS2339 on the read member access. The runtime assertions below exist only so the test:unit -// ava glob exercises the file; the substantive check is that it type-checks. -const roleCreate: RoleCreate = { key: 'editor', name: 'Editor', extends: ['viewer'] }; -const roleUpdate: RoleUpdate = { extends: ['viewer'] }; -const roleReadExtends: Array | undefined = ({} as RoleRead).extends; - -const resourceRoleCreate: ResourceRoleCreate = { - key: 'editor', - name: 'Editor', - extends: ['viewer'], -}; -const resourceRoleUpdate: ResourceRoleUpdate = { extends: ['viewer'] }; -const resourceRoleReadExtends: Array | undefined = ({} as ResourceRoleRead).extends; - -test('Role and ResourceRole types expose the `extends` inheritance field', (t) => { - t.deepEqual(roleCreate.extends, ['viewer']); - t.deepEqual(roleUpdate.extends, ['viewer']); - t.is(roleReadExtends, undefined); - t.deepEqual(resourceRoleCreate.extends, ['viewer']); - t.deepEqual(resourceRoleUpdate.extends, ['viewer']); - t.is(resourceRoleReadExtends, undefined); -}); +import { Permit } from '../../index'; + +type RoleCreate = Parameters[0]; +type RoleRead = Awaited>; +type RoleUpdate = Parameters[1]; +type ResourceRoleCreate = Parameters[1]; +type ResourceRoleRead = Awaited>; +type ResourceRoleUpdate = Parameters[2]; + +type IsExact = (() => T extends A ? 1 : 2) extends () => T extends B ? 1 : 2 + ? true + : false; +type Assert = T; +type Inheritance = { extends?: Array }; + +// yarn test:unit runs build:types before AVA. Pick preserves the optional modifier, and +// exact equality rejects any, null, non-array values, and a required property (even with undefined). +export type RoleInheritanceContracts = [ + Assert, Inheritance>>, + Assert, Inheritance>>, + Assert, Inheritance>>, + Assert, Inheritance>>, + Assert, Inheritance>>, + Assert, Inheritance>>, +]; + +function createClient(inheritance: Inheritance) { + const permit = new Permit({ + token: 'test', + apiUrl: 'https://api.permit.io', + log: { level: 'silent' }, + }); + const requests: Array<{ method: string | undefined; url: string | undefined; body: unknown }> = + []; + const role: ResourceRoleRead = { + key: 'editor', + name: 'Editor', + id: 'role-id', + organization_id: 'org', + project_id: 'proj', + environment_id: 'env', + resource_id: 'resource-id', + resource: 'doc', + created_at: '2026-01-01T00:00:00Z', + updated_at: '2026-01-01T00:00:00Z', + ...inheritance, + }; + permit.config.axiosInstance.defaults.adapter = async (config) => { + const isScopeRequest = config.url === 'https://api.permit.io/v2/api-key/scope'; + if (!isScopeRequest) { + requests.push({ + method: config.method, + url: config.url, + body: config.data === undefined ? undefined : JSON.parse(config.data), + }); + } + return { + status: 200, + statusText: 'OK', + headers: {}, + config, + data: isScopeRequest + ? { organization_id: 'org', project_id: 'proj', environment_id: 'env' } + : { ...role }, + }; + }; + return { permit, requests }; +} + +const cases: Array<{ name: string; inheritance: Inheritance }> = [ + { name: 'multiple inherited roles', inheritance: { extends: ['viewer', 'auditor'] } }, + { name: 'empty inheritance', inheritance: { extends: [] } }, + { name: 'omitted inheritance', inheritance: {} }, +]; + +for (const { name, inheritance } of cases) { + test(`roles create/update/get preserve ${name}`, async (t) => { + const { permit, requests } = createClient(inheritance); + const created = await permit.api.roles.create({ + key: 'editor', + name: 'Editor', + ...inheritance, + }); + const updated = await permit.api.roles.update('editor', { ...inheritance }); + const fetched = await permit.api.roles.get('editor'); + + t.deepEqual(created.extends, inheritance.extends); + t.deepEqual(updated.extends, inheritance.extends); + t.deepEqual(fetched.extends, inheritance.extends); + t.deepEqual(requests, [ + { + method: 'post', + url: 'https://api.permit.io/v2/schema/proj/env/roles', + body: { key: 'editor', name: 'Editor', ...inheritance }, + }, + { + method: 'patch', + url: 'https://api.permit.io/v2/schema/proj/env/roles/editor', + body: { ...inheritance }, + }, + { + method: 'get', + url: 'https://api.permit.io/v2/schema/proj/env/roles/editor', + body: undefined, + }, + ]); + }); + + test(`resource roles create/update/get preserve ${name}`, async (t) => { + const { permit, requests } = createClient(inheritance); + const created = await permit.api.resourceRoles.create('doc', { + key: 'editor', + name: 'Editor', + ...inheritance, + }); + const updated = await permit.api.resourceRoles.update('doc', 'editor', { ...inheritance }); + const fetched = await permit.api.resourceRoles.get('doc', 'editor'); + + t.deepEqual(created.extends, inheritance.extends); + t.deepEqual(updated.extends, inheritance.extends); + t.deepEqual(fetched.extends, inheritance.extends); + t.deepEqual(requests, [ + { + method: 'post', + url: 'https://api.permit.io/v2/schema/proj/env/resources/doc/roles', + body: { key: 'editor', name: 'Editor', ...inheritance }, + }, + { + method: 'patch', + url: 'https://api.permit.io/v2/schema/proj/env/resources/doc/roles/editor', + body: { ...inheritance }, + }, + { + method: 'get', + url: 'https://api.permit.io/v2/schema/proj/env/resources/doc/roles/editor', + body: undefined, + }, + ]); + }); +} From 3134f8230940867c2fe2ae1103a7f5bd4ba08da1 Mon Sep 17 00:00:00 2001 From: Zeev Manilovich Date: Tue, 29 Sep 2026 22:59:16 +0300 Subject: [PATCH 3/3] test: wrap role-extends comment to the 100-char print width Co-Authored-By: Claude Opus 5.5 --- src/tests/unit/role-extends.spec.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/tests/unit/role-extends.spec.ts b/src/tests/unit/role-extends.spec.ts index ada216d4..b4d13ea5 100644 --- a/src/tests/unit/role-extends.spec.ts +++ b/src/tests/unit/role-extends.spec.ts @@ -15,8 +15,8 @@ type IsExact = (() => T extends A ? 1 : 2) extends () => T extends B type Assert = T; type Inheritance = { extends?: Array }; -// yarn test:unit runs build:types before AVA. Pick preserves the optional modifier, and -// exact equality rejects any, null, non-array values, and a required property (even with undefined). +// yarn test:unit runs build:types before AVA. Pick preserves the optional modifier, and exact +// equality rejects any, null, non-array values, and a required property (even with undefined). export type RoleInheritanceContracts = [ Assert, Inheritance>>, Assert, Inheritance>>,