diff --git a/packages/core/src/libraries/hook/context-manager.ts b/packages/core/src/libraries/hook/context-manager.ts index 83a144977..45fb16fa1 100644 --- a/packages/core/src/libraries/hook/context-manager.ts +++ b/packages/core/src/libraries/hook/context-manager.ts @@ -1,12 +1,14 @@ import { InteractionEvent, InteractionHookEvent, + type User, managementApiHooksRegistration, type DataHookEvent, type InteractionApiMetadata, type ManagementApiContext, + userInfoSelectFields, } from '@logto/schemas'; -import { type Optional } from '@silverhand/essentials'; +import { pick, type Optional } from '@silverhand/essentials'; import { type Context } from 'koa'; import { type IRouterParamContext } from 'koa-router'; @@ -21,21 +23,44 @@ type DataHookMetadata = { ip: string; } & Partial; -type DataHookContext = { - event: DataHookEvent; +export type DataHookContext = { /** Data details */ data?: unknown; } & Partial & Record; +type UserContext = { + /** + * This user will be picked with {@link userInfoSelectFields} and set to the `data` field. The + * original user object will be discarded. + * + * @example + * const context = { user: { ... } }; + * + * // The actual context to send will be: + * { data: pick(user, ...userInfoSelectFields) } + */ + user: User; +}; + +/** + * A map of data hook event to its context type for better type hinting. + */ +export type DataHookContextMap = { + 'Organization.Membership.Updated': { organizationId: string }; + 'User.Created': UserContext; + 'User.Data.Updated': UserContext; + 'User.Deleted': UserContext; +}; + export class DataHookContextManager { - contextArray: DataHookContext[] = []; + contextArray: Array = []; constructor(public metadata: DataHookMetadata) {} getRegisteredDataHookEventContext( ctx: IRouterParamContext & Context - ): DataHookContext | undefined { + ): Readonly<[DataHookEvent, DataHookContext]> | undefined { const { method, _matchedRoute: matchedRoute } = ctx; const key = buildManagementApiDataHookRegistrationKey(method, matchedRoute); @@ -44,16 +69,29 @@ export class DataHookContextManager { return; } - return { - event: managementApiHooksRegistration[key], - ...buildManagementApiContext(ctx), - data: ctx.response.body, - }; + return Object.freeze([ + managementApiHooksRegistration[key], + { + ...buildManagementApiContext(ctx), + data: ctx.response.body, + }, + ]); } - appendContext(context: DataHookContext) { + appendContext( + event: Event, + context: Event extends keyof DataHookContextMap + ? DataHookContextMap[Event] & Partial & Record + : DataHookContext + ) { + const { user, ...rest } = context; // eslint-disable-next-line @silverhand/fp/no-mutating-methods - this.contextArray.push(context); + this.contextArray.push({ + event, + // eslint-disable-next-line no-restricted-syntax -- trust the input + ...(user ? { data: pick(user as User, ...userInfoSelectFields) } : {}), + ...rest, + }); } } diff --git a/packages/core/src/libraries/hook/index.test.ts b/packages/core/src/libraries/hook/index.test.ts index ef5e827a4..79b3e0a87 100644 --- a/packages/core/src/libraries/hook/index.test.ts +++ b/packages/core/src/libraries/hook/index.test.ts @@ -198,10 +198,7 @@ describe('triggerDataHooks()', () => { const hookData = { path: '/test', method: 'POST', data: { success: true } }; const hooksManager = new DataHookContextManager(metadata); - hooksManager.appendContext({ - event: 'Role.Created', - ...hookData, - }); + hooksManager.appendContext('Role.Created', hookData); await triggerDataHooks(new ConsoleLog(), hooksManager); @@ -257,8 +254,7 @@ describe('triggerDataHooks()', () => { const hooksManager = new DataHookContextManager(metadata); - hooksManager.appendContext({ - event: 'Role.Created', + hooksManager.appendContext('Role.Created', { data: { id: 'user_id', username: 'user' }, }); diff --git a/packages/core/src/libraries/user.ts b/packages/core/src/libraries/user.ts index 7b3b62f87..043ec64ea 100644 --- a/packages/core/src/libraries/user.ts +++ b/packages/core/src/libraries/user.ts @@ -70,6 +70,14 @@ const converBindMfaToMfaVerification = (bindMfa: BindMfa): MfaVerification => { }; }; +export type InsertUserResult = [ + User, + { + /** The organization IDs that the user has been provisioned into. */ + organizationIds: readonly string[]; + }, +]; + export type UserLibrary = ReturnType; export const createUserLibrary = (queries: Queries) => { @@ -107,7 +115,10 @@ export const createUserLibrary = (queries: Queries) => { { retries, factor: 0 } // No need for exponential backoff ); - const insertUser = async (data: OmitAutoSetFields, additionalRoleNames: string[]) => { + const insertUser = async ( + data: OmitAutoSetFields, + additionalRoleNames: string[] + ): Promise => { const roleNames = [...EnvSet.values.userDefaultRoleNames, ...additionalRoleNames]; const [parameterRoles, defaultRoles] = await Promise.all([ findRolesByRoleNames(roleNames), @@ -131,23 +142,31 @@ export const createUserLibrary = (queries: Queries) => { ); } - // Just-in-time organization provisioning - const userEmailDomain = data.primaryEmail?.split('@')[1]; - // TODO: Remove this check when launching - if (EnvSet.values.isDevFeaturesEnabled && userEmailDomain) { - const organizationQueries = new OrganizationQueries(connection); - const organizationIds = await organizationQueries.emailDomains.getOrganizationIdsByDomain( - userEmailDomain - ); - - if (organizationIds.length > 0) { - await organizationQueries.relations.users.insert( - ...organizationIds.map<[string, string]>((organizationId) => [organizationId, user.id]) + const provisionOrganizations = async (): Promise => { + // Just-in-time organization provisioning + const userEmailDomain = data.primaryEmail?.split('@')[1]; + // TODO: Remove this check when launching + if (EnvSet.values.isDevFeaturesEnabled && userEmailDomain) { + const organizationQueries = new OrganizationQueries(connection); + const organizationIds = await organizationQueries.emailDomains.getOrganizationIdsByDomain( + userEmailDomain ); - } - } - return user; + if (organizationIds.length > 0) { + await organizationQueries.relations.users.insert( + ...organizationIds.map<[string, string]>((organizationId) => [ + organizationId, + user.id, + ]) + ); + return organizationIds; + } + } + + return []; + }; + + return [user, { organizationIds: await provisionOrganizations() }]; }); }; diff --git a/packages/core/src/middleware/koa-management-api-hooks.test.ts b/packages/core/src/middleware/koa-management-api-hooks.test.ts index 73f316aa1..ccf82489a 100644 --- a/packages/core/src/middleware/koa-management-api-hooks.test.ts +++ b/packages/core/src/middleware/koa-management-api-hooks.test.ts @@ -38,7 +38,7 @@ describe('koaManagementApiHooks', () => { appendDataHookContext: notToBeCalled, }; next.mockImplementation(() => { - ctx.appendDataHookContext({ event: 'Role.Created', data: { id: '123' } }); + ctx.appendDataHookContext('Role.Created', { data: { id: '123' } }); }); await koaManagementApiHooks(mockHooksLibrary)(ctx, next); diff --git a/packages/core/src/middleware/koa-management-api-hooks.ts b/packages/core/src/middleware/koa-management-api-hooks.ts index 16c157d47..7133f4881 100644 --- a/packages/core/src/middleware/koa-management-api-hooks.ts +++ b/packages/core/src/middleware/koa-management-api-hooks.ts @@ -37,11 +37,10 @@ export const koaManagementApiHooks = 'fooId'), insertUser: jest.fn( - async (user: CreateUser): Promise => ({ - ...mockUser, - ...removeUndefinedKeys(user), // No undefined values will be returned from database - }) + async (user: CreateUser): Promise => [ + { + ...mockUser, + ...removeUndefinedKeys(user), // No undefined values will be returned from database + }, + { organizationIds: [] }, + ] ), verifyUserPassword, signOutUser, diff --git a/packages/core/src/routes/admin-user/basics.ts b/packages/core/src/routes/admin-user/basics.ts index 3ffaea835..330c47ff1 100644 --- a/packages/core/src/routes/admin-user/basics.ts +++ b/packages/core/src/routes/admin-user/basics.ts @@ -200,7 +200,7 @@ export default function adminUserBasicsRoutes( const id = await generateUserId(); - const user = await insertUser( + const [user, { organizationIds }] = await insertUser( { id, primaryEmail, @@ -221,8 +221,14 @@ export default function adminUserBasicsRoutes( [] ); - ctx.body = pick(user, ...userInfoSelectFields); + for (const organizationId of organizationIds) { + ctx.appendDataHookContext('Organization.Membership.Updated', { + ...buildManagementApiContext(ctx), + organizationId, + }); + } + ctx.body = pick(user, ...userInfoSelectFields); return next(); } ); @@ -382,10 +388,9 @@ export default function adminUserBasicsRoutes( ctx.status = 204; // Manually trigger the `User.Deleted` hook since we need to send the user data in the payload - ctx.appendDataHookContext({ - event: 'User.Deleted', + ctx.appendDataHookContext('User.Deleted', { ...buildManagementApiContext(ctx), - data: pick(user, ...userInfoSelectFields), + user, }); return next(); diff --git a/packages/core/src/routes/admin-user/mfa-verifications.test.ts b/packages/core/src/routes/admin-user/mfa-verifications.test.ts index 4e0ac5811..84c85a2f6 100644 --- a/packages/core/src/routes/admin-user/mfa-verifications.test.ts +++ b/packages/core/src/routes/admin-user/mfa-verifications.test.ts @@ -8,6 +8,7 @@ import { mockUserTotpMfaVerification, mockUserWithMfaVerifications, } from '#src/__mocks__/index.js'; +import { type InsertUserResult } from '#src/libraries/user.js'; import type Libraries from '#src/tenants/Libraries.js'; import type Queries from '#src/tenants/Queries.js'; import { MockTenant, type Partial2 } from '#src/test-utils/tenant.js'; @@ -49,10 +50,13 @@ await mockEsmWithActual('../interaction/utils/backup-code-validation.js', () => const usersLibraries = { generateUserId: jest.fn(async () => 'fooId'), insertUser: jest.fn( - async (user: CreateUser): Promise => ({ - ...mockUser, - ...removeUndefinedKeys(user), // No undefined values will be returned from database - }) + async (user: CreateUser): Promise => [ + { + ...mockUser, + ...removeUndefinedKeys(user), // No undefined values will be returned from database + }, + { organizationIds: [] }, + ] ), } satisfies Partial; diff --git a/packages/core/src/routes/admin-user/search.test.ts b/packages/core/src/routes/admin-user/search.test.ts index 8ff623024..83d7daf8e 100644 --- a/packages/core/src/routes/admin-user/search.test.ts +++ b/packages/core/src/routes/admin-user/search.test.ts @@ -1,9 +1,10 @@ import type { CreateUser, Role, User } from '@logto/schemas'; import { userInfoSelectFields, RoleType } from '@logto/schemas'; import { pickDefault } from '@logto/shared/esm'; -import { pick } from '@silverhand/essentials'; +import { pick, removeUndefinedKeys } from '@silverhand/essentials'; import { mockUser, mockUserList, mockUserListResponse } from '#src/__mocks__/index.js'; +import { type InsertUserResult } from '#src/libraries/user.js'; import type Libraries from '#src/tenants/Libraries.js'; import type Queries from '#src/tenants/Queries.js'; import { MockTenant, type Partial2 } from '#src/test-utils/tenant.js'; @@ -52,10 +53,13 @@ const mockedQueries = { const usersLibraries = { generateUserId: jest.fn(async () => 'fooId'), insertUser: jest.fn( - async (user: CreateUser): Promise => ({ - ...mockUser, - ...user, - }) + async (user: CreateUser): Promise => [ + { + ...mockUser, + ...removeUndefinedKeys(user), // No undefined values will be returned from database + }, + { organizationIds: [] }, + ] ), } satisfies Partial; diff --git a/packages/core/src/routes/admin-user/social.test.ts b/packages/core/src/routes/admin-user/social.test.ts index 0d6e4677a..c6dfd41f2 100644 --- a/packages/core/src/routes/admin-user/social.test.ts +++ b/packages/core/src/routes/admin-user/social.test.ts @@ -1,5 +1,6 @@ import { ConnectorType, type CreateUser, type User } from '@logto/schemas'; import { pickDefault } from '@logto/shared/esm'; +import { removeUndefinedKeys } from '@silverhand/essentials'; import { mockConnector0, @@ -9,6 +10,7 @@ import { } from '#src/__mocks__/index.js'; import { mockUser } from '#src/__mocks__/user.js'; import RequestError from '#src/errors/RequestError/index.js'; +import { type InsertUserResult } from '#src/libraries/user.js'; import type Libraries from '#src/tenants/Libraries.js'; import type Queries from '#src/tenants/Queries.js'; import { MockTenant, type Partial2 } from '#src/test-utils/tenant.js'; @@ -47,10 +49,13 @@ const mockedQueries = { const usersLibraries = { generateUserId: jest.fn(async () => 'fooId'), insertUser: jest.fn( - async (user: CreateUser): Promise => ({ - ...mockUser, - ...user, - }) + async (user: CreateUser): Promise => [ + { + ...mockUser, + ...removeUndefinedKeys(user), // No undefined values will be returned from database + }, + { organizationIds: [] }, + ] ), } satisfies Partial; diff --git a/packages/core/src/routes/interaction/actions/submit-interaction.mfa.test.ts b/packages/core/src/routes/interaction/actions/submit-interaction.mfa.test.ts index e45458c13..c02d179e4 100644 --- a/packages/core/src/routes/interaction/actions/submit-interaction.mfa.test.ts +++ b/packages/core/src/routes/interaction/actions/submit-interaction.mfa.test.ts @@ -52,7 +52,10 @@ const userQueries = { const { hasActiveUsers, updateUserById } = userQueries; -const userLibraries = { generateUserId: jest.fn().mockResolvedValue('uid'), insertUser: jest.fn() }; +const userLibraries = { + generateUserId: jest.fn().mockResolvedValue('uid'), + insertUser: jest.fn().mockResolvedValue([{}, { organizationIds: [] }]), +}; const { generateUserId, insertUser } = userLibraries; const submitInteraction = await pickDefault(import('./submit-interaction.js')); @@ -73,7 +76,7 @@ describe('submit action', () => { // eslint-disable-next-line @typescript-eslint/consistent-type-assertions interactionDetails: { params: {} } as Awaited>, assignInteractionHookResult: jest.fn(), - assignDataHookContext: jest.fn(), + appendDataHookContext: jest.fn(), }; const profile = { username: 'username', diff --git a/packages/core/src/routes/interaction/actions/submit-interaction.test.ts b/packages/core/src/routes/interaction/actions/submit-interaction.test.ts index 4c49dc5da..90f6a384f 100644 --- a/packages/core/src/routes/interaction/actions/submit-interaction.test.ts +++ b/packages/core/src/routes/interaction/actions/submit-interaction.test.ts @@ -9,6 +9,7 @@ import { import { createMockUtils, pickDefault } from '@logto/shared/esm'; import type Provider from 'oidc-provider'; +import { type InsertUserResult } from '#src/libraries/user.js'; import { createMockLogContext } from '#src/test-utils/koa-audit-log.js'; import { MockTenant } from '#src/test-utils/tenant.js'; import { createContextWithRouteParameters } from '#src/utils/test-utils.js'; @@ -62,7 +63,9 @@ const { hasActiveUsers, updateUserById, hasUserWithEmail, hasUserWithPhone } = u const userLibraries = { generateUserId: jest.fn().mockResolvedValue('uid'), - insertUser: jest.fn(async (user: CreateUser) => user as User), + insertUser: jest.fn( + async (user: CreateUser): Promise => [user as User, { organizationIds: [] }] + ), }; const { generateUserId, insertUser } = userLibraries; @@ -84,7 +87,7 @@ describe('submit action', () => { // eslint-disable-next-line @typescript-eslint/consistent-type-assertions interactionDetails: { params: {} } as Awaited>, assignInteractionHookResult: jest.fn(), - assignDataHookContext: jest.fn(), + appendDataHookContext: jest.fn(), }; const profile = { username: 'username', @@ -153,8 +156,7 @@ describe('submit action', () => { login: { accountId: 'uid' }, }); - expect(ctx.assignDataHookContext).toBeCalledWith({ - event: 'User.Created', + expect(ctx.appendDataHookContext).toBeCalledWith('User.Created', { user: { id: 'uid', ...upsertProfile, @@ -188,8 +190,7 @@ describe('submit action', () => { login: { accountId: 'pending-account-id' }, }); - expect(ctx.assignDataHookContext).toBeCalledWith({ - event: 'User.Created', + expect(ctx.appendDataHookContext).toBeCalledWith('User.Created', { user: { id: 'pending-account-id', ...upsertProfile, @@ -336,7 +337,7 @@ describe('submit action', () => { expect(assignInteractionResults).toBeCalledWith(ctx, tenant.provider, { login: { accountId: 'foo' }, }); - expect(ctx.assignDataHookContext).not.toBeCalled(); + expect(ctx.appendDataHookContext).not.toBeCalled(); }); it('sign-in with new profile', async () => { @@ -371,8 +372,7 @@ describe('submit action', () => { expect(assignInteractionResults).toBeCalledWith(ctx, tenant.provider, { login: { accountId: 'foo' }, }); - expect(ctx.assignDataHookContext).toBeCalledWith({ - event: 'User.Data.Updated', + expect(ctx.appendDataHookContext).toBeCalledWith('User.Data.Updated', { user: updateProfile, }); }); @@ -432,8 +432,7 @@ describe('submit action', () => { expect(assignInteractionResults).toBeCalledWith(ctx, tenant.provider, { login: { accountId: 'foo' }, }); - expect(ctx.assignDataHookContext).toBeCalledWith({ - event: 'User.Data.Updated', + expect(ctx.appendDataHookContext).toBeCalledWith('User.Data.Updated', { user: { primaryEmail: 'email', name: userInfo.name, @@ -458,8 +457,7 @@ describe('submit action', () => { passwordEncryptionMethod: 'plain', }); expect(assignInteractionResults).not.toBeCalled(); - expect(ctx.assignDataHookContext).toBeCalledWith({ - event: 'User.Data.Updated', + expect(ctx.appendDataHookContext).toBeCalledWith('User.Data.Updated', { user: { passwordEncrypted: 'passwordEncrypted', passwordEncryptionMethod: 'plain', diff --git a/packages/core/src/routes/interaction/actions/submit-interaction.ts b/packages/core/src/routes/interaction/actions/submit-interaction.ts index b22a8d2fd..36f428f42 100644 --- a/packages/core/src/routes/interaction/actions/submit-interaction.ts +++ b/packages/core/src/routes/interaction/actions/submit-interaction.ts @@ -135,7 +135,7 @@ async function handleSubmitRegister( (invitation) => invitation.status === OrganizationInvitationStatus.Pending ); - const user = await insertUser( + const [user, { organizationIds }] = await insertUser( { id, ...userProfile, @@ -188,7 +188,13 @@ async function handleSubmitRegister( await assignInteractionResults(ctx, provider, { login: { accountId: id } }); ctx.assignInteractionHookResult({ userId: id }); - ctx.assignDataHookContext({ event: 'User.Created', user }); + ctx.appendDataHookContext('User.Created', { user }); + + for (const organizationId of organizationIds) { + ctx.appendDataHookContext('Organization.Membership.Updated', { + organizationId, + }); + } log?.append({ userId: id }); appInsights.client?.trackEvent({ @@ -242,10 +248,7 @@ async function handleSubmitSignIn( ctx.assignInteractionHookResult({ userId: accountId }); // Trigger user.updated data hook event if the user profile or mfa data is updated if (hasUpdatedProfile(updateUserProfile) || mfaVerifications.length > 0) { - ctx.assignDataHookContext({ - event: 'User.Data.Updated', - user: updatedUser, - }); + ctx.appendDataHookContext('User.Data.Updated', { user: updatedUser }); } appInsights.client?.trackEvent({ @@ -284,7 +287,7 @@ export default async function submitInteraction( passwordEncryptionMethod, }); ctx.assignInteractionHookResult({ userId: accountId }); - ctx.assignDataHookContext({ event: 'User.Data.Updated', user }); + ctx.appendDataHookContext('User.Data.Updated', { user }); await clearInteractionStorage(ctx, provider); ctx.status = 204; diff --git a/packages/core/src/routes/interaction/middleware/koa-interaction-hooks.ts b/packages/core/src/routes/interaction/middleware/koa-interaction-hooks.ts index 3ce01f274..654c92d6e 100644 --- a/packages/core/src/routes/interaction/middleware/koa-interaction-hooks.ts +++ b/packages/core/src/routes/interaction/middleware/koa-interaction-hooks.ts @@ -1,9 +1,10 @@ -import { userInfoSelectFields, type DataHookEvent, type User } from '@logto/schemas'; -import { conditional, conditionalString, pick, trySafe } from '@silverhand/essentials'; +import { type User } from '@logto/schemas'; +import { conditionalString, trySafe } from '@silverhand/essentials'; import type { MiddlewareType } from 'koa'; import type { IRouterParamContext } from 'koa-router'; import { + type DataHookContext, DataHookContextManager, InteractionHookContextManager, } from '#src/libraries/hook/context-manager.js'; @@ -14,17 +15,17 @@ import { getInteractionStorage } from '../utils/interaction.js'; import type { WithInteractionDetailsContext } from './koa-interaction-details.js'; -type AssignDataHookContext = (payload: { - event: DataHookEvent; - user?: User; - data?: Record; -}) => void; +type AppendDataHookContext = ( + payload: DataHookContext & { + user?: User; + } +) => void; export type WithInteractionHooksContext< ContextT extends IRouterParamContext = IRouterParamContext, > = ContextT & { assignInteractionHookResult: InteractionHookContextManager['assignInteractionHookResult']; - assignDataHookContext: AssignDataHookContext; + appendDataHookContext: DataHookContextManager['appendContext']; }; /** @@ -68,17 +69,7 @@ export default function koaInteractionHooks< ip, }); - // Assign user and event data to the data hook context - ctx.assignDataHookContext = ({ event, user, data: extraData }) => { - dataHookContext.appendContext({ - event, - data: { - // Only return the selected user fields - ...conditional(user && pick(user, ...userInfoSelectFields)), - ...extraData, - }, - }); - }; + ctx.appendDataHookContext = dataHookContext.appendContext.bind(dataHookContext); await next(); diff --git a/packages/core/src/routes/interaction/single-sign-on.ts b/packages/core/src/routes/interaction/single-sign-on.ts index 0a6b3c04f..a3dce05ea 100644 --- a/packages/core/src/routes/interaction/single-sign-on.ts +++ b/packages/core/src/routes/interaction/single-sign-on.ts @@ -135,7 +135,7 @@ export default function singleSignOnRoutes( async (ctx, next) => { const { assignInteractionHookResult, - assignDataHookContext, + appendDataHookContext, guard: { params }, } = ctx; const { @@ -161,7 +161,7 @@ export default function singleSignOnRoutes( // Trigger webhooks assignInteractionHookResult({ userId: accountId }); - assignDataHookContext({ event: 'User.Created', user }); + appendDataHookContext('User.Created', { user }); return next(); } diff --git a/packages/core/src/routes/interaction/utils/single-sign-on.test.ts b/packages/core/src/routes/interaction/utils/single-sign-on.test.ts index 831dd90f4..eca0d1ad7 100644 --- a/packages/core/src/routes/interaction/utils/single-sign-on.test.ts +++ b/packages/core/src/routes/interaction/utils/single-sign-on.test.ts @@ -13,6 +13,7 @@ import { MockTenant } from '#src/test-utils/tenant.js'; import { createContextWithRouteParameters } from '#src/utils/test-utils.js'; import { type WithInteractionDetailsContext } from '../middleware/koa-interaction-details.js'; +import { type WithInteractionHooksContext } from '../middleware/koa-interaction-hooks.js'; const { jest } = import.meta; const { mockEsm } = createMockUtils(jest); @@ -25,7 +26,7 @@ const updateUserSsoIdentityMock = jest.fn(); const insertUserSsoIdentityMock = jest.fn(); const updateUserMock = jest.fn(); const findUserByEmailMock = jest.fn(); -const insertUserMock = jest.fn(); +const insertUserMock = jest.fn().mockResolvedValue([{ id: 'foo' }, { organizationIds: [] }]); const generateUserIdMock = jest.fn().mockResolvedValue('foo'); const getAvailableSsoConnectorsMock = jest.fn(); @@ -59,12 +60,14 @@ const { } = await import('./single-sign-on.js'); describe('Single sign on util methods tests', () => { - const mockContext: WithLogContext & WithInteractionDetailsContext = { + const mockContext = { ...createContextWithRouteParameters(), ...createMockLogContext(), // eslint-disable-next-line @typescript-eslint/consistent-type-assertions interactionDetails: { jti: 'foo' } as Awaited>, - }; + assignInteractionHookResult: jest.fn(), + appendDataHookContext: jest.fn(), + } satisfies WithInteractionHooksContext>; const mockProvider = createMockProvider(); @@ -288,7 +291,7 @@ describe('Single sign on util methods tests', () => { describe('registerWithSsoAuthentication tests', () => { it('should register if no related user account found', async () => { - insertUserMock.mockResolvedValueOnce({ id: 'foo' }); + insertUserMock.mockResolvedValueOnce([{ id: 'foo' }, { organizationIds: [] }]); const { id } = await registerWithSsoAuthentication(mockContext, tenant, { connectorId: wellConfiguredSsoConnector.id, diff --git a/packages/core/src/routes/interaction/utils/single-sign-on.ts b/packages/core/src/routes/interaction/utils/single-sign-on.ts index f9cb5220f..e67514a3b 100644 --- a/packages/core/src/routes/interaction/utils/single-sign-on.ts +++ b/packages/core/src/routes/interaction/utils/single-sign-on.ts @@ -18,6 +18,8 @@ import type Queries from '#src/tenants/Queries.js'; import type TenantContext from '#src/tenants/TenantContext.js'; import assertThat from '#src/utils/assert-that.js'; +import { type WithInteractionHooksContext } from '../middleware/koa-interaction-hooks.js'; + import { assignSingleSignOnAuthenticationResult, getSingleSignOnSessionResult, @@ -289,7 +291,7 @@ const signInAndLinkWithSsoAuthentication = async ( }; export const registerWithSsoAuthentication = async ( - ctx: WithLogContext, + ctx: WithInteractionHooksContext, { queries: { userSsoIdentities: userSsoIdentitiesQueries }, libraries: { users: usersLibrary }, @@ -308,7 +310,7 @@ export const registerWithSsoAuthentication = async ( }; // Insert new user - const user = await usersLibrary.insertUser( + const [user, { organizationIds }] = await usersLibrary.insertUser( { id: await usersLibrary.generateUserId(), ...syncingProfile, @@ -316,6 +318,11 @@ export const registerWithSsoAuthentication = async ( }, [] ); + for (const organizationId of organizationIds) { + ctx.appendDataHookContext('Organization.Membership.Updated', { + organizationId, + }); + } const { id: userId } = user; diff --git a/packages/core/src/routes/organization/index.email-domains.ts b/packages/core/src/routes/organization/index.email-domains.ts index db1c54fcc..b9a5fd560 100644 --- a/packages/core/src/routes/organization/index.email-domains.ts +++ b/packages/core/src/routes/organization/index.email-domains.ts @@ -1,14 +1,14 @@ import { OrganizationEmailDomains } from '@logto/schemas'; -import { type IRouterParamContext } from 'koa-router'; import type Router from 'koa-router'; import { z } from 'zod'; import koaGuard from '#src/middleware/koa-guard.js'; +import { type WithHookContext } from '#src/middleware/koa-management-api-hooks.js'; import koaPagination from '#src/middleware/koa-pagination.js'; import type OrganizationQueries from '#src/queries/organization/index.js'; export default function emailDomainRoutes( - router: Router, + router: Router, organizations: OrganizationQueries ) { const params = Object.freeze({ id: z.string().min(1) }); diff --git a/packages/core/src/routes/organization/index.ts b/packages/core/src/routes/organization/index.ts index 028f0673c..bf3ca873e 100644 --- a/packages/core/src/routes/organization/index.ts +++ b/packages/core/src/routes/organization/index.ts @@ -83,7 +83,10 @@ export default function organizationRoutes( ); // MARK: Organization - user relation routes - router.addRelationRoutes(organizations.relations.users, undefined, { disabled: { get: true } }); + router.addRelationRoutes(organizations.relations.users, undefined, { + disabled: { get: true }, + hookEvent: 'Organization.Membership.Updated', + }); router.get( '/:id/users', diff --git a/packages/core/src/routes/organization/index.user-role-relations.ts b/packages/core/src/routes/organization/index.user-role-relations.ts index db2fa4d5f..283156b93 100644 --- a/packages/core/src/routes/organization/index.user-role-relations.ts +++ b/packages/core/src/routes/organization/index.user-role-relations.ts @@ -1,16 +1,16 @@ import { OrganizationRoles, OrganizationScopes } from '@logto/schemas'; import type Router from 'koa-router'; -import { type IRouterParamContext } from 'koa-router'; import { z } from 'zod'; import RequestError from '#src/errors/RequestError/index.js'; import koaGuard from '#src/middleware/koa-guard.js'; +import { type WithHookContext } from '#src/middleware/koa-management-api-hooks.js'; import koaPagination from '#src/middleware/koa-pagination.js'; import type OrganizationQueries from '#src/queries/organization/index.js'; // Manually add these routes since I don't want to over-engineer the `SchemaRouter` export default function userRoleRelationRoutes( - router: Router, + router: Router, organizations: OrganizationQueries ) { // MARK: Organization - user - organization role relation routes diff --git a/packages/core/src/routes/organization/roles.ts b/packages/core/src/routes/organization/roles.ts index 2f5c630b7..2571f9a47 100644 --- a/packages/core/src/routes/organization/roles.ts +++ b/packages/core/src/routes/organization/roles.ts @@ -116,8 +116,7 @@ export default function organizationRoleRoutes( // Trigger `OrganizationRole.Scope.Updated` event if organizationScopeIds or resourceScopeIds are provided. if (organizationScopeIds.length > 0 || resourceScopeIds.length > 0) { - ctx.appendDataHookContext({ - event: 'OrganizationRole.Scopes.Updated', + ctx.appendDataHookContext('OrganizationRole.Scopes.Updated', { ...buildManagementApiContext(ctx), organizationRoleId: role.id, }); diff --git a/packages/core/src/routes/role.ts b/packages/core/src/routes/role.ts index a26db72b1..be078c110 100644 --- a/packages/core/src/routes/role.ts +++ b/packages/core/src/routes/role.ts @@ -180,8 +180,7 @@ export default function roleRoutes( // Align the response type with POST /roles/:id/scopes const newRolesScopes = await findScopesByIds(scopeIds); - ctx.appendDataHookContext({ - event: 'Role.Scopes.Updated', + ctx.appendDataHookContext('Role.Scopes.Updated', { ...buildManagementApiContext(ctx), roleId: role.id, data: newRolesScopes, diff --git a/packages/core/src/utils/SchemaRouter.ts b/packages/core/src/utils/SchemaRouter.ts index 2b9f0b280..c521708fe 100644 --- a/packages/core/src/utils/SchemaRouter.ts +++ b/packages/core/src/utils/SchemaRouter.ts @@ -1,14 +1,16 @@ -import { type SchemaLike, type GeneratedSchema } from '@logto/schemas'; +import { type SchemaLike, type GeneratedSchema, type DataHookEvent } from '@logto/schemas'; import { generateStandardId } from '@logto/shared'; import { type DeepPartial, isPlainObject } from '@silverhand/essentials'; import camelcase from 'camelcase'; import deepmerge from 'deepmerge'; -import { type MiddlewareType } from 'koa'; +import { type Context, type MiddlewareType } from 'koa'; import Router, { type IRouterParamContext } from 'koa-router'; import { z } from 'zod'; import { type SearchOptions } from '#src/database/utils.js'; +import { buildManagementApiContext } from '#src/libraries/hook/utils.js'; import koaGuard from '#src/middleware/koa-guard.js'; +import { type WithHookContext } from '#src/middleware/koa-management-api-hooks.js'; import koaPagination from '#src/middleware/koa-pagination.js'; import { type TwoRelationsQueries } from './RelationQueries.js'; @@ -87,6 +89,8 @@ type SchemaRouterConfig = { }; type RelationRoutesConfig = { + /** The event that should be triggered when the relation is modified. */ + hookEvent?: DataHookEvent; /** Disable certain routes for the relation. */ disabled: { /** Disable `GET /:id/[pathname]` route. */ @@ -112,7 +116,7 @@ export default class SchemaRouter< CreateSchema extends Partial & { id: string }>, Schema extends SchemaLike & { id: string }, StateT = unknown, - CustomT extends IRouterParamContext = IRouterParamContext, + CustomT extends WithHookContext = WithHookContext, > extends Router { public readonly config: SchemaRouterConfig; @@ -180,7 +184,7 @@ export default class SchemaRouter< GeneratedSchema >, pathname = tableToPathname(relationQueries.schemas[1].table), - { disabled }: Partial = {} + { disabled, hookEvent }: Partial = {} ) { const relationSchema = relationQueries.schemas[1]; const relationSchemaId = camelCaseSchemaId(relationSchema); @@ -189,6 +193,14 @@ export default class SchemaRouter< relationSchemaId, relationSchemaIds: relationSchemaId + 's', }; + const appendHookContext = (ctx: WithHookContext, id: string) => { + if (hookEvent) { + ctx.appendDataHookContext(hookEvent, { + ...buildManagementApiContext(ctx), + [columns.schemaId]: id, + }); + } + }; if (!disabled?.get) { this.get( @@ -207,9 +219,7 @@ export default class SchemaRouter< const [totalCount, entities] = await relationQueries.getEntities( relationSchema, - { - [columns.schemaId]: id, - }, + { [columns.schemaId]: id }, ctx.pagination ); @@ -236,7 +246,7 @@ export default class SchemaRouter< await relationQueries.insert( ...(relationIds?.map<[string, string]>((relationId) => [id, relationId]) ?? []) ); - + appendHookContext(ctx, id); ctx.status = 201; return next(); } @@ -256,6 +266,7 @@ export default class SchemaRouter< } = ctx.guard; await relationQueries.replace(id, relationIds ?? []); + appendHookContext(ctx, id); ctx.status = 204; return next(); } @@ -278,7 +289,8 @@ export default class SchemaRouter< // eslint-disable-next-line @typescript-eslint/no-non-null-assertion -- `koaGuard()` ensures the value is not `undefined` [columns.relationSchemaId]: relationId!, }); - + // eslint-disable-next-line @typescript-eslint/no-non-null-assertion + appendHookContext(ctx, id!); ctx.status = 204; return next(); } diff --git a/packages/integration-tests/src/api/organization.ts b/packages/integration-tests/src/api/organization.ts index 195e00ec8..660406c5a 100644 --- a/packages/integration-tests/src/api/organization.ts +++ b/packages/integration-tests/src/api/organization.ts @@ -34,6 +34,10 @@ export class OrganizationApi extends ApiFactory< await authedAdminApi.post(`${this.path}/${id}/users`, { json: { userIds } }); } + async replaceUsers(id: string, userIds: string[]): Promise { + await authedAdminApi.put(`${this.path}/${id}/users`, { json: { userIds } }); + } + async getUsers( id: string, query?: Query diff --git a/packages/integration-tests/src/tests/api/hook/hook.trigger.custom.data.test.ts b/packages/integration-tests/src/tests/api/hook/hook.trigger.custom.data.test.ts index 3b3040440..b64c83aba 100644 --- a/packages/integration-tests/src/tests/api/hook/hook.trigger.custom.data.test.ts +++ b/packages/integration-tests/src/tests/api/hook/hook.trigger.custom.data.test.ts @@ -1,21 +1,26 @@ -import { hookEvents, userInfoSelectFields } from '@logto/schemas'; +import { SignInIdentifier, hookEvents, userInfoSelectFields } from '@logto/schemas'; import { pick } from '@silverhand/essentials'; -import { createUser, deleteUser } from '#src/api/admin-user.js'; -import { OrganizationRoleApi } from '#src/api/organization-role.js'; -import { OrganizationScopeApi } from '#src/api/organization-scope.js'; +import { deleteUser } from '#src/api/admin-user.js'; import { createResource, deleteResource } from '#src/api/resource.js'; import { createRole } from '#src/api/role.js'; import { createScope } from '#src/api/scope.js'; +import { setEmailConnector, setSmsConnector } from '#src/helpers/connector.js'; import { WebHookApiTest } from '#src/helpers/hook.js'; -import { generateName, generateRoleName } from '#src/utils.js'; +import { registerWithEmail } from '#src/helpers/interactions.js'; +import { OrganizationApiTest } from '#src/helpers/organization.js'; +import { enableAllVerificationCodeSignInMethods } from '#src/helpers/sign-in-experience.js'; +import { UserApiTest } from '#src/helpers/user.js'; +import { generateName, generateRoleName, randomString } from '#src/utils.js'; import WebhookMockServer from './WebhookMockServer.js'; import { assertHookLogResult } from './utils.js'; -describe('trigger custom data hook events', () => { +describe('manual data hook tests', () => { const webbHookMockServer = new WebhookMockServer(9999); const webHookApi = new WebHookApiTest(); + const userApi = new UserApiTest(); + const organizationApi = new OrganizationApiTest(); const hookName = 'customDataHookEventListener'; beforeAll(async () => { @@ -35,7 +40,7 @@ describe('trigger custom data hook events', () => { }); afterEach(async () => { - await webHookApi.cleanUp(); + await Promise.all([webHookApi.cleanUp(), userApi.cleanUp(), organizationApi.cleanUp()]); }); it('create roles with scopeIds should trigger Roles.Scopes.Updated event', async () => { @@ -67,12 +72,10 @@ describe('trigger custom data hook events', () => { }); it('create organizationRoles with organizationScopeIds should trigger OrganizationRole.Scopes.Updated event', async () => { - const roleApi = new OrganizationRoleApi(); - const organizationScopeApi = new OrganizationScopeApi(); - const scope = await organizationScopeApi.create({ name: generateName() }); + const scope = await organizationApi.scopeApi.create({ name: generateName() }); const hook = webHookApi.hooks.get(hookName)!; - const organizationRole = await roleApi.create({ + const organizationRole = await organizationApi.roleApi.create({ name: generateRoleName(), organizationScopeIds: [scope.id], }); @@ -92,13 +95,10 @@ describe('trigger custom data hook events', () => { organizationRoleId: organizationRole.id, }, }); - - await roleApi.delete(organizationRole.id); - await organizationScopeApi.delete(scope.id); }); it('delete user should trigger User.Deleted event with selected user info', async () => { - const user = await createUser(); + const user = await userApi.create({}); const hook = webHookApi.hooks.get(hookName)!; await deleteUser(user.id); @@ -110,4 +110,44 @@ describe('trigger custom data hook events', () => { }, }); }); + + const assertOrganizationMembershipUpdated = async (organizationId: string) => + assertHookLogResult(webHookApi.hooks.get(hookName)!, 'Organization.Membership.Updated', { + hookPayload: { + event: 'Organization.Membership.Updated', + organizationId, + }, + }); + + describe('organization membership update by just-in-time organization provisioning', () => { + it('should trigger `Organization.Membership.Updated` event when user is provisioned by Management API', async () => { + const organization = await organizationApi.create({ name: 'foo' }); + const domain = 'example.com'; + await organizationApi.addEmailDomain(organization.id, domain); + + await userApi.create({ primaryEmail: `${randomString()}@${domain}` }); + await assertOrganizationMembershipUpdated(organization.id); + }); + + // TODO: Add user deletion test case + + it('should trigger `Organization.Membership.Updated` event when user is provisioned by experience', async () => { + await setEmailConnector(); + await setSmsConnector(); + await enableAllVerificationCodeSignInMethods({ + identifiers: [SignInIdentifier.Email], + password: false, + verify: true, + }); + + const organization = await organizationApi.create({ name: 'foo' }); + const domain = 'example.com'; + await organizationApi.addEmailDomain(organization.id, domain); + + await registerWithEmail(`${randomString()}@${domain}`); + await assertOrganizationMembershipUpdated(organization.id); + }); + + // TODO: Add SSO test case + }); }); 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 3b8ebf2c3..f6071e3ea 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 @@ -242,13 +242,16 @@ describe('organization data hook events', () => { it.each(organizationDataHookTestCases)( 'test case %#: %p', - async ({ route, event, method, endpoint, payload }) => { + async ({ route, event, method, endpoint, payload, hookPayload }) => { await authedAdminApi[method]( endpoint.replace('{organizationId}', organizationId).replace('{userId}', userId), { json: JSON.parse(JSON.stringify(payload).replace('{userId}', userId)) } ); const hook = await getWebhookResult(route); expect(hook?.payload.event).toBe(event); + if (hookPayload) { + expect(hook?.payload).toMatchObject(hookPayload); + } } ); }); 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 7f072d93d..3151077e0 100644 --- a/packages/integration-tests/src/tests/api/hook/test-cases.ts +++ b/packages/integration-tests/src/tests/api/hook/test-cases.ts @@ -5,7 +5,10 @@ type TestCase = { event: string; method: 'patch' | 'post' | 'delete' | 'put'; endpoint: string; + /** The payload that should be sent to the route. */ payload: Record; + /** The payload that should be sent to the webhook. */ + hookPayload?: Record; }; export const userDataHookTestCases: TestCase[] = [ @@ -108,6 +111,7 @@ export const organizationDataHookTestCases: TestCase[] = [ method: 'post', endpoint: `organizations/{organizationId}/users`, payload: { userIds: ['{userId}'] }, + hookPayload: { organizationId: expect.any(String) }, }, { route: 'PUT /organizations/:id/users', @@ -115,6 +119,7 @@ export const organizationDataHookTestCases: TestCase[] = [ method: 'put', endpoint: `organizations/{organizationId}/users`, payload: { userIds: ['{userId}'] }, + hookPayload: { organizationId: expect.any(String) }, }, { route: 'DELETE /organizations/:id/users/:userId', @@ -122,6 +127,7 @@ export const organizationDataHookTestCases: TestCase[] = [ method: 'delete', endpoint: `organizations/{organizationId}/users/{userId}`, payload: {}, + hookPayload: { organizationId: expect.any(String) }, }, { route: 'DELETE /organizations/:id', diff --git a/packages/integration-tests/src/tests/api/interaction/organization-jit.test.ts b/packages/integration-tests/src/tests/api/interaction/organization-jit.test.ts index b8b1d3ca2..cbedfd327 100644 --- a/packages/integration-tests/src/tests/api/interaction/organization-jit.test.ts +++ b/packages/integration-tests/src/tests/api/interaction/organization-jit.test.ts @@ -63,4 +63,6 @@ describe('organization just-in-time provisioning', () => { await logoutClient(client); await deleteUser(id); }); + + // TODO: Add SSO test case }); diff --git a/packages/schemas/src/foundations/jsonb-types/hooks.ts b/packages/schemas/src/foundations/jsonb-types/hooks.ts index 0b4204c1d..52fc505ac 100644 --- a/packages/schemas/src/foundations/jsonb-types/hooks.ts +++ b/packages/schemas/src/foundations/jsonb-types/hooks.ts @@ -134,9 +134,6 @@ export const managementApiHooksRegistration = Object.freeze({ 'POST /organizations': 'Organization.Created', 'DELETE /organizations/:id': 'Organization.Deleted', 'PATCH /organizations/:id': 'Organization.Data.Updated', - 'PUT /organizations/:id/users': 'Organization.Membership.Updated', - 'POST /organizations/:id/users': 'Organization.Membership.Updated', - 'DELETE /organizations/:id/users/:userId': 'Organization.Membership.Updated', 'POST /organization-roles': 'OrganizationRole.Created', 'DELETE /organization-roles/:id': 'OrganizationRole.Deleted', 'PATCH /organization-roles/:id': 'OrganizationRole.Data.Updated',