0
Fork 0
mirror of https://github.com/logto-io/logto.git synced 2025-03-10 22:22:45 -05:00

feat(core): add suctom scopes.updated hook events (#5880)

* feat(core): add suctom scopes.updated hook events

add scopes.updated hook event to role creation api

* chore(core): add dev feature guard

add dev feature gurad

* feat(core): fetch scopes details and return to the hook

fetch scopes details and return to the hook

* refactor(core): mark deprecated body of roles/:id/scopes api

mark deprecated body of roles/:id/scopes api

* fix(test): fix unit test

fix unit test
This commit is contained in:
simeng-li 2024-05-17 17:25:31 +08:00 committed by GitHub
parent a1091aee20
commit 88f568f3c5
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
14 changed files with 280 additions and 106 deletions

View file

@ -1,11 +1,22 @@
import {
InteractionEvent,
InteractionHookEvent,
managementApiHooksRegistration,
type DataHookEvent,
type InteractionApiMetadata,
type ManagementApiContext,
} from '@logto/schemas';
import { type Optional } from '@silverhand/essentials';
import { type Context } from 'koa';
import { type IRouterParamContext } from 'koa-router';
import {
buildManagementApiContext,
buildManagementApiDataHookRegistrationKey,
hasRegisteredDataHookEvent,
} from './utils.js';
type ManagementApiHooksRegistrationKey = keyof typeof managementApiHooksRegistration;
type DataHookMetadata = {
userAgent?: string;
@ -16,13 +27,32 @@ type DataHookContext = {
event: DataHookEvent;
/** Data details */
data?: unknown;
} & Partial<ManagementApiContext>;
} & Partial<ManagementApiContext> &
Record<string, unknown>;
export class DataHookContextManager {
contextArray: DataHookContext[] = [];
constructor(public metadata: DataHookMetadata) {}
getRegisteredDataHookEventContext(
ctx: IRouterParamContext & Context
): DataHookContext | undefined {
const { method, _matchedRoute: matchedRoute } = ctx;
const key = buildManagementApiDataHookRegistrationKey(method, matchedRoute);
if (!hasRegisteredDataHookEvent(key)) {
return;
}
return {
event: managementApiHooksRegistration[key],
...buildManagementApiContext(ctx),
data: ctx.response.body,
};
}
appendContext(context: DataHookContext) {
// eslint-disable-next-line @silverhand/fp/no-mutating-methods
this.contextArray.push(context);

View file

@ -9,7 +9,7 @@ import { mockId, mockIdGenerators } from '#src/test-utils/nanoid.js';
import { generateHookTestPayload, parseResponse } from './utils.js';
const { jest } = import.meta;
const { mockEsm } = createMockUtils(jest);
const { mockEsm, mockEsmWithActual } = createMockUtils(jest);
await mockIdGenerators();
@ -18,7 +18,7 @@ mockEsm('#src/utils/sign.js', () => ({
sign: () => mockSignature,
}));
const { sendWebhookRequest } = mockEsm('./utils.js', () => ({
const { sendWebhookRequest } = await mockEsmWithActual('./utils.js', () => ({
sendWebhookRequest: jest
.fn()
.mockResolvedValue({ status: 200, text: async () => '{"message":"ok"}' }),

View file

@ -4,8 +4,10 @@ import {
type HookConfig,
type HookEvent,
type HookEventPayload,
type ManagementApiContext,
} from '@logto/schemas';
import { conditional, trySafe } from '@silverhand/essentials';
import { type Context } from 'koa';
import { type IRouterParamContext } from 'koa-router';
import ky, { type KyResponse } from 'ky';
@ -94,3 +96,17 @@ export const buildManagementApiDataHookRegistrationKey = (
export const hasRegisteredDataHookEvent = (
key: string
): key is keyof typeof managementApiHooksRegistration => key in managementApiHooksRegistration;
export const buildManagementApiContext = (
ctx: IRouterParamContext & Context
): ManagementApiContext => {
const { path, method, status, _matchedRoute: matchedRoute, params } = ctx;
return {
path,
method,
status,
params,
matchedRoute: matchedRoute && String(matchedRoute),
};
};

View file

@ -1,14 +1,9 @@
import { managementApiHooksRegistration } from '@logto/schemas';
import { trySafe } from '@silverhand/essentials';
import { type MiddlewareType } from 'koa';
import { type IRouterParamContext } from 'koa-router';
import { EnvSet } from '#src/env-set/index.js';
import { DataHookContextManager } from '#src/libraries/hook/context-manager.js';
import {
buildManagementApiDataHookRegistrationKey,
hasRegisteredDataHookEvent,
} from '#src/libraries/hook/utils.js';
import type Libraries from '#src/tenants/Libraries.js';
import { getConsoleLogFromContext } from '#src/utils/console.js';
@ -48,33 +43,12 @@ export const koaManagementApiHooks = <StateT, ContextT extends IRouterParamConte
await next();
const {
path,
method,
status,
_matchedRoute: matchedRoute,
params,
response: { body },
} = ctx;
const hookRegistrationMatchedRouteKey = buildManagementApiDataHookRegistrationKey(
method,
matchedRoute
);
// Auto append pre-registered management API hooks if any
if (hasRegisteredDataHookEvent(hookRegistrationMatchedRouteKey)) {
const event = managementApiHooksRegistration[hookRegistrationMatchedRouteKey];
const registeredHookEventContext =
dataHooksContextManager.getRegisteredDataHookEventContext(ctx);
dataHooksContextManager.appendContext({
event,
path,
method,
status,
params,
matchedRoute: matchedRoute && String(matchedRoute),
data: body,
});
if (registeredHookEventContext) {
dataHooksContextManager.appendContext(registeredHookEventContext);
}
// Trigger data hooks

View file

@ -1,5 +1,5 @@
import { dateRegex } from '@logto/core-kit';
import { getNewUsersResponseGuard, getActiveUsersResponseGuard } from '@logto/schemas';
import { getActiveUsersResponseGuard, getNewUsersResponseGuard } from '@logto/schemas';
import { endOfDay, format, subDays } from 'date-fns';
import { number, object, string } from 'zod';

View file

@ -1,11 +1,15 @@
import {
type CreateOrganizationRole,
OrganizationRoles,
organizationRoleWithScopesGuard,
type CreateOrganizationRole,
type OrganizationRole,
type OrganizationRoleKeys,
} from '@logto/schemas';
import { generateStandardId } from '@logto/shared';
import { z } from 'zod';
import { EnvSet } from '#src/env-set/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 koaQuotaGuard from '#src/middleware/koa-quota-guard.js';
@ -13,7 +17,11 @@ import { organizationRoleSearchKeys } from '#src/queries/organization/index.js';
import SchemaRouter from '#src/utils/SchemaRouter.js';
import { parseSearchOptions } from '#src/utils/search.js';
import { type ManagementApiRouter, type RouterInitArgs } from '../types.js';
import {
type ManagementApiRouter,
type ManagementApiRouterContext,
type RouterInitArgs,
} from '../types.js';
import { errorHandler } from './utils.js';
@ -31,7 +39,13 @@ export default function organizationRoleRoutes<T extends ManagementApiRouter>(
},
]: RouterInitArgs<T>
) {
const router = new SchemaRouter(OrganizationRoles, roles, {
const router = new SchemaRouter<
OrganizationRoleKeys,
CreateOrganizationRole,
OrganizationRole,
unknown,
ManagementApiRouterContext
>(OrganizationRoles, roles, {
middlewares: [koaQuotaGuard({ key: 'organizationsEnabled', quota, methods: ['POST', 'PUT'] })],
disabled: { get: true, post: true },
errorHandler,
@ -98,8 +112,24 @@ export default function organizationRoleRoutes<T extends ManagementApiRouter>(
);
}
const { isDevFeaturesEnabled } = EnvSet.values;
ctx.body = role;
ctx.status = 201;
// Trigger `OrganizationRole.Scope.Updated` event if organizationScopeIds or resourceScopeIds are provided.
// TODO: remove dev feature guard
if (
isDevFeaturesEnabled &&
(organizationScopeIds.length > 0 || resourceScopeIds.length > 0)
) {
ctx.appendDataHookContext({
event: 'OrganizationRole.Scopes.Updated',
...buildManagementApiContext(ctx),
organizationRoleId: role.id,
});
}
return next();
}
);

View file

@ -3,8 +3,8 @@ import { pickDefault } from '@logto/shared/esm';
import {
mockAdminUserRole,
mockScope,
mockResource,
mockScope,
mockScopeWithResource,
} from '#src/__mocks__/index.js';
import { mockId, mockIdGenerators } from '#src/test-utils/nanoid.js';
@ -105,7 +105,7 @@ describe('role scope routes', () => {
const response = await roleRequester.post(`/roles/${mockAdminUserRole.id}/scopes`).send({
scopeIds: [mockScope.id],
});
expect(response.status).toEqual(200);
expect(response.status).toEqual(201);
expect(validateRoleScopeAssignment).toHaveBeenCalledWith([mockScope.id], mockAdminUserRole.id);
expect(insertRolesScopes).toHaveBeenCalledWith([
{ id: mockId, roleId: mockAdminUserRole.id, scopeId: mockScope.id },

View file

@ -85,7 +85,7 @@ export default function roleScopeRoutes<T extends ManagementApiRouter>(
params: object({ id: string().min(1) }),
body: object({ scopeIds: string().min(1).array().nonempty() }),
response: Scopes.guard.array(),
status: [200, 400, 404, 422],
status: [201, 400, 404, 422],
}),
async (ctx, next) => {
const {
@ -100,9 +100,15 @@ export default function roleScopeRoutes<T extends ManagementApiRouter>(
scopeIds.map((scopeId) => ({ id: generateStandardId(), roleId: id, scopeId }))
);
/**
* @deprecated
* Align with organization role scopes assignment.
* All relation assignment should not return the entity details at the body
**/
const newRolesScopes = await findRolesScopesByRoleId(id);
const scopes = await findScopesByIds(newRolesScopes.map(({ scopeId }) => scopeId));
ctx.body = scopes;
ctx.status = 201;
return next();
}

View file

@ -1,10 +1,12 @@
import type { RoleResponse } from '@logto/schemas';
import { RoleType, Roles, featuredApplicationGuard, featuredUserGuard } from '@logto/schemas';
import { generateStandardId } from '@logto/shared';
import { pickState, tryThat } from '@silverhand/essentials';
import { object, string, z, number } from 'zod';
import { pickState, trySafe, tryThat } from '@silverhand/essentials';
import { number, object, string, z } from 'zod';
import { EnvSet } from '#src/env-set/index.js';
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 +23,7 @@ export default function roleRoutes<T extends ManagementApiRouter>(
const { queries, libraries } = tenant;
const {
rolesScopes: { insertRolesScopes },
scopes: { findScopesByIds },
roles: {
countRoles,
deleteRoleById,
@ -172,6 +175,24 @@ export default function roleRoutes<T extends ManagementApiRouter>(
await insertRolesScopes(
scopeIds.map((scopeId) => ({ id: generateStandardId(), roleId: role.id, scopeId }))
);
const { isDevFeaturesEnabled } = EnvSet.values;
// TODO: Remove dev feature guard
if (isDevFeaturesEnabled) {
// Trigger the `Role.Scopes.Updated` event if scopeIds are provided. Should not break the request
await trySafe(async () => {
// Align the response type with POST /roles/:id/scopes
const newRolesScopes = await findScopesByIds(scopeIds);
ctx.appendDataHookContext({
event: 'Role.Scopes.Updated',
...buildManagementApiContext(ctx),
roleId: role.id,
data: newRolesScopes,
});
});
}
}
ctx.body = role;

View file

@ -4,7 +4,7 @@ import {
ssoConnectorWithProviderConfigGuard,
} from '@logto/schemas';
import { generateStandardShortId } from '@logto/shared';
import { conditional, assert } from '@silverhand/essentials';
import { assert, conditional } from '@silverhand/essentials';
import { z } from 'zod';
import RequestError from '#src/errors/RequestError/index.js';
@ -13,18 +13,18 @@ import koaPagination from '#src/middleware/koa-pagination.js';
import koaQuotaGuard from '#src/middleware/koa-quota-guard.js';
import { ssoConnectorCreateGuard, ssoConnectorPatchGuard } from '#src/routes/sso-connector/type.js';
import { ssoConnectorFactories } from '#src/sso/index.js';
import { isSupportedSsoProvider, isSupportedSsoConnector } from '#src/sso/utils.js';
import { isSupportedSsoConnector, isSupportedSsoProvider } from '#src/sso/utils.js';
import { tableToPathname } from '#src/utils/SchemaRouter.js';
import assertThat from '#src/utils/assert-that.js';
import { type ManagementApiRouter, type RouterInitArgs } from '../types.js';
import {
parseFactoryDetail,
parseConnectorConfig,
fetchConnectorProviderDetails,
validateConnectorDomains,
parseConnectorConfig,
parseFactoryDetail,
validateConnectorConfigConnectionStatus,
validateConnectorDomains,
} from './utils.js';
export default function singleSignOnConnectorsRoutes<T extends ManagementApiRouter>(

View file

@ -9,7 +9,7 @@ import type TenantContext from '#src/tenants/TenantContext.js';
export type AnonymousRouter = Router<unknown, WithLogContext & WithI18nContext>;
type ManagementApiRouterContext = WithAuthContext &
export type ManagementApiRouterContext = WithAuthContext &
WithLogContext &
WithI18nContext &
WithHookContext &

View file

@ -0,0 +1,97 @@
import { hookEvents } from '@logto/schemas';
import { OrganizationRoleApi } from '#src/api/organization-role.js';
import { OrganizationScopeApi } from '#src/api/organization-scope.js';
import { createResource, deleteResource } from '#src/api/resource.js';
import { createRole } from '#src/api/role.js';
import { createScope } from '#src/api/scope.js';
import { WebHookApiTest } from '#src/helpers/hook.js';
import { generateName, generateRoleName } from '#src/utils.js';
import WebhookMockServer from './WebhookMockServer.js';
import { assertHookLogResult } from './utils.js';
describe('trigger custom data hook events', () => {
const webbHookMockServer = new WebhookMockServer(9999);
const webHookApi = new WebHookApiTest();
const hookName = 'customDataHookEventListener';
beforeAll(async () => {
await webbHookMockServer.listen();
});
afterAll(async () => {
await webbHookMockServer.close();
});
beforeEach(async () => {
await webHookApi.create({
name: hookName,
events: [...hookEvents],
config: { url: webbHookMockServer.endpoint },
});
});
afterEach(async () => {
await webHookApi.cleanUp();
});
it('create roles with scopeIds should trigger Roles.Scopes.Updated event', async () => {
const resource = await createResource();
const scope = await createScope(resource.id);
const hook = webHookApi.hooks.get(hookName)!;
const role = await createRole({ scopeIds: [scope.id] });
await assertHookLogResult(hook, 'Role.Created', {
hookPayload: {
event: 'Role.Created',
path: '/roles',
data: role,
},
});
await assertHookLogResult(hook, 'Role.Scopes.Updated', {
hookPayload: {
event: 'Role.Scopes.Updated',
path: '/roles',
roleId: role.id,
data: [scope],
},
});
// Clean up
await deleteResource(resource.id);
});
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 hook = webHookApi.hooks.get(hookName)!;
const organizationRole = await roleApi.create({
name: generateRoleName(),
organizationScopeIds: [scope.id],
});
await assertHookLogResult(hook, 'OrganizationRole.Created', {
hookPayload: {
event: 'OrganizationRole.Created',
path: '/organization-roles',
data: organizationRole,
},
});
await assertHookLogResult(hook, 'OrganizationRole.Scopes.Updated', {
hookPayload: {
event: 'OrganizationRole.Scopes.Updated',
path: '/organization-roles',
organizationRoleId: organizationRole.id,
},
});
await roleApi.delete(organizationRole.id);
await organizationScopeApi.delete(scope.id);
});
});

View file

@ -1,16 +1,11 @@
import {
InteractionEvent,
InteractionHookEvent,
LogResult,
SignInIdentifier,
hookEvents,
type Hook,
type HookEvent,
} from '@logto/schemas';
import { assert } from '@silverhand/essentials';
import { authedAdminApi } from '#src/api/api.js';
import { getWebhookRecentLogs } from '#src/api/logs.js';
import { resetPasswordlessConnectors } from '#src/helpers/connector.js';
import { WebHookApiTest } from '#src/helpers/hook.js';
import {
@ -24,9 +19,10 @@ import {
enableAllVerificationCodeSignInMethods,
} from '#src/helpers/sign-in-experience.js';
import { UserApiTest, generateNewUserProfile } from '#src/helpers/user.js';
import { generateEmail, generatePassword, waitFor } from '#src/utils.js';
import { generateEmail, generatePassword } from '#src/utils.js';
import WebhookMockServer, { mockHookResponseGuard, verifySignature } from './WebhookMockServer.js';
import WebhookMockServer from './WebhookMockServer.js';
import { assertHookLogResult } from './utils.js';
const webbHookMockServer = new WebhookMockServer(9999);
const userNamePrefix = 'hookTriggerTestUser';
@ -38,57 +34,6 @@ const email = generateEmail();
const userApi = new UserApiTest();
const webHookApi = new WebHookApiTest();
const assertHookLogResult = async (
{ id: hookId, signingKey }: Hook,
event: HookEvent,
assertions: {
errorMessage?: string;
toBeUndefined?: boolean;
hookPayload?: Record<string, unknown>;
}
) => {
// Since the webhook request is async, we need to wait for a while to ensure the webhook response is received.
await waitFor(50);
const logs = await getWebhookRecentLogs(
hookId,
new URLSearchParams({ logKey: `TriggerHook.${event}`, page_size: '10' })
);
const logEntry = logs[0];
if (assertions.toBeUndefined) {
expect(logEntry).toBeUndefined();
return;
}
expect(logEntry).toBeTruthy();
assert(logEntry, new Error('Log entry not found'));
const { payload } = logEntry;
expect(payload.hookId).toEqual(hookId);
expect(payload.key).toEqual(`TriggerHook.${event}`);
const { result, error } = payload;
if (result === LogResult.Success) {
expect(payload.response).toBeTruthy();
const { body } = mockHookResponseGuard.parse(payload.response);
expect(verifySignature(body.rawPayload, signingKey, body.signature)).toBeTruthy();
if (assertions.hookPayload) {
expect(body.payload).toEqual(expect.objectContaining(assertions.hookPayload));
}
}
if (assertions.errorMessage) {
expect(result).toEqual(LogResult.Error);
expect(error).toContain(assertions.errorMessage);
}
};
beforeAll(async () => {
await Promise.all([
resetPasswordlessConnectors(),

View file

@ -0,0 +1,55 @@
import { LogResult, type Hook, type HookEvent } from '@logto/schemas';
import { assert } from '@silverhand/essentials';
import { getWebhookRecentLogs } from '#src/api/logs.js';
import { waitFor } from '#src/utils.js';
import { mockHookResponseGuard, verifySignature } from './WebhookMockServer.js';
export const assertHookLogResult = async (
{ id: hookId, signingKey }: Hook,
event: HookEvent,
assertions: {
errorMessage?: string;
toBeUndefined?: boolean;
hookPayload?: Record<string, unknown>;
}
) => {
// Since the webhook request is async, we need to wait for a while to ensure the webhook response is received.
await waitFor(100);
const logs = await getWebhookRecentLogs(
hookId,
new URLSearchParams({ logKey: `TriggerHook.${event}`, page_size: '10' })
);
const logEntry = logs[0];
if (assertions.toBeUndefined) {
expect(logEntry).toBeUndefined();
return;
}
expect(logEntry).toBeTruthy();
assert(logEntry, new Error('Log entry not found'));
const { payload } = logEntry;
expect(payload.hookId).toEqual(hookId);
expect(payload.key).toEqual(`TriggerHook.${event}`);
const { result, error } = payload;
if (assertions.hookPayload) {
expect(result).toEqual(LogResult.Success);
expect(payload.response).toBeTruthy();
const { body } = mockHookResponseGuard.parse(payload.response);
expect(verifySignature(body.rawPayload, signingKey, body.signature)).toBeTruthy();
expect(body.payload).toEqual(expect.objectContaining(assertions.hookPayload));
}
if (assertions.errorMessage) {
expect(result).toEqual(LogResult.Error);
expect(error).toContain(assertions.errorMessage);
}
};