0
Fork 0
mirror of https://github.com/logto-io/logto.git synced 2024-12-30 20:33:54 -05:00

refactor(core,console): filter out webhook logs from audit logs list (#4243)

* refactor(core,console): filter out webhook logs from audit logs list

* refactor(core): separate the method of finding audit logs and webhook logs

* refactor(test): update integration tests

* chore: adopt code review suggestions

* refactor(core): refactor build log condition method and update its use cases
This commit is contained in:
Darcy Ye 2023-08-01 11:23:03 +08:00 committed by GitHub
parent 74e9734ef8
commit 028ffae068
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
12 changed files with 222 additions and 113 deletions

View file

@ -6,7 +6,7 @@ import useSWR from 'swr';
import ApplicationName from '@/components/ApplicationName';
import UserName from '@/components/UserName';
import { defaultPageSize } from '@/consts';
import { auditLogEventTitle, defaultPageSize } from '@/consts';
import Table from '@/ds-components/Table';
import type { Column } from '@/ds-components/Table/types';
import type { RequestError } from '@/hooks/use-api';
@ -21,6 +21,11 @@ import EventName from './components/EventName';
import EventSelector from './components/EventSelector';
import * as styles from './index.module.scss';
const auditLogEventOptions = Object.entries(auditLogEventTitle).map(([value, title]) => ({
value,
title: title ?? value,
}));
type Props = {
userId?: string;
className?: string;
@ -103,6 +108,7 @@ function AuditLogTable({ userId, className }: Props) {
<div className={styles.eventSelector}>
<EventSelector
value={event}
options={auditLogEventOptions}
onChange={(event) => {
updateSearchParameters({ event, page: undefined });
}}

View file

@ -1,54 +1,64 @@
import type { LogKey } from '@logto/schemas';
import type { AuditLogKey, LogKey, WebhookLogKey } from '@logto/schemas';
import { type Optional } from '@silverhand/essentials';
export const logEventTitle: Record<string, Optional<string>> & Record<LogKey, Optional<string>> =
Object.freeze({
'ExchangeTokenBy.AuthorizationCode': 'Exchange token by Code',
'ExchangeTokenBy.ClientCredentials': 'Exchange token by Client Credentials',
'ExchangeTokenBy.RefreshToken': 'Exchange token by Refresh Token',
'ExchangeTokenBy.Unknown': undefined,
'Interaction.Create': 'Interaction started',
'Interaction.End': 'Interaction ended',
'Interaction.ForgotPassword.Identifier.Password.Submit':
'Submit forgot-password identifier with password',
'Interaction.ForgotPassword.Identifier.Social.Create': undefined,
'Interaction.ForgotPassword.Identifier.Social.Submit': undefined,
'Interaction.ForgotPassword.Identifier.VerificationCode.Create':
'Create and send forgot-password verification code',
'Interaction.ForgotPassword.Identifier.VerificationCode.Submit':
'Submit and verify forgot-password verification code',
'Interaction.ForgotPassword.Profile.Create': 'Put new forgot-password interaction profile',
'Interaction.ForgotPassword.Profile.Delete': 'Delete forgot-password interaction profile',
'Interaction.ForgotPassword.Profile.Update': 'Patch update forgot-password interaction profile',
'Interaction.ForgotPassword.Submit': 'Submit forgot-password interaction',
'Interaction.ForgotPassword.Update': 'Update forgot-password interaction',
'Interaction.Register.Identifier.Password.Submit': undefined,
'Interaction.Register.Identifier.Social.Create': undefined,
'Interaction.Register.Identifier.Social.Submit': undefined,
'Interaction.Register.Identifier.VerificationCode.Create':
'Create and send register identifier with verification code',
'Interaction.Register.Identifier.VerificationCode.Submit':
'Submit and verify register verification code',
'Interaction.Register.Profile.Create': 'Put new register interaction profile',
'Interaction.Register.Profile.Delete': 'Delete register interaction profile',
'Interaction.Register.Profile.Update': 'Patch update register interaction profile',
'Interaction.Register.Submit': 'Submit register interaction',
'Interaction.Register.Update': 'Update register interaction',
'Interaction.SignIn.Identifier.Password.Submit': 'Submit sign-in identifier with password',
'Interaction.SignIn.Identifier.Social.Create': 'Create social sign-in authorization-url',
'Interaction.SignIn.Identifier.Social.Submit': 'Authenticate and submit social identifier',
'Interaction.SignIn.Identifier.VerificationCode.Create':
'Create and send sign-in verification code',
'Interaction.SignIn.Identifier.VerificationCode.Submit':
'Submit and verify sign-in identifier with verification code',
'Interaction.SignIn.Profile.Create': 'Put new sign-in interaction profile',
'Interaction.SignIn.Profile.Delete': 'Delete sign-in interaction profile',
'Interaction.SignIn.Profile.Update': 'Patch Update sign-in interaction profile',
'Interaction.SignIn.Submit': 'Submit sign-in interaction',
'Interaction.SignIn.Update': 'Update sign-in interaction',
'TriggerHook.PostRegister': undefined,
'TriggerHook.PostResetPassword': undefined,
'TriggerHook.PostSignIn': undefined,
RevokeToken: undefined,
Unknown: undefined,
});
export const auditLogEventTitle: Record<string, Optional<string>> &
Record<AuditLogKey, Optional<string>> = Object.freeze({
'ExchangeTokenBy.AuthorizationCode': 'Exchange token by Code',
'ExchangeTokenBy.ClientCredentials': 'Exchange token by Client Credentials',
'ExchangeTokenBy.RefreshToken': 'Exchange token by Refresh Token',
'ExchangeTokenBy.Unknown': undefined,
'Interaction.Create': 'Interaction started',
'Interaction.End': 'Interaction ended',
'Interaction.ForgotPassword.Identifier.Password.Submit':
'Submit forgot-password identifier with password',
'Interaction.ForgotPassword.Identifier.Social.Create': undefined,
'Interaction.ForgotPassword.Identifier.Social.Submit': undefined,
'Interaction.ForgotPassword.Identifier.VerificationCode.Create':
'Create and send forgot-password verification code',
'Interaction.ForgotPassword.Identifier.VerificationCode.Submit':
'Submit and verify forgot-password verification code',
'Interaction.ForgotPassword.Profile.Create': 'Put new forgot-password interaction profile',
'Interaction.ForgotPassword.Profile.Delete': 'Delete forgot-password interaction profile',
'Interaction.ForgotPassword.Profile.Update': 'Patch update forgot-password interaction profile',
'Interaction.ForgotPassword.Submit': 'Submit forgot-password interaction',
'Interaction.ForgotPassword.Update': 'Update forgot-password interaction',
'Interaction.Register.Identifier.Password.Submit': undefined,
'Interaction.Register.Identifier.Social.Create': undefined,
'Interaction.Register.Identifier.Social.Submit': undefined,
'Interaction.Register.Identifier.VerificationCode.Create':
'Create and send register identifier with verification code',
'Interaction.Register.Identifier.VerificationCode.Submit':
'Submit and verify register verification code',
'Interaction.Register.Profile.Create': 'Put new register interaction profile',
'Interaction.Register.Profile.Delete': 'Delete register interaction profile',
'Interaction.Register.Profile.Update': 'Patch update register interaction profile',
'Interaction.Register.Submit': 'Submit register interaction',
'Interaction.Register.Update': 'Update register interaction',
'Interaction.SignIn.Identifier.Password.Submit': 'Submit sign-in identifier with password',
'Interaction.SignIn.Identifier.Social.Create': 'Create social sign-in authorization-url',
'Interaction.SignIn.Identifier.Social.Submit': 'Authenticate and submit social identifier',
'Interaction.SignIn.Identifier.VerificationCode.Create':
'Create and send sign-in verification code',
'Interaction.SignIn.Identifier.VerificationCode.Submit':
'Submit and verify sign-in identifier with verification code',
'Interaction.SignIn.Profile.Create': 'Put new sign-in interaction profile',
'Interaction.SignIn.Profile.Delete': 'Delete sign-in interaction profile',
'Interaction.SignIn.Profile.Update': 'Patch Update sign-in interaction profile',
'Interaction.SignIn.Submit': 'Submit sign-in interaction',
'Interaction.SignIn.Update': 'Update sign-in interaction',
RevokeToken: undefined,
Unknown: undefined,
});
// `webhookLogEventTitle` and `logEventTitle` are not used yet, keep them just in case.
const webhookLogEventTitle: Record<string, Optional<string>> &
Record<WebhookLogKey, Optional<string>> = Object.freeze({
'TriggerHook.PostRegister': undefined,
'TriggerHook.PostResetPassword': undefined,
'TriggerHook.PostSignIn': undefined,
});
export const logEventTitle: Record<string, Optional<string>> & Record<LogKey, Optional<string>> = {
...auditLogEventTitle,
...webhookLogEventTitle,
};

View file

@ -1,33 +1,50 @@
import type { HookExecutionStats, Log } from '@logto/schemas';
import { token, Logs } from '@logto/schemas';
import {
token,
type hook,
Logs,
type HookExecutionStats,
type Log,
type interaction,
type LogKeyUnknown,
} from '@logto/schemas';
import { conditionalSql, convertToIdentifiers } from '@logto/shared';
import { conditional, conditionalArray } from '@silverhand/essentials';
import { subDays } from 'date-fns';
import type { CommonQueryMethods } from 'slonik';
import { sql } from 'slonik';
import type { CommonQueryMethods } from 'slonik';
import { buildFindEntityByIdWithPool } from '#src/database/find-entity-by-id.js';
import { buildInsertIntoWithPool } from '#src/database/insert-into.js';
const { table, fields } = convertToIdentifiers(Logs);
export type AllowedKeyPrefix = hook.Type | token.Type | interaction.Prefix | typeof LogKeyUnknown;
type LogCondition = {
logKey?: string;
applicationId?: string;
userId?: string;
hookId?: string;
payload?: { applicationId?: string; userId?: string; hookId?: string };
startTimeExclusive?: number;
includeKeyPrefix?: AllowedKeyPrefix[];
};
const buildLogConditionSql = (logCondition: LogCondition) =>
conditionalSql(logCondition, ({ logKey, applicationId, userId, hookId, startTimeExclusive }) => {
conditionalSql(logCondition, ({ logKey, payload, startTimeExclusive, includeKeyPrefix = [] }) => {
const keyPrefixFilter = conditional(
includeKeyPrefix.length > 0 &&
includeKeyPrefix.map((prefix) => sql`${fields.key} like ${`${prefix}%`}`)
);
const subConditions = [
conditionalSql(logKey, (logKey) => sql`${fields.key}=${logKey}`),
conditionalSql(userId, (userId) => sql`${fields.payload}->>'userId'=${userId}`),
conditionalSql(
applicationId,
(applicationId) => sql`${fields.payload}->>'applicationId'=${applicationId}`
keyPrefixFilter,
(keyPrefixFilter) => sql`(${sql.join(keyPrefixFilter, sql` or `)})`
),
conditionalSql(hookId, (hookId) => sql`${fields.payload}->>'hookId'=${hookId}`),
...conditionalArray(
payload &&
Object.entries(payload).map(([key, value]) =>
value ? sql`${fields.payload}->>${key}=${value}` : sql``
)
),
conditionalSql(logKey, (logKey) => sql`${fields.key}=${logKey}`),
conditionalSql(
startTimeExclusive,
(startTimeExclusive) =>

View file

@ -6,6 +6,7 @@ import {
type CreateHook,
LogResult,
type Log,
hook,
} from '@logto/schemas';
import { pickDefault } from '@logto/shared/esm';
import { subDays } from 'date-fns';
@ -155,8 +156,18 @@ describe('hook routes', () => {
await hookRequest.get(
`/hooks/${hookId}/recent-logs?logKey=${logKey}&page=${page}&page_size=${pageSize}`
);
expect(countLogs).toHaveBeenCalledWith({ hookId, logKey, startTimeExclusive });
expect(findLogs).toHaveBeenCalledWith(5, 0, { hookId, logKey, startTimeExclusive });
expect(countLogs).toHaveBeenCalledWith({
payload: { hookId },
logKey,
startTimeExclusive,
includeKeyPrefix: [hook.Type.TriggerHook],
});
expect(findLogs).toHaveBeenCalledWith(5, 0, {
payload: { hookId },
logKey,
startTimeExclusive,
includeKeyPrefix: [hook.Type.TriggerHook],
});
jest.useRealTimers();
});

View file

@ -1,4 +1,11 @@
import { Hooks, Logs, hookConfigGuard, hookEventsGuard, hookResponseGuard } from '@logto/schemas';
import {
Hooks,
Logs,
hookConfigGuard,
hookEventsGuard,
hookResponseGuard,
hook,
} from '@logto/schemas';
import { generateStandardId } from '@logto/shared';
import { conditional, deduplicate, yes } from '@silverhand/essentials';
import { subDays } from 'date-fns';
@ -8,6 +15,7 @@ import RequestError from '#src/errors/RequestError/index.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';
import { type AllowedKeyPrefix } from '#src/queries/log.js';
import assertThat from '#src/utils/assert-that.js';
import type { AuthedRouter, RouterInitArgs } from './types.js';
@ -114,11 +122,22 @@ export default function hookRoutes<T extends AuthedRouter>(
query: { logKey },
} = ctx.guard;
const includeKeyPrefix: AllowedKeyPrefix[] = [hook.Type.TriggerHook];
const startTimeExclusive = subDays(new Date(), 1).getTime();
const [{ count }, logs] = await Promise.all([
countLogs({ logKey, hookId: id, startTimeExclusive }),
findLogs(limit, offset, { logKey, hookId: id, startTimeExclusive }),
countLogs({
logKey,
payload: { hookId: id },
startTimeExclusive,
includeKeyPrefix,
}),
findLogs(limit, offset, {
logKey,
payload: { hookId: id },
startTimeExclusive,
includeKeyPrefix,
}),
]);
ctx.pagination.totalCount = count;

View file

@ -1,4 +1,4 @@
import { LogResult } from '@logto/schemas';
import { LogResult, token, interaction, LogKeyUnknown } from '@logto/schemas';
import type { Log } from '@logto/schemas';
import { pickDefault } from '@logto/shared/esm';
@ -42,8 +42,26 @@ describe('logRoutes', () => {
await logRequest.get(
`/logs?userId=${userId}&applicationId=${applicationId}&logKey=${logKey}&page=${page}&page_size=${pageSize}`
);
expect(countLogs).toHaveBeenCalledWith({ userId, applicationId, logKey });
expect(findLogs).toHaveBeenCalledWith(5, 0, { userId, applicationId, logKey });
expect(countLogs).toHaveBeenCalledWith({
payload: { userId, applicationId },
logKey,
includeKeyPrefix: [
token.Type.ExchangeTokenBy,
token.Type.RevokeToken,
interaction.prefix,
LogKeyUnknown,
],
});
expect(findLogs).toHaveBeenCalledWith(5, 0, {
payload: { userId, applicationId },
logKey,
includeKeyPrefix: [
token.Type.ExchangeTokenBy,
token.Type.RevokeToken,
interaction.prefix,
LogKeyUnknown,
],
});
});
it('should return correct response', async () => {

View file

@ -1,15 +1,16 @@
import { Logs } from '@logto/schemas';
import { Logs, interaction, token, LogKeyUnknown } from '@logto/schemas';
import { object, string } from 'zod';
import koaGuard from '#src/middleware/koa-guard.js';
import koaPagination from '#src/middleware/koa-pagination.js';
import { type AllowedKeyPrefix } from '#src/queries/log.js';
import type { AuthedRouter, RouterInitArgs } from './types.js';
export default function logRoutes<T extends AuthedRouter>(
...[router, { queries }]: RouterInitArgs<T>
) {
const { countLogs, findLogById, findLogs } = queries.logs;
const { findLogById, countLogs, findLogs } = queries.logs;
router.get(
'/logs',
@ -29,10 +30,25 @@ export default function logRoutes<T extends AuthedRouter>(
query: { userId, applicationId, logKey },
} = ctx.guard;
const includeKeyPrefix: AllowedKeyPrefix[] = [
token.Type.ExchangeTokenBy,
token.Type.RevokeToken,
interaction.prefix,
LogKeyUnknown,
];
// TODO: @Gao refactor like user search
const [{ count }, logs] = await Promise.all([
countLogs({ logKey, applicationId, userId }),
findLogs(limit, offset, { logKey, userId, applicationId }),
countLogs({
logKey,
payload: { applicationId, userId },
includeKeyPrefix,
}),
findLogs(limit, offset, {
logKey,
payload: { userId, applicationId },
includeKeyPrefix,
}),
]);
// Return totalCount to pagination middleware

View file

@ -3,7 +3,12 @@ import { conditionalString } from '@silverhand/essentials';
import { authedAdminApi } from './api.js';
export const getLogs = async (params?: URLSearchParams) =>
export const getAuditLogs = async (params?: URLSearchParams) =>
authedAdminApi.get('logs?' + conditionalString(params?.toString())).json<Log[]>();
export const getWebhookRecentLogs = async (hookId: string, params?: URLSearchParams) =>
authedAdminApi
.get(`hooks/${hookId}/recent-logs?` + conditionalString(params?.toString()))
.json<Log[]>();
export const getLog = async (logId: string) => authedAdminApi.get(`logs/${logId}`).json<Log>();

View file

@ -3,7 +3,7 @@ import { assert } from '@silverhand/essentials';
import { deleteUser } from '#src/api/admin-user.js';
import { putInteraction } from '#src/api/interaction.js';
import { getLogs } from '#src/api/logs.js';
import { getAuditLogs } from '#src/api/logs.js';
import MockClient from '#src/client/index.js';
import { enableAllPasswordSignInMethods } from '#src/helpers/sign-in-experience.js';
import { generateNewUserProfile } from '#src/helpers/user.js';
@ -26,7 +26,7 @@ describe('audit logs for interaction', () => {
console.debug('Testing interaction', interactionId);
// Expect interaction create log
const createLogs = await getLogs(
const createLogs = await getAuditLogs(
new URLSearchParams({ logKey: `${interaction.prefix}.${interaction.Action.Create}` })
);
expect(createLogs.some((value) => value.payload.interactionId === interactionId)).toBeTruthy();
@ -43,7 +43,7 @@ describe('audit logs for interaction', () => {
await client.processSession(response.redirectTo);
// Expect interaction end log
const endLogs = await getLogs(
const endLogs = await getAuditLogs(
new URLSearchParams({ logKey: `${interaction.prefix}.${interaction.Action.End}` })
);
expect(endLogs.some((value) => value.payload.interactionId === interactionId)).toBeTruthy();

View file

@ -10,10 +10,11 @@ import {
type Log,
ConnectorType,
} from '@logto/schemas';
import { type Optional } from '@silverhand/essentials';
import { deleteUser } from '#src/api/admin-user.js';
import { authedAdminApi } from '#src/api/api.js';
import { getLogs } from '#src/api/logs.js';
import { getWebhookRecentLogs } from '#src/api/logs.js';
import {
clearConnectorsByTypes,
setEmailConnector,
@ -87,13 +88,14 @@ describe('trigger hooks', () => {
await signInWithPassword({ username, password });
// Check hook trigger log
const logs = await getLogs(new URLSearchParams({ logKey, page_size: '100' }));
const logs = await getWebhookRecentLogs(
createdHook.id,
new URLSearchParams({ logKey, page_size: '100' })
);
expect(
logs.some(
({ payload: { hookId, result, error } }) =>
hookId === createdHook.id &&
result === LogResult.Error &&
error === 'RequestError: Invalid URL'
({ payload: { result, error } }) =>
result === LogResult.Error && error === 'RequestError: Invalid URL'
)
).toBeTruthy();
@ -128,23 +130,23 @@ describe('trigger hooks', () => {
const userId = await registerNewUser(username, password);
// Check hook trigger log
const logs = await getLogs(new URLSearchParams({ logKey, page_size: '100' }));
expect(
logs.some(
({ payload: { hookId, result, error } }) =>
hookId === hook1.id && result === LogResult.Error && error === 'RequestError: Invalid URL'
)
).toBeTruthy();
expect(
logs.some(
({ payload: { hookId, result } }) => hookId === hook2.id && result === LogResult.Success
)
).toBeTruthy();
expect(
logs.some(
({ payload: { hookId, result } }) => hookId === hook3.id && result === LogResult.Success
)
).toBeTruthy();
for (const [hook, expectedResult, expectedError] of [
[hook1, LogResult.Error, 'RequestError: Invalid URL'],
[hook2, LogResult.Success, undefined],
[hook3, LogResult.Success, undefined],
] satisfies Array<[Hook, LogResult, Optional<string>]>) {
// eslint-disable-next-line no-await-in-loop
const logs = await getWebhookRecentLogs(
hook.id,
new URLSearchParams({ logKey, page_size: '100' })
);
expect(
logs.some(
({ payload: { result, error } }) =>
result === expectedResult && (!expectedError || error === expectedError)
)
).toBeTruthy();
}
// Clean up
await Promise.all([
@ -222,13 +224,15 @@ describe('trigger hooks', () => {
// Wait for the hook to be trigged
await waitFor(1000);
const logs = await getLogs(new URLSearchParams({ logKey, page_size: '100' }));
const relatedLogs = logs.filter(
({ payload: { hookId, result } }) =>
hookId === resetPasswordHook.id && result === LogResult.Success
const relatedLogs = await getWebhookRecentLogs(
resetPasswordHook.id,
new URLSearchParams({ logKey, page_size: '100' })
);
const succeedLogs = relatedLogs.filter(
({ payload: { result } }) => result === LogResult.Success
);
expect(relatedLogs).toHaveLength(2);
expect(succeedLogs).toHaveLength(2);
await authedAdminApi.delete(`hooks/${resetPasswordHook.id}`);
await deleteUser(user.id);

View file

@ -1,15 +1,15 @@
import { getLog, getLogs } from '#src/api/index.js';
import { getLog, getAuditLogs } from '#src/api/index.js';
import { createResponseWithCode } from '#src/helpers/admin-tenant.js';
describe('logs', () => {
it('should get logs successfully', async () => {
const logs = await getLogs();
const logs = await getAuditLogs();
expect(logs.length).toBeGreaterThan(0);
});
it('should get log detail successfully', async () => {
const logs = await getLogs();
const logs = await getAuditLogs();
const logId = logs[0]?.id;
expect(logId).not.toBeUndefined();

View file

@ -9,6 +9,9 @@ export * as hook from './hook.js';
/** Fallback for empty or unrecognized log keys. */
export const LogKeyUnknown = 'Unknown';
export type AuditLogKey = typeof LogKeyUnknown | interaction.LogKey | token.LogKey;
export type WebhookLogKey = hook.LogKey;
/**
* The union type of all available log keys.
* Note duplicate keys are allowed but should be avoided.
@ -16,4 +19,4 @@ export const LogKeyUnknown = 'Unknown';
* @see {@link interaction.LogKey} for interaction log keys.
* @see {@link token.LogKey} for token log keys.
**/
export type LogKey = typeof LogKeyUnknown | interaction.LogKey | token.LogKey | hook.LogKey;
export type LogKey = AuditLogKey | WebhookLogKey;