mirror of
https://github.com/logto-io/logto.git
synced 2024-12-30 20:33:54 -05:00
refactor(core): trigger organization membership updated hook from jit
This commit is contained in:
parent
d4cb91e6c9
commit
ab4867d310
21 changed files with 234 additions and 105 deletions
|
@ -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,41 @@ type DataHookMetadata = {
|
|||
ip: string;
|
||||
} & Partial<InteractionApiMetadata>;
|
||||
|
||||
type DataHookContext = {
|
||||
event: DataHookEvent;
|
||||
export type DataHookContext = {
|
||||
/** Data details */
|
||||
data?: unknown;
|
||||
} & Partial<ManagementApiContext> &
|
||||
Record<string, unknown>;
|
||||
|
||||
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;
|
||||
};
|
||||
|
||||
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<DataHookContext & { event: DataHookEvent }> = [];
|
||||
|
||||
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 +66,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 extends DataHookEvent>(
|
||||
event: Event,
|
||||
context: Event extends keyof DataHookContextMap
|
||||
? DataHookContextMap[Event] & Partial<ManagementApiContext> & Record<string, unknown>
|
||||
: 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,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
@ -107,7 +107,18 @@ export const createUserLibrary = (queries: Queries) => {
|
|||
{ retries, factor: 0 } // No need for exponential backoff
|
||||
);
|
||||
|
||||
const insertUser = async (data: OmitAutoSetFields<CreateUser>, additionalRoleNames: string[]) => {
|
||||
type InsertUserResult = [
|
||||
User,
|
||||
{
|
||||
/** The organization IDs that the user has been provisioned into. */
|
||||
organizationsIds: readonly string[];
|
||||
},
|
||||
];
|
||||
|
||||
const insertUser = async (
|
||||
data: OmitAutoSetFields<CreateUser>,
|
||||
additionalRoleNames: string[]
|
||||
): Promise<InsertUserResult> => {
|
||||
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<readonly string[]> => {
|
||||
// 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, { organizationsIds: await provisionOrganizations() }];
|
||||
});
|
||||
};
|
||||
|
||||
|
|
|
@ -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);
|
||||
|
|
|
@ -37,11 +37,10 @@ export const koaManagementApiHooks = <StateT, ContextT extends IRouterParamConte
|
|||
await next();
|
||||
|
||||
// Auto append pre-registered management API hooks if any
|
||||
const registeredHookEventContext =
|
||||
dataHooksContextManager.getRegisteredDataHookEventContext(ctx);
|
||||
const registeredData = dataHooksContextManager.getRegisteredDataHookEventContext(ctx);
|
||||
|
||||
if (registeredHookEventContext) {
|
||||
dataHooksContextManager.appendContext(registeredHookEventContext);
|
||||
if (registeredData) {
|
||||
dataHooksContextManager.appendContext(...registeredData);
|
||||
}
|
||||
|
||||
// Trigger data hooks
|
||||
|
|
|
@ -200,7 +200,7 @@ export default function adminUserBasicsRoutes<T extends ManagementApiRouter>(
|
|||
|
||||
const id = await generateUserId();
|
||||
|
||||
const user = await insertUser(
|
||||
const [user, { organizationsIds }] = await insertUser(
|
||||
{
|
||||
id,
|
||||
primaryEmail,
|
||||
|
@ -221,8 +221,14 @@ export default function adminUserBasicsRoutes<T extends ManagementApiRouter>(
|
|||
[]
|
||||
);
|
||||
|
||||
ctx.body = pick(user, ...userInfoSelectFields);
|
||||
for (const organizationId of organizationsIds) {
|
||||
ctx.appendDataHookContext('Organization.Membership.Updated', {
|
||||
...buildManagementApiContext(ctx),
|
||||
organizationId,
|
||||
});
|
||||
}
|
||||
|
||||
ctx.body = pick(user, ...userInfoSelectFields);
|
||||
return next();
|
||||
}
|
||||
);
|
||||
|
@ -382,10 +388,9 @@ export default function adminUserBasicsRoutes<T extends ManagementApiRouter>(
|
|||
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();
|
||||
|
|
|
@ -135,7 +135,7 @@ async function handleSubmitRegister(
|
|||
(invitation) => invitation.status === OrganizationInvitationStatus.Pending
|
||||
);
|
||||
|
||||
const user = await insertUser(
|
||||
const [user, { organizationsIds }] = 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 organizationsIds) {
|
||||
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;
|
||||
|
|
|
@ -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<string, unknown>;
|
||||
}) => 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();
|
||||
|
||||
|
|
|
@ -135,7 +135,7 @@ export default function singleSignOnRoutes<T extends IRouterParamContext>(
|
|||
async (ctx, next) => {
|
||||
const {
|
||||
assignInteractionHookResult,
|
||||
assignDataHookContext,
|
||||
appendDataHookContext,
|
||||
guard: { params },
|
||||
} = ctx;
|
||||
const {
|
||||
|
@ -161,7 +161,7 @@ export default function singleSignOnRoutes<T extends IRouterParamContext>(
|
|||
|
||||
// Trigger webhooks
|
||||
assignInteractionHookResult({ userId: accountId });
|
||||
assignDataHookContext({ event: 'User.Created', user });
|
||||
appendDataHookContext('User.Created', { user });
|
||||
|
||||
return next();
|
||||
}
|
||||
|
|
|
@ -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<WithLogContext>,
|
||||
{
|
||||
queries: { userSsoIdentities: userSsoIdentitiesQueries },
|
||||
libraries: { users: usersLibrary },
|
||||
|
@ -308,7 +310,7 @@ export const registerWithSsoAuthentication = async (
|
|||
};
|
||||
|
||||
// Insert new user
|
||||
const user = await usersLibrary.insertUser(
|
||||
const [user, { organizationsIds }] = await usersLibrary.insertUser(
|
||||
{
|
||||
id: await usersLibrary.generateUserId(),
|
||||
...syncingProfile,
|
||||
|
@ -316,6 +318,11 @@ export const registerWithSsoAuthentication = async (
|
|||
},
|
||||
[]
|
||||
);
|
||||
for (const organizationId of organizationsIds) {
|
||||
ctx.appendDataHookContext('Organization.Membership.Updated', {
|
||||
organizationId,
|
||||
});
|
||||
}
|
||||
|
||||
const { id: userId } = user;
|
||||
|
||||
|
|
|
@ -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<unknown, IRouterParamContext>,
|
||||
router: Router<unknown, WithHookContext>,
|
||||
organizations: OrganizationQueries
|
||||
) {
|
||||
const params = Object.freeze({ id: z.string().min(1) });
|
||||
|
|
|
@ -83,7 +83,10 @@ export default function organizationRoutes<T extends ManagementApiRouter>(
|
|||
);
|
||||
|
||||
// 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',
|
||||
|
|
|
@ -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<unknown, IRouterParamContext>,
|
||||
router: Router<unknown, WithHookContext>,
|
||||
organizations: OrganizationQueries
|
||||
) {
|
||||
// MARK: Organization - user - organization role relation routes
|
||||
|
|
|
@ -116,8 +116,7 @@ export default function organizationRoleRoutes<T extends ManagementApiRouter>(
|
|||
|
||||
// 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,
|
||||
});
|
||||
|
|
|
@ -180,8 +180,7 @@ export default function roleRoutes<T extends ManagementApiRouter>(
|
|||
// 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,
|
||||
|
|
|
@ -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<Key extends string> = {
|
|||
};
|
||||
|
||||
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<SchemaLike<Key> & { id: string }>,
|
||||
Schema extends SchemaLike<Key> & { id: string },
|
||||
StateT = unknown,
|
||||
CustomT extends IRouterParamContext = IRouterParamContext,
|
||||
CustomT extends WithHookContext = WithHookContext,
|
||||
> extends Router<StateT, CustomT> {
|
||||
public readonly config: SchemaRouterConfig<Key>;
|
||||
|
||||
|
@ -180,7 +184,7 @@ export default class SchemaRouter<
|
|||
GeneratedSchema<string, RelationCreateSchema, RelationSchema>
|
||||
>,
|
||||
pathname = tableToPathname(relationQueries.schemas[1].table),
|
||||
{ disabled }: Partial<RelationRoutesConfig> = {}
|
||||
{ disabled, hookEvent }: Partial<RelationRoutesConfig> = {}
|
||||
) {
|
||||
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<IRouterParamContext & Context>, 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();
|
||||
}
|
||||
|
|
|
@ -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<void> {
|
||||
await authedAdminApi.put(`${this.path}/${id}/users`, { json: { userIds } });
|
||||
}
|
||||
|
||||
async getUsers(
|
||||
id: string,
|
||||
query?: Query
|
||||
|
|
|
@ -1,21 +1,30 @@
|
|||
import { hookEvents, userInfoSelectFields } from '@logto/schemas';
|
||||
/**
|
||||
* @fileoverview
|
||||
* Tests for manual-triggered data hook events.
|
||||
*/
|
||||
|
||||
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 { 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 +44,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 +76,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 +99,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 +114,40 @@ 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);
|
||||
});
|
||||
|
||||
it('should trigger `Organization.Membership.Updated` event when user is provisioned by experience', async () => {
|
||||
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
|
||||
});
|
||||
});
|
||||
|
|
|
@ -1,3 +1,8 @@
|
|||
/**
|
||||
* @fileoverview
|
||||
* Tests for automatic data hook events triggered by the Management API.
|
||||
*/
|
||||
|
||||
import {
|
||||
RoleType,
|
||||
hookEventGuard,
|
||||
|
@ -242,13 +247,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);
|
||||
}
|
||||
}
|
||||
);
|
||||
});
|
||||
|
|
|
@ -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<string, unknown>;
|
||||
/** The payload that should be sent to the webhook. */
|
||||
hookPayload?: Record<string, unknown>;
|
||||
};
|
||||
|
||||
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',
|
||||
|
|
|
@ -63,4 +63,6 @@ describe('organization just-in-time provisioning', () => {
|
|||
await logoutClient(client);
|
||||
await deleteUser(id);
|
||||
});
|
||||
|
||||
// TODO: Add SSO test case
|
||||
});
|
||||
|
|
|
@ -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',
|
||||
|
|
Loading…
Reference in a new issue