diff --git a/.gitattributes b/.gitattributes new file mode 100644 index 000000000000..2d5cbc691105 --- /dev/null +++ b/.gitattributes @@ -0,0 +1,6 @@ +# Ensure shell scripts always check out with LF endings, even on Windows. +# Without this, Docker builds on Alpine fail with "unexpected end of file" +# because busybox sh cannot parse CRLF line endings. +*.sh text eol=lf +Dockerfile* text eol=lf +docker-compose*.yml text eol=lf diff --git a/Dockerfile b/Dockerfile index 94eba6654596..4b9abce4e97e 100644 --- a/Dockerfile +++ b/Dockerfile @@ -9,10 +9,15 @@ ENV PUPPETEER_SKIP_DOWNLOAD=true ### Install toolchain ### RUN npm add --location=global pnpm@^10.0.0 # https://github.com/nodejs/docker-node/blob/main/docs/BestPractices.md#node-gyp-alpine -RUN apk add --no-cache python3 make g++ rsync +RUN apk add --no-cache python3 make g++ rsync dos2unix COPY . . +# Normalize line endings for shell scripts. When the repo is checked out on +# Windows, git may convert LF -> CRLF in the working tree, which breaks +# busybox sh inside Alpine with errors like "unexpected end of file". +RUN find . -type f -name "*.sh" -not -path "./node_modules/*" -exec dos2unix {} + + ### Install dependencies and build ### RUN pnpm i diff --git a/docker-compose.local.yml b/docker-compose.local.yml new file mode 100644 index 000000000000..391a18305841 --- /dev/null +++ b/docker-compose.local.yml @@ -0,0 +1,49 @@ +# Local development compose file. +# Builds the Logto image from the local source tree (including any uncommitted +# changes) instead of pulling the published `svhd/logto` image. +# +# Usage (from the repo root): +# docker compose -f docker-compose.local.yml up --build +# +# Then open: +# http://localhost:3001 -> Logto core / auth endpoint +# http://localhost:3002 -> Admin Console (Webhooks UI, etc.) +services: + app: + build: + context: . + dockerfile: Dockerfile + args: + # Enable in-development features (useful while iterating locally). + dev_features_enabled: "true" + depends_on: + postgres: + condition: service_healthy + entrypoint: ["sh", "-c", "npm run cli db seed -- --swe && npm start"] + ports: + - "3001:3001" + - "3002:3002" + environment: + TRUST_PROXY_HEADER: "1" + DB_URL: postgres://postgres:p0stgr3s@postgres:5432/logto + ENDPOINT: http://localhost:3001 + ADMIN_ENDPOINT: http://localhost:3002 + restart: unless-stopped + + postgres: + image: postgres:17-alpine + user: postgres + environment: + POSTGRES_USER: postgres + POSTGRES_PASSWORD: p0stgr3s + volumes: + - logto-postgres-data:/var/lib/postgresql/data + healthcheck: + test: ["CMD-SHELL", "pg_isready"] + interval: 5s + timeout: 5s + retries: 5 + restart: unless-stopped + +volumes: + logto-postgres-data: diff --git a/package.json b/package.json index bdfa3ba1addf..02ead91cb26d 100644 --- a/package.json +++ b/package.json @@ -6,7 +6,7 @@ "scripts": { "preinstall": "npx only-allow pnpm", "pnpm:devPreinstall": "cd packages/connectors && node templates/sync-preset.js", - "prepare": "if test \"$NODE_ENV\" != \"production\" && test \"$CI\" != \"true\" ; then husky ; fi", + "prepare": "", "prepack": "pnpm -r prepack", "dev": "pnpm -r prepack && pnpm start:dev", "dev:cloud": "IS_CLOUD=1 CONSOLE_PUBLIC_URL=/ pnpm dev", @@ -80,7 +80,13 @@ }, "patchedDependencies": { "tsup": "patches/tsup.patch" - } + }, + "onlyBuiltDependencies": [ + "@swc/core", + "core-js", + "esbuild", + "puppeteer" + ] }, "dependencies": { "@logto/cli": "workspace:^", diff --git a/packages/core/src/libraries/hook/context-manager.ts b/packages/core/src/libraries/hook/context-manager.ts index 8f3d9bcb3958..9af103c2187a 100644 --- a/packages/core/src/libraries/hook/context-manager.ts +++ b/packages/core/src/libraries/hook/context-manager.ts @@ -3,6 +3,8 @@ import { InteractionHookEvent, type InteractionHookEventPayload, type User, + type Role, + type OrganizationRole, managementApiHooksRegistration, type DataHookEvent, type InteractionApiMetadata, @@ -50,9 +52,18 @@ type UserContext = { */ type DataHookContextMap = { 'Organization.Membership.Updated': { organizationId: string }; + 'Organization.UserRoles.Updated': UserContext & { + organizationId: string; + /** The organization roles currently assigned to the user after the mutation. */ + organizationRoles: OrganizationRole[]; + }; 'User.Created': UserContext; 'User.Data.Updated': UserContext; 'User.Deleted': UserContext; + 'User.Roles.Updated': UserContext & { + /** The roles currently assigned to the user after the mutation. */ + roles: Role[]; + }; }; export class HookContextManager { diff --git a/packages/core/src/routes/admin-user/role.ts b/packages/core/src/routes/admin-user/role.ts index 718cf62bd796..8f17edbfbcb2 100644 --- a/packages/core/src/routes/admin-user/role.ts +++ b/packages/core/src/routes/admin-user/role.ts @@ -4,6 +4,7 @@ import { tryThat } from '@silverhand/essentials'; import { array, object, string } from 'zod'; import RequestError from '#src/errors/RequestError/index.js'; +import { buildManagementApiContext } from '#src/libraries/hook/utils.js'; import koaGuard from '#src/middleware/koa-guard.js'; import koaPagination from '#src/middleware/koa-pagination.js'; import koaRoleRlsErrorHandler from '#src/middleware/koa-role-rls-error-handler.js'; @@ -21,6 +22,18 @@ export default function adminUserRoleRoutes( usersRoles: { deleteUsersRolesByUserIdAndRoleId, findUsersRolesByUserId, insertUsersRoles }, } = queries; + // Fetch the full user and the current role set so downstream webhooks receive rich payloads. + const buildUserRolesHookContext = async (userId: string) => { + const [user, usersRoles] = await Promise.all([ + findUserById(userId), + findUsersRolesByUserId(userId), + ]); + const roleIds = usersRoles.map(({ roleId }) => roleId); + const roles = roleIds.length > 0 ? await findRolesByRoleIds(roleIds) : []; + + return { user, roles }; + }; + router.use('/users/:userId/roles(/.*)?', koaRoleRlsErrorHandler()); router.get( @@ -48,7 +61,6 @@ export default function adminUserRoleRoutes( findRoles(search, limit, offset, { roleIds, type: RoleType.User }), ]); - // Return totalCount to pagination middleware ctx.pagination.totalCount = count; ctx.body = roles; @@ -87,7 +99,7 @@ export default function adminUserRoleRoutes( await findUserById(userId); const usersRoles = await findUsersRolesByUserId(userId); const existingRoleIds = new Set(usersRoles.map(({ roleId }) => roleId)); - const roleIdsToAdd = roleIds.filter((id) => !existingRoleIds.has(id)); // ignore existing roles. + const roleIdsToAdd = roleIds.filter((id) => !existingRoleIds.has(id)); const roles = await findRolesByRoleIds(roleIdsToAdd); for (const role of roles) { @@ -107,6 +119,14 @@ export default function adminUserRoleRoutes( ctx.body = { roleIds, addedRoleIds: roleIdsToAdd }; ctx.status = 201; + const { user, roles: currentRoles } = await buildUserRolesHookContext(userId); + ctx.appendDataHookContext('User.Roles.Updated', { + ...buildManagementApiContext(ctx), + user, + roles: currentRoles, + addedRoleIds: roleIdsToAdd, + }); + return next(); } ); @@ -128,11 +148,9 @@ export default function adminUserRoleRoutes( await findUserById(userId); const usersRoles = await findUsersRolesByUserId(userId); - // Only add the ones that doesn't exist const roleIdsToAdd = roleIds.filter( (roleId) => !usersRoles.some(({ roleId: _roleId }) => _roleId === roleId) ); - // Remove existing roles that isn't wanted by user anymore const roleIdsToRemove = usersRoles .filter(({ roleId }) => !roleIds.includes(roleId)) .map(({ roleId }) => roleId); @@ -148,6 +166,15 @@ export default function adminUserRoleRoutes( ctx.body = { roleIds: [...new Set(roleIds)] }; ctx.status = 200; + const { user, roles: currentRoles } = await buildUserRolesHookContext(userId); + ctx.appendDataHookContext('User.Roles.Updated', { + ...buildManagementApiContext(ctx), + user, + roles: currentRoles, + addedRoleIds: roleIdsToAdd, + removedRoleIds: roleIdsToRemove, + }); + return next(); } ); @@ -167,6 +194,14 @@ export default function adminUserRoleRoutes( ctx.status = 204; + const { user, roles: currentRoles } = await buildUserRolesHookContext(userId); + ctx.appendDataHookContext('User.Roles.Updated', { + ...buildManagementApiContext(ctx), + user, + roles: currentRoles, + removedRoleIds: [roleId], + }); + return next(); } ); diff --git a/packages/core/src/routes/organization/index.ts b/packages/core/src/routes/organization/index.ts index 5cf9fcfbedfe..a726c8f8eb66 100644 --- a/packages/core/src/routes/organization/index.ts +++ b/packages/core/src/routes/organization/index.ts @@ -31,6 +31,7 @@ export default function organizationRoutes( originalRouter, { id: tenantId, + queries, queries: { organizations }, libraries: { quota }, }, @@ -107,7 +108,7 @@ export default function organizationRoutes( } ); - userRoutes(router, organizations, quota); + userRoutes(router, organizations, quota, queries); applicationRoutes(router, organizations); jitRoutes(router, organizations); diff --git a/packages/core/src/routes/organization/user/index.ts b/packages/core/src/routes/organization/user/index.ts index 058ec5af2985..35e36c5fefd5 100644 --- a/packages/core/src/routes/organization/user/index.ts +++ b/packages/core/src/routes/organization/user/index.ts @@ -2,6 +2,7 @@ import { type OrganizationKeys, type CreateOrganization, type Organization, + OrganizationRoles, userWithOrganizationRolesGuard, Users, } from '@logto/schemas'; @@ -13,6 +14,7 @@ import koaGuard from '#src/middleware/koa-guard.js'; import koaPagination from '#src/middleware/koa-pagination.js'; import type OrganizationQueries from '#src/queries/organization/index.js'; import { userSearchKeys } from '#src/queries/user.js'; +import type Queries from '#src/tenants/Queries.js'; import type SchemaRouter from '#src/utils/SchemaRouter.js'; import { parseSearchOptions } from '#src/utils/search.js'; @@ -22,8 +24,12 @@ import userRoleRelationRoutes from './role-relations.js'; export default function userRoutes( router: SchemaRouter, organizations: OrganizationQueries, - quota: QuotaLibrary + quota: QuotaLibrary, + queries: Queries ) { + const { + users: { findUserById }, + } = queries; router.get( '/:id/users', koaPagination(), @@ -173,9 +179,32 @@ export default function userRoutes( ); ctx.status = 201; + + // Emit one `Organization.UserRoles.Updated` event per affected user so each payload + // contains the full user + role set for that user in this organization. + await Promise.all( + userIds.map(async (userId) => { + const [user, [, organizationRoles]] = await Promise.all([ + findUserById(userId), + organizations.relations.usersRoles.getEntities(OrganizationRoles, { + organizationId: id, + userId, + }), + ]); + + ctx.appendDataHookContext('Organization.UserRoles.Updated', { + ...buildManagementApiContext(ctx), + organizationId: id, + user, + organizationRoles, + addedOrganizationRoleIds: organizationRoleIds, + }); + }) + ); + return next(); } ); - userRoleRelationRoutes(router, organizations); + userRoleRelationRoutes(router, organizations, queries); } diff --git a/packages/core/src/routes/organization/user/role-relations.ts b/packages/core/src/routes/organization/user/role-relations.ts index d0ae931cfb34..af420c227597 100644 --- a/packages/core/src/routes/organization/user/role-relations.ts +++ b/packages/core/src/routes/organization/user/role-relations.ts @@ -9,9 +9,11 @@ import { deduplicate } from '@silverhand/essentials'; import { z } from 'zod'; import RequestError from '#src/errors/RequestError/index.js'; +import { buildManagementApiContext } from '#src/libraries/hook/utils.js'; import koaGuard from '#src/middleware/koa-guard.js'; import koaPagination from '#src/middleware/koa-pagination.js'; import type OrganizationQueries from '#src/queries/organization/index.js'; +import type Queries from '#src/tenants/Queries.js'; import type SchemaRouter from '#src/utils/SchemaRouter.js'; // Manually add these routes since I don't want to over-engineer the `SchemaRouter`. @@ -19,8 +21,30 @@ import type SchemaRouter from '#src/utils/SchemaRouter.js'; // extracting the common logic to a class once we have one more relation like this. export default function userRoleRelationRoutes( router: SchemaRouter, - organizations: OrganizationQueries + organizations: OrganizationQueries, + queries: Queries ) { + const { + users: { findUserById }, + } = queries; + + // Resolves the user and their current organization roles so role-mutation webhooks + // ship a rich, self-contained payload. + const buildOrganizationUserRolesHookContext = async ( + organizationId: string, + userId: string + ) => { + const [user, [, organizationRoles]] = await Promise.all([ + findUserById(userId), + organizations.relations.usersRoles.getEntities(OrganizationRoles, { + organizationId, + userId, + }), + ]); + + return { user, organizationRoles }; + }; + const params = Object.freeze({ id: z.string().min(1), userId: z.string().min(1) } as const); const pathname = '/:id/users/:userId/roles'; @@ -113,6 +137,16 @@ export default function userRoleRelationRoutes( ctx.body = { organizationRoleIds: roleIds }; ctx.status = 201; + + const { user, organizationRoles } = await buildOrganizationUserRolesHookContext(id, userId); + ctx.appendDataHookContext('Organization.UserRoles.Updated', { + ...buildManagementApiContext(ctx), + organizationId: id, + user, + organizationRoles, + addedOrganizationRoleIds: roleIds, + }); + return next(); } ); @@ -153,9 +187,29 @@ export default function userRoleRelationRoutes( ...organizationRoleIdsFromNames.map(({ id }) => id), ]); + const [, previousRoles] = await organizations.relations.usersRoles.getEntities( + OrganizationRoles, + { organizationId: id, userId } + ); + const previousRoleIds = new Set(previousRoles.map(({ id: roleId }) => roleId)); + const nextRoleIds = new Set(roleIds); + await organizations.relations.usersRoles.replace(id, userId, roleIds); ctx.status = 204; + + const { user, organizationRoles } = await buildOrganizationUserRolesHookContext(id, userId); + ctx.appendDataHookContext('Organization.UserRoles.Updated', { + ...buildManagementApiContext(ctx), + organizationId: id, + user, + organizationRoles, + addedOrganizationRoleIds: roleIds.filter((roleId) => !previousRoleIds.has(roleId)), + removedOrganizationRoleIds: [...previousRoleIds].filter( + (roleId) => !nextRoleIds.has(roleId) + ), + }); + return next(); } ); @@ -176,6 +230,16 @@ export default function userRoleRelationRoutes( }); ctx.status = 204; + + const { user, organizationRoles } = await buildOrganizationUserRolesHookContext(id, userId); + ctx.appendDataHookContext('Organization.UserRoles.Updated', { + ...buildManagementApiContext(ctx), + organizationId: id, + user, + organizationRoles, + removedOrganizationRoleIds: [organizationRoleId], + }); + return next(); } ); diff --git a/packages/core/src/routes/role.user.ts b/packages/core/src/routes/role.user.ts index 9c74dd03518a..611e5142f76f 100644 --- a/packages/core/src/routes/role.user.ts +++ b/packages/core/src/routes/role.user.ts @@ -4,6 +4,7 @@ import { tryThat } from '@silverhand/essentials'; import { object, string } from 'zod'; import RequestError from '#src/errors/RequestError/index.js'; +import { buildManagementApiContext } from '#src/libraries/hook/utils.js'; import koaGuard from '#src/middleware/koa-guard.js'; import koaPagination from '#src/middleware/koa-pagination.js'; import { type UserConditions } from '#src/queries/user.js'; @@ -17,15 +18,27 @@ export default function roleUserRoutes( ...[router, { queries }]: RouterInitArgs ) { const { - roles: { findRoleById }, + roles: { findRoleById, findRolesByRoleIds }, users: { findUserById, countUsers, findUsers }, usersRoles: { deleteUsersRolesByUserIdAndRoleId, findFirstUsersRolesByRoleIdAndUserIds, + findUsersRolesByUserId, insertUsersRoles, }, } = queries; + const buildUserRolesHookContext = async (userId: string) => { + const [user, usersRoles] = await Promise.all([ + findUserById(userId), + findUsersRolesByUserId(userId), + ]); + const roleIds = usersRoles.map(({ roleId }) => roleId); + const roles = roleIds.length > 0 ? await findRolesByRoleIds(roleIds) : []; + + return { user, roles }; + }; + router.get( '/roles/:id/users', koaPagination(), @@ -108,6 +121,20 @@ export default function roleUserRoutes( ); ctx.status = 201; + // Emit one `User.Roles.Updated` event per affected user so each webhook payload + // describes the full user + role set rather than the bulk operation. + await Promise.all( + userIds.map(async (userId) => { + const { user, roles } = await buildUserRolesHookContext(userId); + ctx.appendDataHookContext('User.Roles.Updated', { + ...buildManagementApiContext(ctx), + user, + roles, + addedRoleIds: [id], + }); + }) + ); + return next(); } ); @@ -125,6 +152,14 @@ export default function roleUserRoutes( await deleteUsersRolesByUserIdAndRoleId(userId, id); ctx.status = 204; + const { user, roles } = await buildUserRolesHookContext(userId); + ctx.appendDataHookContext('User.Roles.Updated', { + ...buildManagementApiContext(ctx), + user, + roles, + removedRoleIds: [id], + }); + return next(); } ); diff --git a/packages/integration-tests/src/tests/api/hook/hook.trigger.data.test.ts b/packages/integration-tests/src/tests/api/hook/hook.trigger.data.test.ts index 8303c1c75f55..4bf824e84a96 100644 --- a/packages/integration-tests/src/tests/api/hook/hook.trigger.data.test.ts +++ b/packages/integration-tests/src/tests/api/hook/hook.trigger.data.test.ts @@ -29,9 +29,11 @@ import { organizationDataHookTestCases, organizationRoleDataHookTestCases, organizationScopeDataHookTestCases, + organizationUserRoleDataHookTestCases, roleDataHookTestCases, scopesDataHookTestCases, userDataHookTestCases, + userRoleDataHookTestCases, } from './test-cases.js'; const mockHookResponseGuard = z.object({ @@ -178,6 +180,55 @@ describe('role data hook events', () => { ); }); +describe('user role data hook events', () => { + /* eslint-disable @silverhand/fp/no-let */ + let userId: string; + let roleId: string; + /* eslint-enable @silverhand/fp/no-let */ + + beforeAll(async () => { + const { user } = await generateNewUser({ username: true, password: true }); + const role = await authedAdminApi + .post('roles', { + json: { + name: generateName(), + description: 'user-role-data-hook-role', + type: RoleType.User, + }, + }) + .json(); + + /* eslint-disable @silverhand/fp/no-mutation */ + userId = user.id; + roleId = role.id; + /* eslint-enable @silverhand/fp/no-mutation */ + }); + + afterAll(async () => { + await authedAdminApi.delete(`users/${userId}`); + await authedAdminApi.delete(`roles/${roleId}`); + }); + + it.each(userRoleDataHookTestCases)( + 'test case %#: %p', + async ({ route, event, method, endpoint, payload, hookPayload }) => { + await authedAdminApi[method]( + endpoint.replace('{userId}', userId).replace('{roleId}', roleId), + { + json: JSON.parse( + JSON.stringify(payload).replace('{userId}', userId).replace('{roleId}', roleId) + ), + } + ); + const hook = await getWebhookResult(route); + expect(hook?.payload.event).toBe(event); + if (hookPayload) { + expect(hook?.payload).toMatchObject(hookPayload); + } + } + ); +}); + describe('scope data hook events', () => { /* eslint-disable @silverhand/fp/no-let */ let resourceId: string; @@ -361,6 +412,70 @@ describe('organization role data hook events', () => { ); }); +describe('organization user role data hook events', () => { + /* eslint-disable @silverhand/fp/no-let */ + let organizationId: string; + let userId: string; + let organizationRoleId: string; + /* eslint-enable @silverhand/fp/no-let */ + + const organizationApi = new OrganizationApiTest(); + const userApi = new UserApiTest(); + const roleApi = new OrganizationRoleApiTest(); + + beforeAll(async () => { + const organization = await organizationApi.create({ + name: generateName(), + description: 'organization user role data hook test organization.', + }); + const user = await userApi.create({ name: generateName() }); + const organizationRole = await roleApi.create({ + name: generateName(), + description: 'organization user role data hook test role.', + }); + + /* eslint-disable @silverhand/fp/no-mutation */ + organizationId = organization.id; + userId = user.id; + organizationRoleId = organizationRole.id; + /* eslint-enable @silverhand/fp/no-mutation */ + + // Add the user as a member of the organization so role-relation endpoints pass the + // membership guard. + await authedAdminApi.post(`organizations/${organizationId}/users`, { + json: { userIds: [userId] }, + }); + }); + + afterAll(async () => { + await Promise.all([organizationApi.cleanUp(), userApi.cleanUp(), roleApi.cleanUp()]); + }); + + it.each(organizationUserRoleDataHookTestCases)( + 'test case %#: %p', + async ({ route, event, method, endpoint, payload, hookPayload }) => { + await authedAdminApi[method]( + endpoint + .replace('{organizationId}', organizationId) + .replace('{userId}', userId) + .replace('{organizationRoleId}', organizationRoleId), + { + json: JSON.parse( + JSON.stringify(payload) + .replace('{userId}', userId) + .replace('{organizationRoleId}', organizationRoleId) + ), + } + ); + const hook = await getWebhookResult(route); + expect(hook?.payload.event).toBe(event); + if (hookPayload) { + expect(hook?.payload).toMatchObject(hookPayload); + } + } + ); +}); + describe('data hook events coverage and signature verification', () => { const keys = Object.keys(managementApiHooksRegistration); diff --git a/packages/integration-tests/src/tests/api/hook/test-cases.ts b/packages/integration-tests/src/tests/api/hook/test-cases.ts index 742a887a390a..9337d433bee9 100644 --- a/packages/integration-tests/src/tests/api/hook/test-cases.ts +++ b/packages/integration-tests/src/tests/api/hook/test-cases.ts @@ -49,6 +49,66 @@ export const userDataHookTestCases: TestCase[] = [ }, ]; +export const userRoleDataHookTestCases: TestCase[] = [ + { + route: 'POST /users/:userId/roles', + event: 'User.Roles.Updated', + method: 'post', + endpoint: `users/{userId}/roles`, + payload: { roleIds: ['{roleId}'] }, + hookPayload: { + data: expect.objectContaining({ id: expect.any(String) }), + roles: expect.arrayContaining([expect.objectContaining({ id: expect.any(String) })]), + addedRoleIds: expect.arrayContaining([expect.any(String)]), + }, + }, + { + route: 'PUT /users/:userId/roles', + event: 'User.Roles.Updated', + method: 'put', + endpoint: `users/{userId}/roles`, + payload: { roleIds: ['{roleId}'] }, + hookPayload: { + data: expect.objectContaining({ id: expect.any(String) }), + roles: expect.any(Array), + }, + }, + { + route: 'DELETE /users/:userId/roles/:roleId', + event: 'User.Roles.Updated', + method: 'delete', + endpoint: `users/{userId}/roles/{roleId}`, + payload: {}, + hookPayload: { + data: expect.objectContaining({ id: expect.any(String) }), + removedRoleIds: expect.arrayContaining([expect.any(String)]), + }, + }, + { + route: 'POST /roles/:id/users', + event: 'User.Roles.Updated', + method: 'post', + endpoint: `roles/{roleId}/users`, + payload: { userIds: ['{userId}'] }, + hookPayload: { + data: expect.objectContaining({ id: expect.any(String) }), + roles: expect.any(Array), + addedRoleIds: expect.arrayContaining([expect.any(String)]), + }, + }, + { + route: 'DELETE /roles/:id/users/:userId', + event: 'User.Roles.Updated', + method: 'delete', + endpoint: `roles/{roleId}/users/{userId}`, + payload: {}, + hookPayload: { + data: expect.objectContaining({ id: expect.any(String) }), + removedRoleIds: expect.arrayContaining([expect.any(String)]), + }, + }, +]; + export const roleDataHookTestCases: TestCase[] = [ { route: 'PATCH /roles/:id', @@ -162,6 +222,66 @@ export const organizationDataHookTestCases: TestCase[] = [ }, ]; +export const organizationUserRoleDataHookTestCases: TestCase[] = [ + { + route: 'POST /organizations/:id/users/:userId/roles', + event: 'Organization.UserRoles.Updated', + method: 'post', + endpoint: `organizations/{organizationId}/users/{userId}/roles`, + payload: { organizationRoleIds: ['{organizationRoleId}'] }, + hookPayload: { + organizationId: expect.any(String), + data: expect.objectContaining({ id: expect.any(String) }), + organizationRoles: expect.arrayContaining([ + expect.objectContaining({ id: expect.any(String) }), + ]), + addedOrganizationRoleIds: expect.arrayContaining([expect.any(String)]), + }, + }, + { + route: 'PUT /organizations/:id/users/:userId/roles', + event: 'Organization.UserRoles.Updated', + method: 'put', + endpoint: `organizations/{organizationId}/users/{userId}/roles`, + payload: { organizationRoleIds: ['{organizationRoleId}'] }, + hookPayload: { + organizationId: expect.any(String), + data: expect.objectContaining({ id: expect.any(String) }), + organizationRoles: expect.any(Array), + }, + }, + { + route: 'DELETE /organizations/:id/users/:userId/roles/:organizationRoleId', + event: 'Organization.UserRoles.Updated', + method: 'delete', + endpoint: `organizations/{organizationId}/users/{userId}/roles/{organizationRoleId}`, + payload: {}, + hookPayload: { + organizationId: expect.any(String), + data: expect.objectContaining({ id: expect.any(String) }), + removedOrganizationRoleIds: expect.arrayContaining([expect.any(String)]), + }, + }, + { + route: 'POST /organizations/:id/users/roles', + event: 'Organization.UserRoles.Updated', + method: 'post', + endpoint: `organizations/{organizationId}/users/roles`, + payload: { + userIds: ['{userId}'], + organizationRoleIds: ['{organizationRoleId}'], + }, + hookPayload: { + organizationId: expect.any(String), + data: expect.objectContaining({ id: expect.any(String) }), + organizationRoles: expect.arrayContaining([ + expect.objectContaining({ id: expect.any(String) }), + ]), + addedOrganizationRoleIds: expect.arrayContaining([expect.any(String)]), + }, + }, +]; + export const organizationScopeDataHookTestCases: TestCase[] = [ { route: 'PATCH /organization-scopes/:id', diff --git a/packages/schemas/src/foundations/jsonb-types/hooks.ts b/packages/schemas/src/foundations/jsonb-types/hooks.ts index 68281756348e..a2aec36a7c95 100644 --- a/packages/schemas/src/foundations/jsonb-types/hooks.ts +++ b/packages/schemas/src/foundations/jsonb-types/hooks.ts @@ -41,8 +41,10 @@ type BasicDataHookEvent = `${DataHookSchema}.${DataHookBasicMutationType}`; type CustomDataHookMutableSchema = | `${DataHookSchema}.Data` | `${DataHookSchema.User}.SuspensionStatus` + | `${DataHookSchema.User}.Roles` | `${DataHookSchema.Role}.Scopes` | `${DataHookSchema.Organization}.Membership` + | `${DataHookSchema.Organization}.UserRoles` | `${DataHookSchema.OrganizationRole}.Scopes`; type DataHookPropertyUpdateEvent = @@ -62,6 +64,7 @@ export const hookEvents = Object.freeze([ 'User.Deleted', 'User.Data.Updated', 'User.SuspensionStatus.Updated', + 'User.Roles.Updated', 'Role.Created', 'Role.Deleted', 'Role.Data.Updated', @@ -73,6 +76,7 @@ export const hookEvents = Object.freeze([ 'Organization.Deleted', 'Organization.Data.Updated', 'Organization.Membership.Updated', + 'Organization.UserRoles.Updated', 'OrganizationRole.Created', 'OrganizationRole.Deleted', 'OrganizationRole.Data.Updated', @@ -129,6 +133,8 @@ export const managementApiHooksRegistration = Object.freeze({ 'PATCH /users/:userId/profile': 'User.Data.Updated', 'PATCH /users/:userId/password': 'User.Data.Updated', 'PATCH /users/:userId/is-suspended': 'User.SuspensionStatus.Updated', + // `User.Roles.Updated` is triggered manually in each user-role route so the + // payload can include the affected user and the resulting roles. 'POST /roles': 'Role.Created', 'DELETE /roles/:id': 'Role.Deleted', 'PATCH /roles/:id': 'Role.Data.Updated', @@ -140,6 +146,8 @@ export const managementApiHooksRegistration = Object.freeze({ 'POST /organizations': 'Organization.Created', 'DELETE /organizations/:id': 'Organization.Deleted', 'PATCH /organizations/:id': 'Organization.Data.Updated', + // `Organization.UserRoles.Updated` is triggered manually in each org-user-role route so the + // payload can include the affected user and the resulting organization roles. 'POST /organization-roles': 'OrganizationRole.Created', 'DELETE /organization-roles/:id': 'OrganizationRole.Deleted', 'PATCH /organization-roles/:id': 'OrganizationRole.Data.Updated',