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..b4d13ea5 --- /dev/null +++ b/src/tests/unit/role-extends.spec.ts @@ -0,0 +1,142 @@ +import test from 'ava'; + +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, + }, + ]); + }); +}