diff --git a/src/authorization/authorization.spec.ts b/src/authorization/authorization.spec.ts new file mode 100644 index 000000000..18fea2c10 --- /dev/null +++ b/src/authorization/authorization.spec.ts @@ -0,0 +1,239 @@ +import fetch from 'jest-fetch-mock'; +import { + fetchOnce, + fetchURL, + fetchSearchParams, + fetchBody, +} from '../common/utils/test-utils'; +import { WorkOS } from '../workos'; +import environmentRoleFixture from './fixtures/environment-role.json'; +import listEnvironmentRolesFixture from './fixtures/list-environment-roles.json'; + +const workos = new WorkOS('sk_test_Sz3IQjepeSWaI4cMS4ms4sMuU'); + +describe('Authorization', () => { + beforeEach(() => fetch.resetMocks()); + + describe('createEnvironmentRole', () => { + it('creates an environment role', async () => { + fetchOnce(environmentRoleFixture, { status: 201 }); + + const role = await workos.authorization.createEnvironmentRole({ + slug: 'admin', + name: 'Admin', + description: 'Full administrative access', + }); + + expect(fetchURL()).toContain('/authorization/roles'); + expect(fetchBody()).toEqual({ + slug: 'admin', + name: 'Admin', + description: 'Full administrative access', + }); + expect(role).toMatchObject({ + object: 'role', + id: 'role_01HXYZ123ABC456DEF789GHI', + slug: 'admin', + name: 'Admin', + description: 'Full administrative access', + type: 'EnvironmentRole', + }); + expect(role.permissions).toEqual( + expect.arrayContaining([ + 'users:read', + 'users:write', + 'settings:manage', + ]), + ); + }); + + it('creates an environment role without description', async () => { + fetchOnce( + { ...environmentRoleFixture, description: null }, + { status: 201 }, + ); + + const role = await workos.authorization.createEnvironmentRole({ + slug: 'member', + name: 'Member', + }); + + expect(fetchBody()).toEqual({ + slug: 'member', + name: 'Member', + }); + expect(role.description).toBeNull(); + }); + }); + + describe('listEnvironmentRoles', () => { + it('returns environment roles', async () => { + fetchOnce(listEnvironmentRolesFixture); + + const { data, object } = + await workos.authorization.listEnvironmentRoles(); + + expect(fetchURL()).toContain('/authorization/roles'); + expect(object).toEqual('list'); + expect(data).toHaveLength(2); + expect(data).toEqual( + expect.arrayContaining([ + expect.objectContaining({ + object: 'role', + id: 'role_01HXYZ123ABC456DEF789GHI', + slug: 'admin', + name: 'Admin', + type: 'EnvironmentRole', + }), + expect.objectContaining({ + object: 'role', + id: 'role_01HXYZ123ABC456DEF789GHJ', + slug: 'member', + name: 'Member', + type: 'EnvironmentRole', + }), + ]), + ); + }); + + it('passes expand parameter', async () => { + fetchOnce(listEnvironmentRolesFixture); + + await workos.authorization.listEnvironmentRoles({ + expand: 'permissions', + }); + + expect(fetchSearchParams()).toEqual({ + expand: 'permissions', + }); + }); + }); + + describe('getEnvironmentRole', () => { + it('gets an environment role by slug', async () => { + fetchOnce(environmentRoleFixture); + + const role = await workos.authorization.getEnvironmentRole('admin'); + + expect(fetchURL()).toContain('/authorization/roles/admin'); + expect(role).toMatchObject({ + object: 'role', + id: 'role_01HXYZ123ABC456DEF789GHI', + slug: 'admin', + name: 'Admin', + description: 'Full administrative access', + type: 'EnvironmentRole', + }); + }); + }); + + describe('updateEnvironmentRole', () => { + it('updates an environment role', async () => { + const updatedRoleFixture = { + ...environmentRoleFixture, + name: 'Super Admin', + description: 'Updated description', + }; + fetchOnce(updatedRoleFixture); + + const role = await workos.authorization.updateEnvironmentRole('admin', { + name: 'Super Admin', + description: 'Updated description', + }); + + expect(fetchURL()).toContain('/authorization/roles/admin'); + expect(fetchBody()).toEqual({ + name: 'Super Admin', + description: 'Updated description', + }); + expect(role).toMatchObject({ + name: 'Super Admin', + description: 'Updated description', + }); + }); + + it('clears description when set to null', async () => { + const updatedRoleFixture = { + ...environmentRoleFixture, + description: null, + }; + fetchOnce(updatedRoleFixture); + + const role = await workos.authorization.updateEnvironmentRole('admin', { + description: null, + }); + + expect(fetchBody()).toEqual({ + description: null, + }); + expect(role.description).toBeNull(); + }); + }); + + describe('setEnvironmentRolePermissions', () => { + it('sets permissions for an environment role', async () => { + const updatedRoleFixture = { + ...environmentRoleFixture, + permissions: ['users:read', 'users:write'], + }; + fetchOnce(updatedRoleFixture); + + const role = await workos.authorization.setEnvironmentRolePermissions( + 'admin', + { permissions: ['users:read', 'users:write'] }, + ); + + expect(fetchURL()).toContain('/authorization/roles/admin/permissions'); + expect(fetchBody()).toEqual({ + permissions: ['users:read', 'users:write'], + }); + expect(role.permissions).toHaveLength(2); + expect(role.permissions).toEqual( + expect.arrayContaining(['users:read', 'users:write']), + ); + }); + + it('clears all permissions when given empty array', async () => { + const updatedRoleFixture = { ...environmentRoleFixture, permissions: [] }; + fetchOnce(updatedRoleFixture); + + const role = await workos.authorization.setEnvironmentRolePermissions( + 'admin', + { permissions: [] }, + ); + + expect(fetchBody()).toEqual({ + permissions: [], + }); + expect(role.permissions).toHaveLength(0); + }); + }); + + describe('addEnvironmentRolePermission', () => { + it('adds a permission to an environment role', async () => { + const updatedRoleFixture = { + ...environmentRoleFixture, + permissions: [ + 'users:read', + 'users:write', + 'settings:manage', + 'billing:read', + ], + }; + fetchOnce(updatedRoleFixture); + + const role = await workos.authorization.addEnvironmentRolePermission( + 'admin', + { permissionSlug: 'billing:read' }, + ); + + expect(fetchURL()).toContain('/authorization/roles/admin/permissions'); + expect(fetchBody()).toEqual({ + slug: 'billing:read', + }); + expect(role.permissions).toEqual( + expect.arrayContaining(['billing:read']), + ); + }); + }); +}); diff --git a/src/authorization/authorization.ts b/src/authorization/authorization.ts new file mode 100644 index 000000000..509a91666 --- /dev/null +++ b/src/authorization/authorization.ts @@ -0,0 +1,84 @@ +import { WorkOS } from '../workos'; +import { + EnvironmentRole, + EnvironmentRoleResponse, + EnvironmentRoleList, + EnvironmentRoleListResponse, + CreateEnvironmentRoleOptions, + UpdateEnvironmentRoleOptions, + ListEnvironmentRolesOptions, + SetEnvironmentRolePermissionsOptions, + AddEnvironmentRolePermissionOptions, +} from './interfaces'; +import { + deserializeEnvironmentRole, + serializeCreateEnvironmentRoleOptions, + serializeUpdateEnvironmentRoleOptions, +} from './serializers'; + +export class Authorization { + constructor(private readonly workos: WorkOS) {} + + async createEnvironmentRole( + options: CreateEnvironmentRoleOptions, + ): Promise { + const { data } = await this.workos.post( + '/authorization/roles', + serializeCreateEnvironmentRoleOptions(options), + ); + return deserializeEnvironmentRole(data); + } + + async listEnvironmentRoles( + options?: ListEnvironmentRolesOptions, + ): Promise { + const { data } = await this.workos.get( + '/authorization/roles', + { query: options }, + ); + return { + object: 'list', + data: data.data.map(deserializeEnvironmentRole), + }; + } + + async getEnvironmentRole(slug: string): Promise { + const { data } = await this.workos.get( + `/authorization/roles/${slug}`, + ); + return deserializeEnvironmentRole(data); + } + + async updateEnvironmentRole( + slug: string, + options: UpdateEnvironmentRoleOptions, + ): Promise { + const { data } = await this.workos.patch( + `/authorization/roles/${slug}`, + serializeUpdateEnvironmentRoleOptions(options), + ); + return deserializeEnvironmentRole(data); + } + + async setEnvironmentRolePermissions( + slug: string, + options: SetEnvironmentRolePermissionsOptions, + ): Promise { + const { data } = await this.workos.put( + `/authorization/roles/${slug}/permissions`, + { permissions: options.permissions }, + ); + return deserializeEnvironmentRole(data); + } + + async addEnvironmentRolePermission( + slug: string, + options: AddEnvironmentRolePermissionOptions, + ): Promise { + const { data } = await this.workos.post( + `/authorization/roles/${slug}/permissions`, + { slug: options.permissionSlug }, + ); + return deserializeEnvironmentRole(data); + } +} diff --git a/src/authorization/fixtures/environment-role-member.json b/src/authorization/fixtures/environment-role-member.json new file mode 100644 index 000000000..cb0324d52 --- /dev/null +++ b/src/authorization/fixtures/environment-role-member.json @@ -0,0 +1,11 @@ +{ + "object": "role", + "id": "role_01HXYZ123ABC456DEF789GHJ", + "name": "Member", + "slug": "member", + "description": null, + "permissions": ["posts:read"], + "type": "EnvironmentRole", + "created_at": "2024-01-15T10:00:00.000Z", + "updated_at": "2024-01-15T10:00:00.000Z" +} diff --git a/src/authorization/fixtures/environment-role.json b/src/authorization/fixtures/environment-role.json new file mode 100644 index 000000000..459d14853 --- /dev/null +++ b/src/authorization/fixtures/environment-role.json @@ -0,0 +1,11 @@ +{ + "object": "role", + "id": "role_01HXYZ123ABC456DEF789GHI", + "name": "Admin", + "slug": "admin", + "description": "Full administrative access", + "permissions": ["users:read", "users:write", "settings:manage"], + "type": "EnvironmentRole", + "created_at": "2024-01-15T09:30:00.000Z", + "updated_at": "2024-01-15T09:30:00.000Z" +} diff --git a/src/authorization/fixtures/list-environment-roles.json b/src/authorization/fixtures/list-environment-roles.json new file mode 100644 index 000000000..5e564fec4 --- /dev/null +++ b/src/authorization/fixtures/list-environment-roles.json @@ -0,0 +1,27 @@ +{ + "object": "list", + "data": [ + { + "object": "role", + "id": "role_01HXYZ123ABC456DEF789GHI", + "name": "Admin", + "slug": "admin", + "description": "Full administrative access", + "permissions": ["users:read", "users:write", "settings:manage"], + "type": "EnvironmentRole", + "created_at": "2024-01-15T09:30:00.000Z", + "updated_at": "2024-01-15T09:30:00.000Z" + }, + { + "object": "role", + "id": "role_01HXYZ123ABC456DEF789GHJ", + "name": "Member", + "slug": "member", + "description": null, + "permissions": ["posts:read"], + "type": "EnvironmentRole", + "created_at": "2024-01-15T10:00:00.000Z", + "updated_at": "2024-01-15T10:00:00.000Z" + } + ] +} diff --git a/src/authorization/index.ts b/src/authorization/index.ts new file mode 100644 index 000000000..d7b90839c --- /dev/null +++ b/src/authorization/index.ts @@ -0,0 +1,2 @@ +export * from './authorization'; +export * from './interfaces'; diff --git a/src/authorization/interfaces/add-environment-role-permission-options.interface.ts b/src/authorization/interfaces/add-environment-role-permission-options.interface.ts new file mode 100644 index 000000000..aa4627c39 --- /dev/null +++ b/src/authorization/interfaces/add-environment-role-permission-options.interface.ts @@ -0,0 +1,3 @@ +export interface AddEnvironmentRolePermissionOptions { + permissionSlug: string; +} diff --git a/src/authorization/interfaces/create-environment-role-options.interface.ts b/src/authorization/interfaces/create-environment-role-options.interface.ts new file mode 100644 index 000000000..a407d8153 --- /dev/null +++ b/src/authorization/interfaces/create-environment-role-options.interface.ts @@ -0,0 +1,11 @@ +export interface CreateEnvironmentRoleOptions { + slug: string; + name: string; + description?: string; +} + +export interface SerializedCreateEnvironmentRoleOptions { + slug: string; + name: string; + description?: string; +} diff --git a/src/authorization/interfaces/environment-role.interface.ts b/src/authorization/interfaces/environment-role.interface.ts new file mode 100644 index 000000000..8fbaae3f7 --- /dev/null +++ b/src/authorization/interfaces/environment-role.interface.ts @@ -0,0 +1,33 @@ +export interface EnvironmentRole { + object: 'role'; + id: string; + name: string; + slug: string; + description: string | null; + permissions: string[]; + type: 'EnvironmentRole'; + createdAt: string; + updatedAt: string; +} + +export interface EnvironmentRoleResponse { + object: 'role'; + id: string; + name: string; + slug: string; + description: string | null; + permissions: string[]; + type: 'EnvironmentRole'; + created_at: string; + updated_at: string; +} + +export interface EnvironmentRoleList { + object: 'list'; + data: EnvironmentRole[]; +} + +export interface EnvironmentRoleListResponse { + object: 'list'; + data: EnvironmentRoleResponse[]; +} diff --git a/src/authorization/interfaces/index.ts b/src/authorization/interfaces/index.ts new file mode 100644 index 000000000..9750d53bc --- /dev/null +++ b/src/authorization/interfaces/index.ts @@ -0,0 +1,6 @@ +export * from './environment-role.interface'; +export * from './create-environment-role-options.interface'; +export * from './update-environment-role-options.interface'; +export * from './list-environment-roles-options.interface'; +export * from './set-environment-role-permissions-options.interface'; +export * from './add-environment-role-permission-options.interface'; diff --git a/src/authorization/interfaces/list-environment-roles-options.interface.ts b/src/authorization/interfaces/list-environment-roles-options.interface.ts new file mode 100644 index 000000000..ea95542f5 --- /dev/null +++ b/src/authorization/interfaces/list-environment-roles-options.interface.ts @@ -0,0 +1,3 @@ +export interface ListEnvironmentRolesOptions { + expand?: 'permissions'; +} diff --git a/src/authorization/interfaces/set-environment-role-permissions-options.interface.ts b/src/authorization/interfaces/set-environment-role-permissions-options.interface.ts new file mode 100644 index 000000000..f9f33d1dc --- /dev/null +++ b/src/authorization/interfaces/set-environment-role-permissions-options.interface.ts @@ -0,0 +1,3 @@ +export interface SetEnvironmentRolePermissionsOptions { + permissions: string[]; +} diff --git a/src/authorization/interfaces/update-environment-role-options.interface.ts b/src/authorization/interfaces/update-environment-role-options.interface.ts new file mode 100644 index 000000000..471bd0967 --- /dev/null +++ b/src/authorization/interfaces/update-environment-role-options.interface.ts @@ -0,0 +1,9 @@ +export interface UpdateEnvironmentRoleOptions { + name?: string; + description?: string | null; +} + +export interface SerializedUpdateEnvironmentRoleOptions { + name?: string; + description?: string | null; +} diff --git a/src/authorization/serializers/create-environment-role-options.serializer.ts b/src/authorization/serializers/create-environment-role-options.serializer.ts new file mode 100644 index 000000000..bb3e34da4 --- /dev/null +++ b/src/authorization/serializers/create-environment-role-options.serializer.ts @@ -0,0 +1,12 @@ +import { + CreateEnvironmentRoleOptions, + SerializedCreateEnvironmentRoleOptions, +} from '../interfaces/create-environment-role-options.interface'; + +export const serializeCreateEnvironmentRoleOptions = ( + options: CreateEnvironmentRoleOptions, +): SerializedCreateEnvironmentRoleOptions => ({ + slug: options.slug, + name: options.name, + description: options.description, +}); diff --git a/src/authorization/serializers/environment-role.serializer.ts b/src/authorization/serializers/environment-role.serializer.ts new file mode 100644 index 000000000..e8678f3aa --- /dev/null +++ b/src/authorization/serializers/environment-role.serializer.ts @@ -0,0 +1,18 @@ +import { + EnvironmentRole, + EnvironmentRoleResponse, +} from '../interfaces/environment-role.interface'; + +export const deserializeEnvironmentRole = ( + role: EnvironmentRoleResponse, +): EnvironmentRole => ({ + object: role.object, + id: role.id, + name: role.name, + slug: role.slug, + description: role.description, + permissions: role.permissions, + type: role.type, + createdAt: role.created_at, + updatedAt: role.updated_at, +}); diff --git a/src/authorization/serializers/index.ts b/src/authorization/serializers/index.ts new file mode 100644 index 000000000..8aad90ac3 --- /dev/null +++ b/src/authorization/serializers/index.ts @@ -0,0 +1,3 @@ +export * from './environment-role.serializer'; +export * from './create-environment-role-options.serializer'; +export * from './update-environment-role-options.serializer'; diff --git a/src/authorization/serializers/update-environment-role-options.serializer.ts b/src/authorization/serializers/update-environment-role-options.serializer.ts new file mode 100644 index 000000000..537c95928 --- /dev/null +++ b/src/authorization/serializers/update-environment-role-options.serializer.ts @@ -0,0 +1,11 @@ +import { + UpdateEnvironmentRoleOptions, + SerializedUpdateEnvironmentRoleOptions, +} from '../interfaces/update-environment-role-options.interface'; + +export const serializeUpdateEnvironmentRoleOptions = ( + options: UpdateEnvironmentRoleOptions, +): SerializedUpdateEnvironmentRoleOptions => ({ + name: options.name, + description: options.description, +}); diff --git a/src/common/interfaces/index.ts b/src/common/interfaces/index.ts index 2b836ddb6..c9abe5235 100644 --- a/src/common/interfaces/index.ts +++ b/src/common/interfaces/index.ts @@ -1,6 +1,7 @@ export * from './event.interface'; export * from './get-options.interface'; export * from './list.interface'; +export * from './patch-options.interface'; export * from './post-options.interface'; export * from './put-options.interface'; export * from './unprocessable-entity-error.interface'; diff --git a/src/common/interfaces/patch-options.interface.ts b/src/common/interfaces/patch-options.interface.ts new file mode 100644 index 000000000..fe235ad12 --- /dev/null +++ b/src/common/interfaces/patch-options.interface.ts @@ -0,0 +1,6 @@ +export interface PatchOptions { + query?: { [key: string]: any }; + idempotencyKey?: string; + /** Skip API key requirement check (for PKCE-safe methods) */ + skipApiKeyCheck?: boolean; +} diff --git a/src/common/net/fetch-client.ts b/src/common/net/fetch-client.ts index 4b5cf808a..52661859f 100644 --- a/src/common/net/fetch-client.ts +++ b/src/common/net/fetch-client.ts @@ -131,6 +131,40 @@ export class FetchHttpClient extends HttpClient implements HttpClientInterface { } } + async patch( + path: string, + entity: Entity, + options: RequestOptions, + ): Promise { + const resourceURL = HttpClient.getResourceURL( + this.baseURL, + path, + options.params, + ); + + if (HttpClient.isPathRetryable(path)) { + return await this.fetchRequestWithRetry( + resourceURL, + 'PATCH', + HttpClient.getBody(entity), + { + ...HttpClient.getContentTypeHeader(entity), + ...options.headers, + }, + ); + } else { + return await this.fetchRequest( + resourceURL, + 'PATCH', + HttpClient.getBody(entity), + { + ...HttpClient.getContentTypeHeader(entity), + ...options.headers, + }, + ); + } + } + async delete( path: string, options: RequestOptions, diff --git a/src/common/net/http-client.ts b/src/common/net/http-client.ts index b3ac1a92a..73a4b0d0b 100644 --- a/src/common/net/http-client.ts +++ b/src/common/net/http-client.ts @@ -39,6 +39,12 @@ export abstract class HttpClient implements HttpClientInterface { options: RequestOptions, ): Promise; + abstract patch( + path: string, + entity: Entity, + options: RequestOptions, + ): Promise; + abstract delete( path: string, options: RequestOptions, diff --git a/src/index.ts b/src/index.ts index 18a2cf4db..a5d4bb93f 100644 --- a/src/index.ts +++ b/src/index.ts @@ -12,6 +12,7 @@ import { WorkOSOptions } from './common/interfaces'; export * from './actions/interfaces'; export * from './api-keys/interfaces'; export * from './audit-logs/interfaces'; +export * from './authorization/interfaces'; export * from './common/exceptions'; export * from './common/interfaces'; export * from './common/utils/pagination'; diff --git a/src/index.worker.ts b/src/index.worker.ts index 6edf21855..c7cbfe685 100644 --- a/src/index.worker.ts +++ b/src/index.worker.ts @@ -9,6 +9,7 @@ import { WorkOS } from './workos'; export * from './actions/interfaces'; export * from './audit-logs/interfaces'; +export * from './authorization/interfaces'; export * from './common/exceptions'; export * from './common/interfaces'; export * from './common/utils/pagination'; diff --git a/src/workos.ts b/src/workos.ts index d6f7d6dc5..8f7273b3c 100644 --- a/src/workos.ts +++ b/src/workos.ts @@ -11,6 +11,7 @@ import { PKCE } from './pkce/pkce'; import { GetOptions, HttpClientResponseInterface, + PatchOptions, PostOptions, PutOptions, WorkOSOptions, @@ -38,6 +39,7 @@ import { SubtleCryptoProvider } from './common/crypto/subtle-crypto-provider'; import { FetchHttpClient } from './common/net/fetch-client'; import { Widgets } from './widgets/widgets'; import { Actions } from './actions/actions'; +import { Authorization } from './authorization/authorization'; import { Vault } from './vault/vault'; import { ConflictException } from './common/exceptions/conflict.exception'; import { CryptoProvider } from './common/crypto/crypto-provider'; @@ -66,6 +68,7 @@ export class WorkOS { readonly actions: Actions; readonly apiKeys = new ApiKeys(this); readonly auditLogs = new AuditLogs(this); + readonly authorization = new Authorization(this); readonly directorySync = new DirectorySync(this); readonly events = new Events(this); readonly featureFlags = new FeatureFlags(this); @@ -337,6 +340,42 @@ export class WorkOS { } } + async patch( + path: string, + entity: Entity, + options: PatchOptions = {}, + ): Promise<{ data: Result }> { + if (!options.skipApiKeyCheck) { + this.requireApiKey(path); + } + + const requestHeaders: Record = {}; + + if (options.idempotencyKey) { + requestHeaders[HEADER_IDEMPOTENCY_KEY] = options.idempotencyKey; + } + + let res: HttpClientResponseInterface; + + try { + res = await this.client.patch(path, entity, { + params: options.query, + headers: requestHeaders, + }); + } catch (error) { + this.handleHttpError({ path, error }); + + throw error; + } + + try { + return { data: await res.toJSON() }; + } catch (error) { + await this.handleParseError(error, res); + throw error; + } + } + async delete(path: string, query?: any): Promise { this.requireApiKey(path);