mirror of
https://github.com/bookorbit/bookorbit.git
synced 2026-11-03 00:11:37 -06:00
feat(audit): rebuild the audit log as a grouped ledger with a drawer
Changes, Sign-ins, Security and All views replace the filter form. Repeats fold into one row, sign-ins fold per person, day and network, and rows name their targets; events now snapshot the target's name so deleted ones keep it. The drawer adds the event's facts, the target's history, what the same person did around it, and one-click scoping. New additive routes: /audit-log/summary, /sign-ins and /export; retention is editable from the page.
This commit is contained in:
@@ -24,6 +24,7 @@ export const APP_SETTING_KEYS = {
|
||||
CROSS_PLATFORM_PATH_SANITIZATION_ENABLED: 'cross_platform_path_sanitization_enabled',
|
||||
METADATA_SCORE_WEIGHTS: 'metadata_score_weights',
|
||||
AUDIT_RETENTION_DAYS: 'audit_retention_days',
|
||||
AUDIT_TARGET_LABELS_BACKFILLED: 'audit_target_labels_backfilled',
|
||||
INITIAL_SETUP_COMPLETED_AT: 'initial_setup_completed_at',
|
||||
UPDATE_CHECK_ENABLED: 'update_check_enabled',
|
||||
MAX_UPLOAD_SIZE_MB: 'max_upload_size_mb',
|
||||
@@ -41,6 +42,7 @@ export const BOOK_DOCK_MANAGED_SETTING_KEYS: readonly string[] = [
|
||||
];
|
||||
|
||||
export const DEFAULT_AUDIT_RETENTION_DAYS = 90;
|
||||
export const MAX_AUDIT_RETENTION_DAYS = 3650;
|
||||
|
||||
export interface OidcFullConfig extends OidcBaseConfig {
|
||||
clientSecret: string;
|
||||
|
||||
@@ -147,4 +147,78 @@ describe('AuditInterceptor', () => {
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
describe('naming a target that is about to be deleted', () => {
|
||||
function setup(options: Record<string, unknown>, label: string | null = 'Old Shelf', body: unknown = undefined) {
|
||||
const reflector = { get: vi.fn().mockReturnValue(options) };
|
||||
const auditEvents = { emit: vi.fn() };
|
||||
const order: string[] = [];
|
||||
const targetLabels = {
|
||||
resolveForWrite: vi.fn(() => {
|
||||
order.push('label');
|
||||
return Promise.resolve(label);
|
||||
}),
|
||||
};
|
||||
const interceptor = new AuditInterceptor(reflector as never, auditEvents as never, targetLabels as never);
|
||||
const next: CallHandler = {
|
||||
handle: vi.fn(() => {
|
||||
order.push('handler');
|
||||
return of(body);
|
||||
}),
|
||||
};
|
||||
return { interceptor, next, auditEvents, targetLabels, order };
|
||||
}
|
||||
|
||||
it('reads the name before the handler runs and sends it with the event', async () => {
|
||||
const { interceptor, next, auditEvents, targetLabels, order } = setup({
|
||||
action: 'library.delete',
|
||||
resource: 'library',
|
||||
getResourceId: (req: { params: Record<string, string> }) => Number(req.params.id),
|
||||
description: 'Deleted library #88',
|
||||
});
|
||||
|
||||
await firstValueFrom(interceptor.intercept(makeContext({ ip: '::1', method: 'DELETE', params: { id: '88' } }), next), {
|
||||
defaultValue: undefined,
|
||||
});
|
||||
|
||||
expect(targetLabels.resolveForWrite).toHaveBeenCalledWith('library', 88);
|
||||
expect(order).toEqual(['label', 'handler']);
|
||||
expect(auditEvents.emit).toHaveBeenCalledWith(AUDIT_EVENT, expect.objectContaining({ resourceId: 88, resourceLabel: 'Old Shelf' }));
|
||||
});
|
||||
|
||||
it('leaves non-deleting writes to be named when the event is stored', async () => {
|
||||
const { interceptor, next, auditEvents, targetLabels } = setup({
|
||||
action: 'library.update',
|
||||
resource: 'library',
|
||||
getResourceId: (req: { params: Record<string, string> }) => Number(req.params.id),
|
||||
description: 'Updated library #88',
|
||||
});
|
||||
|
||||
await firstValueFrom(interceptor.intercept(makeContext({ ip: '::1', method: 'PATCH', params: { id: '88' } }), next), {
|
||||
defaultValue: undefined,
|
||||
});
|
||||
|
||||
expect(targetLabels.resolveForWrite).not.toHaveBeenCalled();
|
||||
expect(auditEvents.emit.mock.calls[0][1]).not.toHaveProperty('resourceLabel');
|
||||
});
|
||||
|
||||
it('carries on without a name when the id only exists in the response', async () => {
|
||||
const { interceptor, next, auditEvents, targetLabels } = setup(
|
||||
{
|
||||
action: 'collection.delete',
|
||||
resource: 'collection',
|
||||
getResourceId: (_req: unknown, res: { id: number }) => res.id,
|
||||
description: 'Deleted collection',
|
||||
},
|
||||
'Unused',
|
||||
{ id: 5 },
|
||||
);
|
||||
|
||||
await firstValueFrom(interceptor.intercept(makeContext({ ip: '::1', method: 'DELETE', params: {} }), next));
|
||||
|
||||
expect(targetLabels.resolveForWrite).not.toHaveBeenCalled();
|
||||
expect(auditEvents.emit).toHaveBeenCalledWith(AUDIT_EVENT, expect.objectContaining({ resourceId: 5 }));
|
||||
expect(auditEvents.emit.mock.calls[0][1]).not.toHaveProperty('resourceLabel');
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,18 +1,23 @@
|
||||
import { CallHandler, ExecutionContext, Injectable, NestInterceptor } from '@nestjs/common';
|
||||
import { CallHandler, ExecutionContext, Injectable, NestInterceptor, Optional } from '@nestjs/common';
|
||||
import { Reflector } from '@nestjs/core';
|
||||
import type { FastifyRequest } from 'fastify';
|
||||
import { tap } from 'rxjs/operators';
|
||||
import { from, of } from 'rxjs';
|
||||
import { switchMap, tap } from 'rxjs/operators';
|
||||
import type { Observable } from 'rxjs';
|
||||
|
||||
import type { RequestUser } from '../types/request-user';
|
||||
import { AUDITABLE_KEY, AuditableOptions, AuditRequest } from '../decorators/auditable.decorator';
|
||||
import { AUDIT_EVENT, AuditEventsService } from '../../modules/audit/audit-events.service';
|
||||
import { AuditTargetLabelService } from '../../modules/audit/audit-target-label.service';
|
||||
|
||||
const REMOVES_TARGET = /\.(delete|purge|remove)$/;
|
||||
|
||||
@Injectable()
|
||||
export class AuditInterceptor implements NestInterceptor {
|
||||
constructor(
|
||||
private readonly reflector: Reflector,
|
||||
private readonly auditEvents: AuditEventsService,
|
||||
@Optional() private readonly targetLabels?: AuditTargetLabelService,
|
||||
) {}
|
||||
|
||||
intercept(context: ExecutionContext, next: CallHandler): Observable<unknown> {
|
||||
@@ -21,25 +26,49 @@ export class AuditInterceptor implements NestInterceptor {
|
||||
|
||||
const req = context.switchToHttp().getRequest<FastifyRequest & { user?: RequestUser }>() as AuditRequest & { user?: RequestUser; ip: string };
|
||||
|
||||
return next.handle().pipe(
|
||||
tap((responseBody) => {
|
||||
if (options.when && !options.when(req, responseBody)) return;
|
||||
const user = req.user;
|
||||
const action = typeof options.action === 'function' ? options.action(req, responseBody) : options.action;
|
||||
const description = typeof options.description === 'function' ? options.description(req, responseBody) : options.description;
|
||||
const meta = options.getMeta?.(req, responseBody);
|
||||
// A delete leaves nothing to name afterwards, so its target's name is read before the handler runs.
|
||||
const before = this.removesTarget(req, options) ? from(this.labelBefore(req, options)) : of(undefined);
|
||||
|
||||
this.auditEvents.emit(AUDIT_EVENT, {
|
||||
userId: user?.id ?? null,
|
||||
actorUsername: user?.username ?? 'system',
|
||||
action,
|
||||
resource: options.resource,
|
||||
resourceId: options.getResourceId?.(req, responseBody),
|
||||
description,
|
||||
ip: req.ip,
|
||||
...(meta ? { meta } : {}),
|
||||
});
|
||||
}),
|
||||
return before.pipe(
|
||||
switchMap((resourceLabel) =>
|
||||
next.handle().pipe(
|
||||
tap((responseBody) => {
|
||||
if (options.when && !options.when(req, responseBody)) return;
|
||||
const user = req.user;
|
||||
const action = typeof options.action === 'function' ? options.action(req, responseBody) : options.action;
|
||||
const description = typeof options.description === 'function' ? options.description(req, responseBody) : options.description;
|
||||
const meta = options.getMeta?.(req, responseBody);
|
||||
|
||||
this.auditEvents.emit(AUDIT_EVENT, {
|
||||
userId: user?.id ?? null,
|
||||
actorUsername: user?.username ?? 'system',
|
||||
action,
|
||||
resource: options.resource,
|
||||
resourceId: options.getResourceId?.(req, responseBody),
|
||||
description,
|
||||
ip: req.ip,
|
||||
...(meta ? { meta } : {}),
|
||||
...(resourceLabel ? { resourceLabel } : {}),
|
||||
});
|
||||
}),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
private removesTarget(req: AuditRequest, options: AuditableOptions): boolean {
|
||||
if (!this.targetLabels || !options.resource || !options.getResourceId) return false;
|
||||
return req.method === 'DELETE' || (typeof options.action === 'string' && REMOVES_TARGET.test(options.action));
|
||||
}
|
||||
|
||||
private async labelBefore(req: AuditRequest, options: AuditableOptions): Promise<string | undefined> {
|
||||
let resourceId: number | undefined;
|
||||
try {
|
||||
resourceId = options.getResourceId?.(req, undefined);
|
||||
} catch {
|
||||
return undefined;
|
||||
}
|
||||
if (!Number.isInteger(resourceId)) return undefined;
|
||||
return (await this.targetLabels!.resolveForWrite(options.resource, resourceId)) ?? undefined;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,2 @@
|
||||
ALTER TABLE "audit_log" ADD COLUMN "resource_label" varchar(255);--> statement-breakpoint
|
||||
CREATE INDEX "idx_audit_resource_label_trgm" ON "audit_log" USING gin ("resource_label" gin_trgm_ops);
|
||||
File diff suppressed because it is too large
Load Diff
@@ -806,6 +806,13 @@
|
||||
"when": 1792163623360,
|
||||
"tag": "0114_add_book_metadata_page_count_index",
|
||||
"breakpoints": true
|
||||
},
|
||||
{
|
||||
"idx": 115,
|
||||
"version": "7",
|
||||
"when": 1792168359342,
|
||||
"tag": "0115_add_audit_resource_label",
|
||||
"breakpoints": true
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
@@ -12,6 +12,8 @@ export const auditLog = pgTable(
|
||||
action: varchar('action', { length: 100 }).notNull(),
|
||||
resource: varchar('resource', { length: 100 }),
|
||||
resourceId: integer('resource_id'),
|
||||
/** The target's name when the event was written, so a deleted target can still be named. */
|
||||
resourceLabel: varchar('resource_label', { length: 255 }),
|
||||
description: text('description').notNull(),
|
||||
ip: varchar('ip', { length: 45 }),
|
||||
meta: jsonb('meta'),
|
||||
@@ -27,6 +29,7 @@ export const auditLog = pgTable(
|
||||
index('idx_audit_action').on(t.action),
|
||||
index('idx_audit_action_trgm').using('gin', t.action.op('gin_trgm_ops')),
|
||||
index('idx_audit_description_trgm').using('gin', t.description.op('gin_trgm_ops')),
|
||||
index('idx_audit_resource_label_trgm').using('gin', t.resourceLabel.op('gin_trgm_ops')),
|
||||
index('idx_audit_meta_text_trgm').using('gin', sql`(${t.meta}::text) gin_trgm_ops`),
|
||||
index('idx_audit_ip').on(t.ip),
|
||||
index('idx_audit_created_at').on(t.createdAt),
|
||||
|
||||
@@ -657,4 +657,19 @@ describe('AppSettingsService', () => {
|
||||
expect(repo.updateByKey).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
describe('update audit_retention_days validation', () => {
|
||||
it('stores a whole number of days', async () => {
|
||||
repo.updateByKey.mockResolvedValue({ key: 'audit_retention_days', value: '365' } as never);
|
||||
await service.update('audit_retention_days', ' 365 ');
|
||||
expect(repo.updateByKey).toHaveBeenCalledWith('audit_retention_days', '365');
|
||||
});
|
||||
|
||||
it('rejects anything that is not between 1 and 3650 days', async () => {
|
||||
for (const value of ['0', '-1', '3651', '12.5', '90 days', 'forever']) {
|
||||
await expect(service.update('audit_retention_days', value)).rejects.toThrow(BadRequestException);
|
||||
}
|
||||
expect(repo.updateByKey).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -24,6 +24,7 @@ import {
|
||||
BOOK_DOCK_MANAGED_SETTING_KEYS,
|
||||
DEFAULT_LIBRARY_ACCESS_CONFIG,
|
||||
DEFAULT_OIDC_CONFIG,
|
||||
MAX_AUDIT_RETENTION_DAYS,
|
||||
type OidcFullConfig,
|
||||
} from '../../common/constants/app-settings.constants';
|
||||
import { ensureSafeUrl } from '../../common/utils/ssrf.utils';
|
||||
@@ -90,6 +91,13 @@ export class AppSettingsService {
|
||||
throw new BadRequestException(`Upload size limit must be an integer between 1 and ${HARD_MAX_UPLOAD_SIZE_MB}`);
|
||||
}
|
||||
}
|
||||
if (key === APP_SETTING_KEYS.AUDIT_RETENTION_DAYS) {
|
||||
const days = /^\d+$/.test(value.trim()) ? parseInt(value, 10) : NaN;
|
||||
if (!(days >= 1 && days <= MAX_AUDIT_RETENTION_DAYS)) {
|
||||
throw new BadRequestException(`Audit retention must be a whole number of days between 1 and ${MAX_AUDIT_RETENTION_DAYS}`);
|
||||
}
|
||||
value = String(days);
|
||||
}
|
||||
const setting = await this.repo.updateByKey(key, value);
|
||||
if (!setting) throw new NotFoundException(`Setting '${key}' not found`);
|
||||
this.clearRuntimeSettingCache(key);
|
||||
|
||||
@@ -0,0 +1,45 @@
|
||||
import {
|
||||
AUDIT_CATEGORY_PREFIXES,
|
||||
AUDIT_SECURITY_ACTIONS,
|
||||
AUDIT_SECURITY_GROUPS,
|
||||
AUDIT_SIGN_IN_ACTIONS,
|
||||
AuditAction,
|
||||
getAuditCategory,
|
||||
} from '@bookorbit/types';
|
||||
|
||||
// The shared definitions live in packages/types, whose own specs never run; they are covered here.
|
||||
describe('audit categories and views', () => {
|
||||
it('places every known action in a real category', () => {
|
||||
const unplaced = Object.values(AuditAction).filter((action) => getAuditCategory(action) === 'other');
|
||||
expect(unplaced).toEqual([]);
|
||||
});
|
||||
|
||||
it('gives requests, Book Dock and maintenance their own homes instead of "other"', () => {
|
||||
expect(getAuditCategory(AuditAction.BookRequestImport)).toBe('requests');
|
||||
expect(getAuditCategory(AuditAction.RequestIndexerPluginInstall)).toBe('requests');
|
||||
expect(getAuditCategory(AuditAction.BookDockFinalize)).toBe('books');
|
||||
expect(getAuditCategory(AuditAction.MaintenanceOrphanedCoversClean)).toBe('settings');
|
||||
});
|
||||
|
||||
it('degrades an action this build has never seen to "other"', () => {
|
||||
expect(getAuditCategory('club.create')).toBe('other');
|
||||
});
|
||||
|
||||
it('keeps prefixes from overlapping, so an action has exactly one category', () => {
|
||||
const prefixes = Object.values(AUDIT_CATEGORY_PREFIXES).flat();
|
||||
for (const action of Object.values(AuditAction)) {
|
||||
expect(prefixes.filter((prefix) => action.startsWith(prefix))).toHaveLength(1);
|
||||
}
|
||||
});
|
||||
|
||||
it('builds the Security view from groups that do not overlap', () => {
|
||||
const grouped = Object.values(AUDIT_SECURITY_GROUPS).flat();
|
||||
expect(new Set(grouped).size).toBe(grouped.length);
|
||||
expect([...AUDIT_SECURITY_ACTIONS].sort()).toEqual([...grouped].sort());
|
||||
});
|
||||
|
||||
it('counts a failed sign-in as both sign-in traffic and a security event', () => {
|
||||
expect(AUDIT_SIGN_IN_ACTIONS).toContain(AuditAction.AuthLoginFailed);
|
||||
expect(AUDIT_SECURITY_ACTIONS).toContain(AuditAction.AuthLoginFailed);
|
||||
});
|
||||
});
|
||||
@@ -14,6 +14,8 @@ export interface AuditEventPayload {
|
||||
description: string;
|
||||
ip?: string;
|
||||
meta?: Record<string, unknown>;
|
||||
/** The target's name, when the caller read it before the target went away. Looked up on write otherwise. */
|
||||
resourceLabel?: string;
|
||||
}
|
||||
|
||||
@Injectable()
|
||||
|
||||
@@ -0,0 +1,47 @@
|
||||
import { APP_SETTING_KEYS } from '../../common/constants/app-settings.constants';
|
||||
import { AuditLabelBackfillService } from './audit-label-backfill.service';
|
||||
|
||||
function makeService(flag: string | null = null) {
|
||||
const auditRepository = { backfillTargetLabels: vi.fn().mockResolvedValue({ fromLive: 12, fromHistory: 3 }) };
|
||||
const appSettings = { getValue: vi.fn().mockResolvedValue(flag), setValue: vi.fn().mockResolvedValue(undefined) };
|
||||
const service = new AuditLabelBackfillService(auditRepository as never, appSettings as never);
|
||||
const logger = (service as unknown as { logger: { log: (m: string) => void; warn: (m: string) => void } }).logger;
|
||||
return {
|
||||
service,
|
||||
auditRepository,
|
||||
appSettings,
|
||||
log: vi.spyOn(logger, 'log').mockImplementation(() => undefined),
|
||||
warn: vi.spyOn(logger, 'warn').mockImplementation(() => undefined),
|
||||
};
|
||||
}
|
||||
|
||||
describe('AuditLabelBackfillService', () => {
|
||||
it('names old rows once and records that it finished', async () => {
|
||||
const { service, auditRepository, appSettings, log } = makeService();
|
||||
|
||||
await service.onApplicationBootstrap();
|
||||
|
||||
expect(auditRepository.backfillTargetLabels).toHaveBeenCalledTimes(1);
|
||||
expect(appSettings.setValue).toHaveBeenCalledWith(APP_SETTING_KEYS.AUDIT_TARGET_LABELS_BACKFILLED, expect.any(String));
|
||||
expect(log).toHaveBeenCalledWith(expect.stringContaining('fromLive=12 fromHistory=3'));
|
||||
});
|
||||
|
||||
it('does nothing once the flag is set', async () => {
|
||||
const { service, auditRepository, appSettings } = makeService('2026-10-16T00:00:00.000Z');
|
||||
|
||||
await service.onApplicationBootstrap();
|
||||
|
||||
expect(auditRepository.backfillTargetLabels).not.toHaveBeenCalled();
|
||||
expect(appSettings.setValue).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('logs a failure without blocking startup, and leaves the flag unset so the next boot retries', async () => {
|
||||
const { service, auditRepository, appSettings, warn } = makeService();
|
||||
auditRepository.backfillTargetLabels.mockRejectedValue(new Error('statement timeout'));
|
||||
|
||||
await expect(service.onApplicationBootstrap()).resolves.toBeUndefined();
|
||||
|
||||
expect(appSettings.setValue).not.toHaveBeenCalled();
|
||||
expect(warn).toHaveBeenCalledWith(expect.stringContaining('[audit.label_backfill] [fail]'));
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,43 @@
|
||||
import { Injectable, Logger, OnApplicationBootstrap } from '@nestjs/common';
|
||||
|
||||
import { APP_SETTING_KEYS } from '../../common/constants/app-settings.constants';
|
||||
import { sanitizeLogValue } from '../../common/utils/log-sanitize.utils';
|
||||
import { AppSettingsService } from '../app-settings/app-settings.service';
|
||||
import { AuditRepository } from './audit.repository';
|
||||
|
||||
/**
|
||||
* Names the audit rows written before every event snapshotted its target, so the log can show
|
||||
* "Novels" instead of "Library #3", and still name a library that has since been deleted.
|
||||
*
|
||||
* Runs once per database: a flag records that it finished. A failure is logged and swallowed,
|
||||
* because unnamed rows still read as "Library #3", and the next boot tries again.
|
||||
*/
|
||||
@Injectable()
|
||||
export class AuditLabelBackfillService implements OnApplicationBootstrap {
|
||||
private readonly logger = new Logger(AuditLabelBackfillService.name);
|
||||
|
||||
constructor(
|
||||
private readonly auditRepository: AuditRepository,
|
||||
private readonly appSettings: AppSettingsService,
|
||||
) {}
|
||||
|
||||
async onApplicationBootstrap(): Promise<void> {
|
||||
const event = 'audit.label_backfill';
|
||||
const startedAt = Date.now();
|
||||
try {
|
||||
if (await this.appSettings.getValue(APP_SETTING_KEYS.AUDIT_TARGET_LABELS_BACKFILLED)) return;
|
||||
this.logger.log(`[${event}] [start] - naming audit targets recorded before label snapshots`);
|
||||
const { fromLive, fromHistory } = await this.auditRepository.backfillTargetLabels();
|
||||
await this.appSettings.setValue(APP_SETTING_KEYS.AUDIT_TARGET_LABELS_BACKFILLED, new Date().toISOString());
|
||||
this.logger.log(
|
||||
`[${event}] [end] durationMs=${Date.now() - startedAt} fromLive=${fromLive} fromHistory=${fromHistory} - audit target names backfilled`,
|
||||
);
|
||||
} catch (err) {
|
||||
const errorClass = err instanceof Error ? err.name : 'Error';
|
||||
const errorMessage = sanitizeLogValue(err instanceof Error ? err.message : String(err));
|
||||
this.logger.warn(
|
||||
`[${event}] [fail] durationMs=${Date.now() - startedAt} errorClass=${errorClass} error="${errorMessage}" - audit target name backfill failed`,
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,96 @@
|
||||
import { AuditAction, AuditResource } from '@bookorbit/types';
|
||||
|
||||
import { AuditTargetLabelService } from './audit-target-label.service';
|
||||
|
||||
function row(overrides: Record<string, unknown>) {
|
||||
return {
|
||||
id: 1,
|
||||
userId: 1,
|
||||
actorUsername: 'neon',
|
||||
action: AuditAction.LibraryUpdate,
|
||||
resource: AuditResource.Library,
|
||||
resourceId: 88,
|
||||
resourceLabel: null,
|
||||
description: 'Updated library #88',
|
||||
ip: '127.0.0.1',
|
||||
meta: null,
|
||||
createdAt: new Date('2026-10-14T22:37:27Z'),
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
function makeService(live: Record<string, string> = {}, snapshots: Record<string, string> = {}) {
|
||||
const repository = {
|
||||
findLiveLabels: vi.fn(() => Promise.resolve(new Map(Object.entries(live)))),
|
||||
findSnapshotLabels: vi.fn(() => Promise.resolve(new Map(Object.entries(snapshots)))),
|
||||
};
|
||||
return { service: new AuditTargetLabelService(repository as never), repository };
|
||||
}
|
||||
|
||||
describe('AuditTargetLabelService', () => {
|
||||
it('names a target that still exists by its current name', async () => {
|
||||
const { service, repository } = makeService({ 'library:88': 'Novels', 'user:1': 'Neon' });
|
||||
|
||||
const [result] = await service.attach([row({ resourceLabel: 'Old name' })] as never);
|
||||
|
||||
expect(result).toMatchObject({ actorName: 'Neon', targetLabel: 'Novels', targetExists: true });
|
||||
expect(result).not.toHaveProperty('resourceLabel');
|
||||
expect(repository.findSnapshotLabels).toHaveBeenCalledWith([]);
|
||||
});
|
||||
|
||||
it('falls back to the row snapshot, then to the newest snapshot for the same target', async () => {
|
||||
const { service, repository } = makeService({}, { 'library:88': 'KOReader Read-along Validation' });
|
||||
|
||||
const [own, borrowed] = await service.attach([
|
||||
row({ id: 2, resourceLabel: 'Snapshot on this row' }),
|
||||
row({ id: 3, action: AuditAction.LibraryDelete }),
|
||||
] as never);
|
||||
|
||||
expect(own).toMatchObject({ targetLabel: 'Snapshot on this row', targetExists: false });
|
||||
expect(borrowed).toMatchObject({ targetLabel: 'KOReader Read-along Validation', targetExists: false });
|
||||
expect(repository.findLiveLabels).toHaveBeenCalledWith([
|
||||
{ resource: AuditResource.Library, resourceId: 88 },
|
||||
{ resource: AuditResource.User, resourceId: 1 },
|
||||
]);
|
||||
expect(repository.findSnapshotLabels).toHaveBeenCalledWith([{ resource: AuditResource.Library, resourceId: 88 }]);
|
||||
});
|
||||
|
||||
it('leaves targets with nothing to look up alone', async () => {
|
||||
const { service, repository } = makeService();
|
||||
|
||||
const [settings, signIn] = await service.attach([
|
||||
row({ resource: AuditResource.AppSettings, resourceId: null, resourceLabel: null, userId: null, actorUsername: 'system' }),
|
||||
row({ action: AuditAction.AuthLogin, resource: null, resourceId: null, userId: null, actorUsername: 'livetest' }),
|
||||
] as never);
|
||||
|
||||
expect(settings).toMatchObject({ actorName: null, targetLabel: null, targetExists: null });
|
||||
expect(signIn).toMatchObject({ actorName: null, targetLabel: null, targetExists: null });
|
||||
expect(repository.findLiveLabels).toHaveBeenCalledWith([]);
|
||||
expect(repository.findSnapshotLabels).toHaveBeenCalledWith([]);
|
||||
});
|
||||
|
||||
it('records no name when the lookup fails rather than failing the write', async () => {
|
||||
const { service, repository } = makeService();
|
||||
repository.findLiveLabels.mockRejectedValueOnce(new Error('connection reset'));
|
||||
const warn = vi.spyOn((service as unknown as { logger: { warn: (m: string) => void } }).logger, 'warn').mockImplementation(() => undefined);
|
||||
|
||||
await expect(service.resolveForWrite(AuditResource.Library, 88)).resolves.toBeNull();
|
||||
expect(warn).toHaveBeenCalledWith(expect.stringContaining('[audit.target_label] [fail]'));
|
||||
});
|
||||
|
||||
it('names people, leaving out accounts that no longer exist', async () => {
|
||||
const { service } = makeService({ 'user:1': 'Neon' });
|
||||
|
||||
const names = await service.actorNames([1, 41, null, 1]);
|
||||
|
||||
expect([...names]).toEqual([[1, 'Neon']]);
|
||||
});
|
||||
|
||||
it('skips the lookup for target types without names', async () => {
|
||||
const { service, repository } = makeService();
|
||||
|
||||
await expect(service.resolveForWrite(AuditResource.AppSettings, 1)).resolves.toBeNull();
|
||||
await expect(service.resolveForWrite(AuditResource.Library, undefined)).resolves.toBeNull();
|
||||
expect(repository.findLiveLabels).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,80 @@
|
||||
import { Injectable, Logger } from '@nestjs/common';
|
||||
import { AuditResource } from '@bookorbit/types';
|
||||
|
||||
import type { AuditLog } from '../../db/schema';
|
||||
import { sanitizeLogValue } from '../../common/utils/log-sanitize.utils';
|
||||
import { AuditRepository, hasTargetLabels, targetKey, type AuditTargetRef } from './audit.repository';
|
||||
|
||||
const LABEL_MAX_LENGTH = 255;
|
||||
|
||||
export type AuditLogRow = Omit<AuditLog, 'resourceLabel'> & { actorName: string | null; targetLabel: string | null; targetExists: boolean | null };
|
||||
|
||||
/**
|
||||
* Names audit targets. A target that still exists shows its current name; one that is gone shows the
|
||||
* last name the log recorded for it, which is why every event snapshots its target's name on write.
|
||||
*/
|
||||
@Injectable()
|
||||
export class AuditTargetLabelService {
|
||||
private readonly logger = new Logger(AuditTargetLabelService.name);
|
||||
|
||||
constructor(private readonly repository: AuditRepository) {}
|
||||
|
||||
async resolveForWrite(resource: string | null | undefined, resourceId: number | null | undefined): Promise<string | null> {
|
||||
if (!hasTargetLabels(resource) || !Number.isInteger(resourceId)) return null;
|
||||
try {
|
||||
const labels = await this.repository.findLiveLabels([{ resource, resourceId: resourceId! }]);
|
||||
return labels.get(targetKey(resource, resourceId!))?.slice(0, LABEL_MAX_LENGTH) ?? null;
|
||||
} catch (err: unknown) {
|
||||
const errorClass = err instanceof Error ? err.constructor.name : 'Error';
|
||||
const error = err instanceof Error ? err.message : String(err);
|
||||
this.logger.warn(
|
||||
`[audit.target_label] [fail] resource=${resource} resourceId=${resourceId} errorClass=${errorClass} error="${sanitizeLogValue(error)}" - target name not recorded`,
|
||||
);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/** Display names for the people behind events; accounts that no longer exist are simply absent. */
|
||||
async actorNames(userIds: Array<number | null>): Promise<Map<number, string>> {
|
||||
const ids = [...new Set(userIds.filter((id): id is number => id !== null))];
|
||||
const labels = await this.repository.findLiveLabels(ids.map((id) => ({ resource: AuditResource.User, resourceId: id })));
|
||||
return new Map(
|
||||
ids.flatMap((id) => (labels.has(targetKey(AuditResource.User, id)) ? [[id, labels.get(targetKey(AuditResource.User, id))!] as const] : [])),
|
||||
);
|
||||
}
|
||||
|
||||
async attach(rows: AuditLog[]): Promise<AuditLogRow[]> {
|
||||
const seen = new Set<string>();
|
||||
const targetKeys = new Set<string>();
|
||||
const lookups: AuditTargetRef[] = [];
|
||||
const want = (resource: string, resourceId: number) => {
|
||||
const key = targetKey(resource, resourceId);
|
||||
if (seen.has(key)) return;
|
||||
seen.add(key);
|
||||
lookups.push({ resource, resourceId });
|
||||
};
|
||||
for (const row of rows) {
|
||||
if (hasTargetLabels(row.resource) && row.resourceId !== null) {
|
||||
want(row.resource, row.resourceId);
|
||||
targetKeys.add(targetKey(row.resource, row.resourceId));
|
||||
}
|
||||
if (row.userId !== null) want(AuditResource.User, row.userId);
|
||||
}
|
||||
// One lookup covers both the targets and the people, since people are user targets too.
|
||||
const live = await this.repository.findLiveLabels(lookups);
|
||||
const missing = lookups.filter(
|
||||
(ref) => targetKeys.has(targetKey(ref.resource, ref.resourceId)) && !live.has(targetKey(ref.resource, ref.resourceId)),
|
||||
);
|
||||
const snapshots = await this.repository.findSnapshotLabels(missing);
|
||||
|
||||
return rows.map(({ resourceLabel, ...row }) => {
|
||||
const actorName = row.userId !== null ? (live.get(targetKey(AuditResource.User, row.userId)) ?? null) : null;
|
||||
if (!row.resource || row.resourceId === null) return { ...row, actorName, targetLabel: resourceLabel ?? null, targetExists: null };
|
||||
if (!hasTargetLabels(row.resource)) return { ...row, actorName, targetLabel: resourceLabel ?? null, targetExists: null };
|
||||
const key = targetKey(row.resource, row.resourceId);
|
||||
const current = live.get(key);
|
||||
if (current) return { ...row, actorName, targetLabel: current, targetExists: true };
|
||||
return { ...row, actorName, targetLabel: resourceLabel ?? snapshots.get(key) ?? null, targetExists: false };
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -74,4 +74,60 @@ describe('AuditController', () => {
|
||||
await expect(controller.getActors({ q: 'read', limit: 20 })).resolves.toEqual(actors);
|
||||
expect(auditService.getActors).toHaveBeenCalledWith('read', 20);
|
||||
});
|
||||
|
||||
it('passes the view, category and target filters through to the list and the summary', async () => {
|
||||
const auditService = {
|
||||
getAuditLogs: vi.fn().mockResolvedValue({ data: [], total: 0 }),
|
||||
getSummary: vi.fn().mockResolvedValue({}),
|
||||
};
|
||||
const controller = new AuditController(auditService as never);
|
||||
const filters = { kind: 'security' as const, category: 'users' as const, securityGroup: 'permissions' as const, resourceId: 40, ip: '127.0.0.1' };
|
||||
|
||||
await controller.getAuditLogs({ ...filters, dateFrom: '2026-10-10T06:00:00.000Z', page: 1, pageSize: 50 });
|
||||
await controller.getSummary({ ...filters });
|
||||
|
||||
expect(auditService.getAuditLogs).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ ...filters, dateFrom: new Date('2026-10-10T06:00:00.000Z'), page: 1, pageSize: 50 }),
|
||||
);
|
||||
expect(auditService.getSummary).toHaveBeenCalledWith(expect.objectContaining(filters));
|
||||
});
|
||||
|
||||
it('groups sign-ins in the requested time zone', async () => {
|
||||
const auditService = { getSignIns: vi.fn().mockResolvedValue({ data: [], total: 0 }) };
|
||||
const controller = new AuditController(auditService as never);
|
||||
|
||||
await controller.getSignIns({ tz: 'America/Denver', actorUsername: 'test1', dateTo: '2026-10-16T09:12:00.000Z', page: 2, pageSize: 25 });
|
||||
|
||||
expect(auditService.getSignIns).toHaveBeenCalledWith({
|
||||
search: undefined,
|
||||
actorUsername: 'test1',
|
||||
ip: undefined,
|
||||
dateFrom: undefined,
|
||||
dateTo: new Date('2026-10-16T09:12:00.000Z'),
|
||||
tz: 'America/Denver',
|
||||
page: 2,
|
||||
pageSize: 25,
|
||||
});
|
||||
});
|
||||
|
||||
it('streams the export as a dated attachment', () => {
|
||||
function* rows() {
|
||||
yield 'id\n';
|
||||
}
|
||||
const auditService = { exportEvents: vi.fn().mockReturnValue(rows()) };
|
||||
const controller = new AuditController(auditService as never);
|
||||
const reply = { type: vi.fn(), header: vi.fn(), send: vi.fn() };
|
||||
reply.type.mockReturnValue(reply);
|
||||
reply.header.mockReturnValue(reply);
|
||||
|
||||
controller.exportEvents({ format: 'json', kind: 'changes' }, reply as never);
|
||||
|
||||
expect(auditService.exportEvents).toHaveBeenCalledWith(expect.objectContaining({ kind: 'changes' }), 'json');
|
||||
expect(reply.type).toHaveBeenCalledWith('application/json; charset=utf-8');
|
||||
expect(reply.header).toHaveBeenCalledWith(
|
||||
'Content-Disposition',
|
||||
expect.stringMatching(/attachment; filename="audit-log-\d{4}-\d{2}-\d{2}\.json"/),
|
||||
);
|
||||
expect(reply.send).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,9 +1,29 @@
|
||||
import { Controller, Get, Query } from '@nestjs/common';
|
||||
import { Controller, Get, Query, Res } from '@nestjs/common';
|
||||
import { Readable } from 'stream';
|
||||
import type { FastifyReply } from 'fastify';
|
||||
import { Permission } from '@bookorbit/types';
|
||||
|
||||
import { RequirePermission } from '../../common/decorators/require-permission.decorator';
|
||||
import { AuditActorQueryDto, AuditLogQueryDto } from './dto/audit-log-query.dto';
|
||||
import { AuditService } from './audit.service';
|
||||
import { contentDispositionHeader } from '../../common/utils/content-disposition.utils';
|
||||
import { AuditActorQueryDto, AuditExportQueryDto, AuditFilterQueryDto, AuditLogQueryDto, AuditSignInQueryDto } from './dto/audit-log-query.dto';
|
||||
import { AuditService, type AuditLogFilters } from './audit.service';
|
||||
|
||||
function toFilters(query: AuditFilterQueryDto): AuditLogFilters {
|
||||
return {
|
||||
userId: query.userId,
|
||||
search: query.search,
|
||||
actorUsername: query.actorUsername,
|
||||
action: query.action,
|
||||
resource: query.resource,
|
||||
resourceId: query.resourceId,
|
||||
ip: query.ip,
|
||||
kind: query.kind,
|
||||
category: query.category,
|
||||
securityGroup: query.securityGroup,
|
||||
dateFrom: query.dateFrom ? new Date(query.dateFrom) : undefined,
|
||||
dateTo: query.dateTo ? new Date(query.dateTo) : undefined,
|
||||
};
|
||||
}
|
||||
|
||||
@Controller('audit-log')
|
||||
export class AuditController {
|
||||
@@ -15,17 +35,43 @@ export class AuditController {
|
||||
return this.auditService.getActors(query.q, query.limit!);
|
||||
}
|
||||
|
||||
@Get('summary')
|
||||
@RequirePermission(Permission.ViewAuditLog)
|
||||
getSummary(@Query() query: AuditFilterQueryDto) {
|
||||
return this.auditService.getSummary(toFilters(query));
|
||||
}
|
||||
|
||||
@Get('sign-ins')
|
||||
@RequirePermission(Permission.ViewAuditLog)
|
||||
getSignIns(@Query() query: AuditSignInQueryDto) {
|
||||
return this.auditService.getSignIns({
|
||||
search: query.search,
|
||||
actorUsername: query.actorUsername,
|
||||
ip: query.ip,
|
||||
dateFrom: query.dateFrom ? new Date(query.dateFrom) : undefined,
|
||||
dateTo: query.dateTo ? new Date(query.dateTo) : undefined,
|
||||
tz: query.tz!,
|
||||
page: query.page!,
|
||||
pageSize: query.pageSize!,
|
||||
});
|
||||
}
|
||||
|
||||
@Get('export')
|
||||
@RequirePermission(Permission.ViewAuditLog)
|
||||
exportEvents(@Query() query: AuditExportQueryDto, @Res() reply: FastifyReply) {
|
||||
const format = query.format ?? 'csv';
|
||||
const filename = `audit-log-${new Date().toISOString().slice(0, 10)}.${format}`;
|
||||
reply.type(format === 'csv' ? 'text/csv; charset=utf-8' : 'application/json; charset=utf-8');
|
||||
reply.header('Content-Disposition', contentDispositionHeader('attachment', filename, filename));
|
||||
reply.header('Cache-Control', 'no-store');
|
||||
reply.send(Readable.from(this.auditService.exportEvents(toFilters(query), format)));
|
||||
}
|
||||
|
||||
@Get()
|
||||
@RequirePermission(Permission.ViewAuditLog)
|
||||
getAuditLogs(@Query() query: AuditLogQueryDto) {
|
||||
return this.auditService.getAuditLogs({
|
||||
userId: query.userId,
|
||||
search: query.search,
|
||||
actorUsername: query.actorUsername,
|
||||
action: query.action,
|
||||
resource: query.resource,
|
||||
dateFrom: query.dateFrom ? new Date(query.dateFrom) : undefined,
|
||||
dateTo: query.dateTo ? new Date(query.dateTo) : undefined,
|
||||
...toFilters(query),
|
||||
page: query.page!,
|
||||
pageSize: query.pageSize!,
|
||||
});
|
||||
|
||||
@@ -6,11 +6,13 @@ import { AuditController } from './audit.controller';
|
||||
import { AuditEventsService } from './audit-events.service';
|
||||
import { AuditRepository } from './audit.repository';
|
||||
import { AuditService } from './audit.service';
|
||||
import { AuditTargetLabelService } from './audit-target-label.service';
|
||||
import { AuditLabelBackfillService } from './audit-label-backfill.service';
|
||||
|
||||
@Module({
|
||||
imports: [AppSettingsModule],
|
||||
controllers: [AuditController],
|
||||
providers: [AuditEventsService, AuditRepository, AuditService, AuditCleanupJob],
|
||||
exports: [AuditEventsService, AuditService],
|
||||
providers: [AuditEventsService, AuditRepository, AuditService, AuditTargetLabelService, AuditLabelBackfillService, AuditCleanupJob],
|
||||
exports: [AuditEventsService, AuditService, AuditTargetLabelService],
|
||||
})
|
||||
export class AuditModule {}
|
||||
|
||||
@@ -1,7 +1,19 @@
|
||||
import { Inject, Injectable } from '@nestjs/common';
|
||||
import { and, asc, count, desc, eq, gte, ilike, lte, or, sql, type SQL } from 'drizzle-orm';
|
||||
import { and, asc, count, desc, eq, gte, ilike, inArray, isNotNull, like, lte, not, notInArray, or, sql, type SQL } from 'drizzle-orm';
|
||||
import type { NodePgDatabase } from 'drizzle-orm/node-postgres';
|
||||
import { AuditAction, AuditResource } from '@bookorbit/types';
|
||||
import type { PgColumn, PgTable } from 'drizzle-orm/pg-core';
|
||||
import {
|
||||
AUDIT_CATEGORY_PREFIXES,
|
||||
AUDIT_SECURITY_ACTIONS,
|
||||
AUDIT_SECURITY_GROUP_KEYS,
|
||||
AUDIT_SECURITY_GROUPS,
|
||||
AUDIT_SIGN_IN_ACTIONS,
|
||||
AuditAction,
|
||||
AuditResource,
|
||||
type AuditCategory,
|
||||
type AuditLogKind,
|
||||
type AuditSecurityGroup,
|
||||
} from '@bookorbit/types';
|
||||
|
||||
import { DB } from '../../db';
|
||||
import * as schema from '../../db/schema';
|
||||
@@ -9,106 +21,368 @@ import type { NewAuditLog } from '../../db/schema';
|
||||
|
||||
type Db = NodePgDatabase<typeof schema>;
|
||||
|
||||
export interface AuditLogQuery {
|
||||
export interface AuditLogFilters {
|
||||
userId?: number;
|
||||
search?: string;
|
||||
actorUsername?: string;
|
||||
action?: AuditAction;
|
||||
resource?: AuditResource;
|
||||
resourceId?: number;
|
||||
ip?: string;
|
||||
kind?: AuditLogKind;
|
||||
category?: AuditCategory;
|
||||
securityGroup?: AuditSecurityGroup;
|
||||
dateFrom?: Date;
|
||||
dateTo?: Date;
|
||||
}
|
||||
|
||||
export interface AuditLogQuery extends AuditLogFilters {
|
||||
page: number;
|
||||
pageSize: number;
|
||||
}
|
||||
|
||||
export interface AuditSignInQuery {
|
||||
search?: string;
|
||||
actorUsername?: string;
|
||||
ip?: string;
|
||||
dateFrom?: Date;
|
||||
dateTo?: Date;
|
||||
tz: string;
|
||||
page: number;
|
||||
pageSize: number;
|
||||
}
|
||||
|
||||
export interface AuditTargetRef {
|
||||
resource: string;
|
||||
resourceId: number;
|
||||
}
|
||||
|
||||
const log = schema.auditLog;
|
||||
const SIGN_IN_ACTIONS = [...AUDIT_SIGN_IN_ACTIONS];
|
||||
const SECURITY_ACTIONS = [...AUDIT_SECURITY_ACTIONS];
|
||||
const SUCCESSFUL_SIGN_INS = [AuditAction.AuthLogin, AuditAction.OidcLogin, AuditAction.MagicLinkLogin];
|
||||
|
||||
/** Where each target type keeps its human name. Types missing here have nothing to look up. */
|
||||
const TARGET_LABELS: Partial<Record<AuditResource, { table: PgTable; id: PgColumn; label: SQL }>> = {
|
||||
[AuditResource.Library]: { table: schema.libraries, id: schema.libraries.id, label: sql`${schema.libraries.name}` },
|
||||
[AuditResource.User]: {
|
||||
table: schema.users,
|
||||
id: schema.users.id,
|
||||
label: sql`coalesce(nullif(${schema.users.name}, ''), ${schema.users.username})`,
|
||||
},
|
||||
[AuditResource.ReadingInsightsProfile]: {
|
||||
table: schema.users,
|
||||
id: schema.users.id,
|
||||
label: sql`coalesce(nullif(${schema.users.name}, ''), ${schema.users.username})`,
|
||||
},
|
||||
[AuditResource.Book]: { table: schema.bookMetadata, id: schema.bookMetadata.bookId, label: sql`${schema.bookMetadata.title}` },
|
||||
[AuditResource.BookRequest]: { table: schema.bookRequests, id: schema.bookRequests.id, label: sql`${schema.bookRequests.title}` },
|
||||
[AuditResource.Collection]: { table: schema.collections, id: schema.collections.id, label: sql`${schema.collections.name}` },
|
||||
[AuditResource.SmartScope]: { table: schema.smartScopes, id: schema.smartScopes.id, label: sql`${schema.smartScopes.name}` },
|
||||
[AuditResource.DownloadClient]: { table: schema.downloadClients, id: schema.downloadClients.id, label: sql`${schema.downloadClients.name}` },
|
||||
[AuditResource.RequestIndexer]: { table: schema.requestIndexers, id: schema.requestIndexers.id, label: sql`${schema.requestIndexers.name}` },
|
||||
[AuditResource.KoboDevice]: { table: schema.koboDevices, id: schema.koboDevices.id, label: sql`${schema.koboDevices.name}` },
|
||||
[AuditResource.EmailProvider]: { table: schema.emailProviders, id: schema.emailProviders.id, label: sql`${schema.emailProviders.name}` },
|
||||
[AuditResource.EmailTemplate]: { table: schema.emailTemplates, id: schema.emailTemplates.id, label: sql`${schema.emailTemplates.name}` },
|
||||
[AuditResource.EmailRecipient]: { table: schema.emailRecipients, id: schema.emailRecipients.id, label: sql`${schema.emailRecipients.name}` },
|
||||
[AuditResource.EmailRecipientGroup]: {
|
||||
table: schema.emailRecipientGroups,
|
||||
id: schema.emailRecipientGroups.id,
|
||||
label: sql`${schema.emailRecipientGroups.name}`,
|
||||
},
|
||||
[AuditResource.Author]: { table: schema.authors, id: schema.authors.id, label: sql`${schema.authors.name}` },
|
||||
[AuditResource.Podcast]: { table: schema.podcasts, id: schema.podcasts.id, label: sql`${schema.podcasts.title}` },
|
||||
};
|
||||
|
||||
export function hasTargetLabels(resource: string | null | undefined): resource is AuditResource {
|
||||
return !!resource && resource in TARGET_LABELS;
|
||||
}
|
||||
|
||||
export const targetKey = (resource: string, resourceId: number) => `${resource}:${resourceId}`;
|
||||
|
||||
function categoryCondition(category: AuditCategory): SQL {
|
||||
const prefixed = (prefixes: readonly string[]) => or(...prefixes.map((prefix) => like(log.action, `${escapeLike(prefix)}%`))) as SQL;
|
||||
if (category !== 'other') return prefixed(AUDIT_CATEGORY_PREFIXES[category]);
|
||||
return not(prefixed(Object.values(AUDIT_CATEGORY_PREFIXES).flat()));
|
||||
}
|
||||
|
||||
/** The category an action belongs to, computed in SQL so counts group without loading rows. */
|
||||
const categoryExpression = sql.join(
|
||||
[
|
||||
sql`case`,
|
||||
...Object.entries(AUDIT_CATEGORY_PREFIXES).map(
|
||||
([category, prefixes]) => sql`when ${or(...prefixes.map((prefix) => like(log.action, `${escapeLike(prefix)}%`)))} then ${category}`,
|
||||
),
|
||||
sql`else 'other' end`,
|
||||
],
|
||||
sql` `,
|
||||
);
|
||||
|
||||
function kindCondition(kind: AuditLogKind): SQL {
|
||||
if (kind === 'changes') return notInArray(log.action, SIGN_IN_ACTIONS);
|
||||
if (kind === 'signins') return inArray(log.action, SIGN_IN_ACTIONS);
|
||||
return inArray(log.action, SECURITY_ACTIONS);
|
||||
}
|
||||
|
||||
function searchCondition(search: string | undefined): SQL | undefined {
|
||||
const normalized = search?.trim();
|
||||
if (!normalized) return undefined;
|
||||
const pattern = `%${escapeLike(normalized)}%`;
|
||||
const numeric = /^\d+$/.test(normalized) ? Number(normalized) : undefined;
|
||||
// Every branch has an index (trigram, btree or primary key), so Postgres can OR them without a table scan.
|
||||
return or(
|
||||
ilike(log.actorUsername, pattern),
|
||||
ilike(log.action, pattern),
|
||||
ilike(log.description, pattern),
|
||||
ilike(log.resource, pattern),
|
||||
ilike(log.resourceLabel, pattern),
|
||||
/^[0-9a-f:.]+$/i.test(normalized) && /[.:]/.test(normalized) ? eq(log.ip, normalized) : undefined,
|
||||
numeric !== undefined && numeric <= 2147483647 ? eq(log.resourceId, numeric) : undefined,
|
||||
numeric !== undefined ? eq(log.id, numeric) : undefined,
|
||||
sql`${log.meta}::text ILIKE ${pattern}`,
|
||||
);
|
||||
}
|
||||
|
||||
function filterConditions(filters: AuditLogFilters, omit: { kind?: boolean; category?: boolean; securityGroup?: boolean } = {}): SQL[] {
|
||||
const { userId, search, actorUsername, action, resource, resourceId, ip, kind, category, securityGroup, dateFrom, dateTo } = filters;
|
||||
return [
|
||||
userId !== undefined ? eq(log.userId, userId) : undefined,
|
||||
actorUsername?.trim() ? eq(log.actorUsername, actorUsername.trim()) : undefined,
|
||||
action ? eq(log.action, action) : undefined,
|
||||
resource ? eq(log.resource, resource) : undefined,
|
||||
resourceId !== undefined ? eq(log.resourceId, resourceId) : undefined,
|
||||
ip?.trim() ? eq(log.ip, ip.trim()) : undefined,
|
||||
kind && !omit.kind ? kindCondition(kind) : undefined,
|
||||
category && !omit.category ? categoryCondition(category) : undefined,
|
||||
securityGroup && !omit.securityGroup ? inArray(log.action, [...AUDIT_SECURITY_GROUPS[securityGroup]]) : undefined,
|
||||
dateFrom ? gte(log.createdAt, dateFrom) : undefined,
|
||||
dateTo ? lte(log.createdAt, dateTo) : undefined,
|
||||
searchCondition(search),
|
||||
].filter((condition): condition is SQL => condition !== undefined);
|
||||
}
|
||||
|
||||
const whereOf = (conditions: SQL[]) => (conditions.length > 0 ? and(...conditions) : undefined);
|
||||
|
||||
@Injectable()
|
||||
export class AuditRepository {
|
||||
constructor(@Inject(DB) private readonly db: Db) {}
|
||||
|
||||
async insert(record: NewAuditLog): Promise<void> {
|
||||
await this.db.insert(schema.auditLog).values(record);
|
||||
await this.db.insert(log).values(record);
|
||||
}
|
||||
|
||||
async findAll(query: AuditLogQuery) {
|
||||
const { userId, search, actorUsername, action, resource, dateFrom, dateTo, page, pageSize } = query;
|
||||
const normalizedSearch = search?.trim();
|
||||
const searchPattern = normalizedSearch ? `%${escapeLike(normalizedSearch)}%` : undefined;
|
||||
const numericSearch = normalizedSearch && /^\d+$/.test(normalizedSearch) ? Number(normalizedSearch) : undefined;
|
||||
|
||||
const filters = [
|
||||
userId !== undefined ? eq(schema.auditLog.userId, userId) : undefined,
|
||||
actorUsername?.trim() ? eq(schema.auditLog.actorUsername, actorUsername.trim()) : undefined,
|
||||
action ? eq(schema.auditLog.action, action) : undefined,
|
||||
resource ? eq(schema.auditLog.resource, resource) : undefined,
|
||||
dateFrom ? gte(schema.auditLog.createdAt, dateFrom) : undefined,
|
||||
dateTo ? lte(schema.auditLog.createdAt, dateTo) : undefined,
|
||||
searchPattern
|
||||
? or(
|
||||
ilike(schema.auditLog.actorUsername, searchPattern),
|
||||
ilike(schema.auditLog.action, searchPattern),
|
||||
ilike(schema.auditLog.description, searchPattern),
|
||||
ilike(schema.auditLog.resource, searchPattern),
|
||||
numericSearch !== undefined ? eq(schema.auditLog.resourceId, numericSearch) : undefined,
|
||||
sql`${schema.auditLog.meta}::text ILIKE ${searchPattern}`,
|
||||
)
|
||||
: undefined,
|
||||
].filter((filter): filter is SQL => filter !== undefined);
|
||||
|
||||
const where = filters.length > 0 ? and(...filters) : undefined;
|
||||
const offset = (page - 1) * pageSize;
|
||||
const where = whereOf(filterConditions(query));
|
||||
const offset = (query.page - 1) * query.pageSize;
|
||||
|
||||
const [data, [{ total }]] = await Promise.all([
|
||||
this.db
|
||||
.select()
|
||||
.from(schema.auditLog)
|
||||
.where(where)
|
||||
.orderBy(desc(schema.auditLog.createdAt), asc(schema.auditLog.id))
|
||||
.limit(pageSize)
|
||||
.offset(offset),
|
||||
this.db.select({ total: count() }).from(schema.auditLog).where(where),
|
||||
this.db.select().from(log).where(where).orderBy(desc(log.createdAt), asc(log.id)).limit(query.pageSize).offset(offset),
|
||||
this.db.select({ total: count() }).from(log).where(where),
|
||||
]);
|
||||
|
||||
return { data, total };
|
||||
}
|
||||
|
||||
/** Counts for the view switcher, the category menu and the security chips, in two aggregate queries. */
|
||||
async summarize(filters: AuditLogFilters) {
|
||||
const base = whereOf(filterConditions(filters, { kind: true, category: true, securityGroup: true }));
|
||||
const inView = whereOf(filterConditions(filters, { category: true }));
|
||||
const groupCounts = Object.fromEntries(
|
||||
AUDIT_SECURITY_GROUP_KEYS.map((group) => [
|
||||
group,
|
||||
sql<number>`count(*) filter (where ${inArray(log.action, [...AUDIT_SECURITY_GROUPS[group]])})::int`,
|
||||
]),
|
||||
) as Record<AuditSecurityGroup, SQL<number>>;
|
||||
|
||||
const [[kinds], categories, [{ oldestAt }]] = await Promise.all([
|
||||
this.db
|
||||
.select({
|
||||
all: sql<number>`count(*)::int`,
|
||||
changes: sql<number>`count(*) filter (where ${kindCondition('changes')})::int`,
|
||||
signins: sql<number>`count(*) filter (where ${kindCondition('signins')})::int`,
|
||||
security: sql<number>`count(*) filter (where ${kindCondition('security')})::int`,
|
||||
...groupCounts,
|
||||
})
|
||||
.from(log)
|
||||
.where(base),
|
||||
this.db
|
||||
.select({ category: sql<AuditCategory>`${categoryExpression}`, total: sql<number>`count(*)::int` })
|
||||
.from(log)
|
||||
.where(inView)
|
||||
.groupBy(sql`1`),
|
||||
this.db.select({ oldestAt: sql<Date | null>`min(${log.createdAt})` }).from(log),
|
||||
]);
|
||||
|
||||
const { all, changes, signins, security, ...securityGroups } = kinds;
|
||||
return {
|
||||
kinds: { all, changes, signins, security },
|
||||
securityGroups: securityGroups as Record<AuditSecurityGroup, number>,
|
||||
categories: Object.fromEntries(categories.map((row) => [row.category, row.total])) as Partial<Record<AuditCategory, number>>,
|
||||
oldestAt: oldestAt ? new Date(oldestAt) : null,
|
||||
};
|
||||
}
|
||||
|
||||
/** Sign-in traffic folded per person, local day and address, newest group first. */
|
||||
async findSignInGroups(query: AuditSignInQuery) {
|
||||
const normalized = query.search?.trim();
|
||||
const pattern = normalized ? `%${escapeLike(normalized)}%` : undefined;
|
||||
const where = and(
|
||||
inArray(log.action, SIGN_IN_ACTIONS),
|
||||
query.actorUsername?.trim() ? eq(log.actorUsername, query.actorUsername.trim()) : undefined,
|
||||
query.ip?.trim() ? eq(log.ip, query.ip.trim()) : undefined,
|
||||
query.dateFrom ? gte(log.createdAt, query.dateFrom) : undefined,
|
||||
query.dateTo ? lte(log.createdAt, query.dateTo) : undefined,
|
||||
pattern ? or(ilike(log.actorUsername, pattern), ilike(log.ip, pattern), sql`${log.meta}->>'attemptedUsername' ILIKE ${pattern}`) : undefined,
|
||||
);
|
||||
const day = sql<string>`to_char(${log.createdAt} at time zone ${query.tz}, 'YYYY-MM-DD')`;
|
||||
const groups = this.db
|
||||
.select({
|
||||
day: day.as('day'),
|
||||
actorUsername: log.actorUsername,
|
||||
userId: log.userId,
|
||||
ip: log.ip,
|
||||
signIns: sql<number>`count(*) filter (where ${inArray(log.action, SUCCESSFUL_SIGN_INS)})::int`.as('sign_ins'),
|
||||
signOuts: sql<number>`count(*) filter (where ${eq(log.action, AuditAction.AuthLogout)})::int`.as('sign_outs'),
|
||||
failed: sql<number>`count(*) filter (where ${eq(log.action, AuditAction.AuthLoginFailed)})::int`.as('failed'),
|
||||
failedUsernames: sql<
|
||||
string[]
|
||||
>`coalesce(array_agg(distinct ${log.meta}->>'attemptedUsername') filter (where ${eq(log.action, AuditAction.AuthLoginFailed)} and ${log.meta}->>'attemptedUsername' is not null), '{}')`.as(
|
||||
'failed_usernames',
|
||||
),
|
||||
firstAt: sql<Date>`min(${log.createdAt})`.as('first_at'),
|
||||
lastAt: sql<Date>`max(${log.createdAt})`.as('last_at'),
|
||||
})
|
||||
.from(log)
|
||||
.where(where)
|
||||
.groupBy(sql`1`, log.actorUsername, log.userId, log.ip)
|
||||
.as('groups');
|
||||
|
||||
const [data, [{ total }]] = await Promise.all([
|
||||
this.db
|
||||
.select()
|
||||
.from(groups)
|
||||
.orderBy(desc(groups.lastAt), asc(groups.actorUsername))
|
||||
.limit(query.pageSize)
|
||||
.offset((query.page - 1) * query.pageSize),
|
||||
this.db.select({ total: count() }).from(groups),
|
||||
]);
|
||||
return { data, total };
|
||||
}
|
||||
|
||||
/** Current names for the given targets, one query per target type. */
|
||||
async findLiveLabels(targets: AuditTargetRef[]): Promise<Map<string, string>> {
|
||||
const byType = new Map<AuditResource, Set<number>>();
|
||||
for (const { resource, resourceId } of targets) {
|
||||
if (!hasTargetLabels(resource)) continue;
|
||||
if (!byType.has(resource)) byType.set(resource, new Set());
|
||||
byType.get(resource)!.add(resourceId);
|
||||
}
|
||||
const labels = new Map<string, string>();
|
||||
await Promise.all(
|
||||
[...byType].map(async ([resource, ids]) => {
|
||||
const source = TARGET_LABELS[resource]!;
|
||||
const rows = await this.db
|
||||
.select({ id: sql<number>`${source.id}`, label: sql<string | null>`${source.label}` })
|
||||
.from(source.table)
|
||||
.where(inArray(source.id, [...ids]));
|
||||
for (const row of rows) if (row.label) labels.set(targetKey(resource, Number(row.id)), row.label);
|
||||
}),
|
||||
);
|
||||
return labels;
|
||||
}
|
||||
|
||||
/** The newest name the log recorded for each target, for targets that no longer exist. */
|
||||
async findSnapshotLabels(targets: AuditTargetRef[]): Promise<Map<string, string>> {
|
||||
if (targets.length === 0) return new Map();
|
||||
const rows = await this.db
|
||||
.selectDistinctOn([log.resource, log.resourceId], { resource: log.resource, resourceId: log.resourceId, label: log.resourceLabel })
|
||||
.from(log)
|
||||
.where(
|
||||
and(
|
||||
isNotNull(log.resourceLabel),
|
||||
or(...targets.map((target) => and(eq(log.resource, target.resource), eq(log.resourceId, target.resourceId)))),
|
||||
),
|
||||
)
|
||||
.orderBy(log.resource, log.resourceId, desc(log.createdAt));
|
||||
return new Map(
|
||||
rows
|
||||
.filter((row) => row.resource && row.resourceId !== null && row.label)
|
||||
.map((row) => [targetKey(row.resource!, row.resourceId!), row.label!]),
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Names rows written before targets were snapshotted: from the target itself while it exists, otherwise
|
||||
* from the log's own "Created library 'Name'" line for the same target. Only fills empty labels.
|
||||
*/
|
||||
async backfillTargetLabels(): Promise<{ fromLive: number; fromHistory: number }> {
|
||||
let fromLive = 0;
|
||||
for (const [resource, source] of Object.entries(TARGET_LABELS)) {
|
||||
const result = await this.db.execute(
|
||||
sql`update ${log} set ${sql.raw('resource_label')} = left(${source.label}, 255) from ${source.table}
|
||||
where ${log.resource} = ${resource} and ${log.resourceLabel} is null and ${log.resourceId} = ${source.id} and ${source.label} is not null`,
|
||||
);
|
||||
fromLive += result.rowCount ?? 0;
|
||||
}
|
||||
const created = sql`select distinct on (${log.resource}, ${log.resourceId}) ${log.resource} as resource, ${log.resourceId} as resource_id,
|
||||
substring(${log.description} from ${"'(.+)'$"}) as label
|
||||
from ${log} where ${log.resourceId} is not null and ${log.description} ~ ${"^Created .+'.+'$"}
|
||||
order by ${log.resource}, ${log.resourceId}, ${log.createdAt} desc`;
|
||||
const history = await this.db.execute(
|
||||
sql`update ${log} set ${sql.raw('resource_label')} = left(created.label, 255) from (${created}) as created
|
||||
where ${log.resource} = created.resource and ${log.resourceId} = created.resource_id and ${log.resourceLabel} is null and created.label is not null`,
|
||||
);
|
||||
return { fromLive, fromHistory: history.rowCount ?? 0 };
|
||||
}
|
||||
|
||||
/** One batch of the export, oldest first after the cursor, so a long export never holds the table in memory. */
|
||||
async findExportBatch(filters: AuditLogFilters, afterId: number, batchSize: number) {
|
||||
return this.db
|
||||
.select()
|
||||
.from(log)
|
||||
.where(whereOf([...filterConditions(filters), sql`${log.id} > ${afterId}`]))
|
||||
.orderBy(asc(log.id))
|
||||
.limit(batchSize);
|
||||
}
|
||||
|
||||
async findActors(query: string | undefined, limit: number) {
|
||||
const normalizedQuery = query?.trim();
|
||||
const where = normalizedQuery ? ilike(schema.auditLog.actorUsername, `%${escapeLike(normalizedQuery)}%`) : undefined;
|
||||
const where = normalizedQuery ? ilike(log.actorUsername, `%${escapeLike(normalizedQuery)}%`) : undefined;
|
||||
return this.db
|
||||
.selectDistinct({
|
||||
userId: schema.auditLog.userId,
|
||||
username: schema.auditLog.actorUsername,
|
||||
userId: log.userId,
|
||||
username: log.actorUsername,
|
||||
})
|
||||
.from(schema.auditLog)
|
||||
.from(log)
|
||||
.where(where)
|
||||
.orderBy(asc(schema.auditLog.actorUsername))
|
||||
.orderBy(asc(log.actorUsername))
|
||||
.limit(limit);
|
||||
}
|
||||
|
||||
async deleteOlderThan(before: Date): Promise<void> {
|
||||
await this.db.delete(schema.auditLog).where(lte(schema.auditLog.createdAt, before));
|
||||
await this.db.delete(log).where(lte(log.createdAt, before));
|
||||
}
|
||||
|
||||
async findReadingInsightsAccess(subjectUserId: number, page: number, pageSize: number) {
|
||||
const where = and(
|
||||
eq(schema.auditLog.action, AuditAction.ReadingInsightsProfileView),
|
||||
eq(schema.auditLog.resource, AuditResource.ReadingInsightsProfile),
|
||||
eq(schema.auditLog.resourceId, subjectUserId),
|
||||
eq(log.action, AuditAction.ReadingInsightsProfileView),
|
||||
eq(log.resource, AuditResource.ReadingInsightsProfile),
|
||||
eq(log.resourceId, subjectUserId),
|
||||
);
|
||||
const [items, [{ total }]] = await Promise.all([
|
||||
this.db
|
||||
.select({
|
||||
id: schema.auditLog.id,
|
||||
viewerUsername: schema.auditLog.actorUsername,
|
||||
meta: schema.auditLog.meta,
|
||||
viewedAt: schema.auditLog.createdAt,
|
||||
id: log.id,
|
||||
viewerUsername: log.actorUsername,
|
||||
meta: log.meta,
|
||||
viewedAt: log.createdAt,
|
||||
})
|
||||
.from(schema.auditLog)
|
||||
.from(log)
|
||||
.where(where)
|
||||
.orderBy(desc(schema.auditLog.createdAt), desc(schema.auditLog.id))
|
||||
.orderBy(desc(log.createdAt), desc(log.id))
|
||||
.limit(pageSize)
|
||||
.offset((page - 1) * pageSize),
|
||||
this.db.select({ total: count() }).from(schema.auditLog).where(where),
|
||||
this.db.select({ total: count() }).from(log).where(where),
|
||||
]);
|
||||
return { items, total: Number(total) };
|
||||
}
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { APP_SETTING_KEYS, DEFAULT_AUDIT_RETENTION_DAYS } from '../../common/constants/app-settings.constants';
|
||||
import { AuditAction } from '@bookorbit/types';
|
||||
import { AuditAction, AuditResource } from '@bookorbit/types';
|
||||
import { AUDIT_EVENT, AuditEventsService } from './audit-events.service';
|
||||
import { AuditService } from './audit.service';
|
||||
|
||||
@@ -37,13 +37,23 @@ function makeService() {
|
||||
findActors: vi.fn(),
|
||||
findReadingInsightsAccess: vi.fn(),
|
||||
deleteOlderThan: vi.fn().mockResolvedValue(undefined),
|
||||
summarize: vi.fn(),
|
||||
findSignInGroups: vi.fn(),
|
||||
findExportBatch: vi.fn(),
|
||||
};
|
||||
const appSettings = {
|
||||
getValue: vi.fn(),
|
||||
};
|
||||
const targetLabels = {
|
||||
attach: vi.fn((rows: Array<Record<string, unknown>>) =>
|
||||
Promise.resolve(rows.map(({ resourceLabel, ...row }) => ({ ...row, targetLabel: resourceLabel ?? null, targetExists: null }))),
|
||||
),
|
||||
resolveForWrite: vi.fn().mockResolvedValue(null),
|
||||
actorNames: vi.fn().mockResolvedValue(new Map([[2, 'Test One']])),
|
||||
};
|
||||
|
||||
const service = new AuditService(auditEvents as never, auditRepository as never, appSettings as never);
|
||||
return { service, auditEvents, auditRepository, appSettings };
|
||||
const service = new AuditService(auditEvents as never, auditRepository as never, appSettings as never, targetLabels as never);
|
||||
return { service, auditEvents, auditRepository, appSettings, targetLabels };
|
||||
}
|
||||
|
||||
describe('AuditService', () => {
|
||||
@@ -77,6 +87,7 @@ describe('AuditService', () => {
|
||||
action: AuditAction.BookMetadataUpdate,
|
||||
resource: null,
|
||||
resourceId: null,
|
||||
resourceLabel: null,
|
||||
description: 'updated book metadata',
|
||||
ip: null,
|
||||
meta: null,
|
||||
@@ -85,6 +96,27 @@ describe('AuditService', () => {
|
||||
await service.onModuleDestroy();
|
||||
});
|
||||
|
||||
it('snapshots the target name with the event so a deleted target can still be named', async () => {
|
||||
const { service, auditRepository, targetLabels } = makeService();
|
||||
targetLabels.resolveForWrite.mockResolvedValue('Science Fiction');
|
||||
|
||||
await service.record(makePayload({ action: AuditAction.LibraryUpdate, resource: AuditResource.Library, resourceId: 7 }));
|
||||
|
||||
expect(targetLabels.resolveForWrite).toHaveBeenCalledWith(AuditResource.Library, 7);
|
||||
expect(auditRepository.insert).toHaveBeenCalledWith(expect.objectContaining({ resourceId: 7, resourceLabel: 'Science Fiction' }));
|
||||
});
|
||||
|
||||
it('keeps a name read before the target was deleted instead of looking it up again', async () => {
|
||||
const { service, auditRepository, targetLabels } = makeService();
|
||||
|
||||
await service.record(
|
||||
makePayload({ action: AuditAction.LibraryDelete, resource: AuditResource.Library, resourceId: 7, resourceLabel: 'Old Shelf' }),
|
||||
);
|
||||
|
||||
expect(targetLabels.resolveForWrite).not.toHaveBeenCalled();
|
||||
expect(auditRepository.insert).toHaveBeenCalledWith(expect.objectContaining({ resourceLabel: 'Old Shelf' }));
|
||||
});
|
||||
|
||||
it('drops writes when queue reaches max concurrent capacity', async () => {
|
||||
const { service, auditEvents, auditRepository } = makeService();
|
||||
const allPending = Array.from({ length: 20 }, () => deferred());
|
||||
@@ -148,15 +180,143 @@ describe('AuditService', () => {
|
||||
const result = await service.getAuditLogs(query);
|
||||
|
||||
expect(auditRepository.findAll).toHaveBeenCalledWith(query);
|
||||
expect(result).toEqual({ data: [{ id: 1 }], total: 1 });
|
||||
expect(result).toEqual({ data: [{ id: 1, targetLabel: null, targetExists: null }], total: 1 });
|
||||
});
|
||||
|
||||
it('returns view counts with the configured retention', async () => {
|
||||
const { service, auditRepository, appSettings } = makeService();
|
||||
appSettings.getValue.mockResolvedValue('30');
|
||||
auditRepository.summarize.mockResolvedValue({
|
||||
kinds: { all: 10, changes: 4, signins: 6, security: 2 },
|
||||
categories: { access: 6, libraries: 4 },
|
||||
securityGroups: { failed: 1, accounts: 1, passwords: 0, permissions: 0, access: 0, links: 0 },
|
||||
oldestAt: new Date('2026-08-15T04:32:57.000Z'),
|
||||
});
|
||||
|
||||
const summary = await service.getSummary({ kind: 'changes' });
|
||||
|
||||
expect(auditRepository.summarize).toHaveBeenCalledWith({ kind: 'changes' });
|
||||
expect(summary).toEqual({
|
||||
kinds: { all: 10, changes: 4, signins: 6, security: 2 },
|
||||
categories: { access: 6, libraries: 4 },
|
||||
securityGroups: { failed: 1, accounts: 1, passwords: 0, permissions: 0, access: 0, links: 0 },
|
||||
retentionDays: 30,
|
||||
oldestAt: '2026-08-15T04:32:57.000Z',
|
||||
});
|
||||
});
|
||||
|
||||
it('serialises sign-in group times as ISO strings', async () => {
|
||||
const { service, auditRepository } = makeService();
|
||||
auditRepository.findSignInGroups.mockResolvedValue({
|
||||
data: [
|
||||
{
|
||||
day: '2026-10-15',
|
||||
actorUsername: 'test1',
|
||||
userId: 2,
|
||||
ip: '192.168.8.230',
|
||||
signIns: 1,
|
||||
signOuts: 1,
|
||||
failed: 1,
|
||||
failedUsernames: ['test1'],
|
||||
firstAt: new Date('2026-10-16T01:49:37Z'),
|
||||
lastAt: '2026-10-16T02:02:00Z',
|
||||
},
|
||||
],
|
||||
total: '1',
|
||||
});
|
||||
|
||||
const page = await service.getSignIns({ tz: 'America/Denver', page: 1, pageSize: 50 });
|
||||
|
||||
expect(page.total).toBe(1);
|
||||
expect(page.data[0]).toMatchObject({
|
||||
actorName: 'Test One',
|
||||
firstAt: '2026-10-16T01:49:37.000Z',
|
||||
lastAt: '2026-10-16T02:02:00.000Z',
|
||||
failedUsernames: ['test1'],
|
||||
});
|
||||
});
|
||||
|
||||
it('exports CSV in id batches and defuses spreadsheet formulas', async () => {
|
||||
const { service, auditRepository } = makeService();
|
||||
const row = (id: number, description: string) => ({
|
||||
id,
|
||||
userId: 1,
|
||||
actorUsername: 'neon',
|
||||
action: AuditAction.LibraryCreate,
|
||||
resource: AuditResource.Library,
|
||||
resourceId: id,
|
||||
resourceLabel: '=HYPERLINK("x")',
|
||||
description,
|
||||
ip: '127.0.0.1',
|
||||
meta: null,
|
||||
createdAt: new Date('2026-10-14T22:37:27Z'),
|
||||
});
|
||||
auditRepository.findExportBatch
|
||||
.mockResolvedValueOnce(Array.from({ length: 500 }, (_, i) => row(i + 1, 'Created library')))
|
||||
.mockResolvedValueOnce([row(501, 'Says "hi", twice')]);
|
||||
|
||||
const chunks: string[] = [];
|
||||
for await (const chunk of service.exportEvents({ kind: 'changes' }, 'csv')) chunks.push(chunk);
|
||||
|
||||
expect(auditRepository.findExportBatch).toHaveBeenNthCalledWith(1, { kind: 'changes' }, 0, 500);
|
||||
expect(auditRepository.findExportBatch).toHaveBeenNthCalledWith(2, { kind: 'changes' }, 500, 500);
|
||||
expect(chunks).toHaveLength(502);
|
||||
expect(chunks[0]).toBe('id,occurred_at,actor,user_id,action,category,target_type,target_id,target,ip,description,meta\n');
|
||||
expect(chunks[501]).toBe(
|
||||
`501,2026-10-14T22:37:27.000Z,neon,1,library.create,libraries,library,501,"'=HYPERLINK(""x"")",127.0.0.1,"Says ""hi"", twice",\n`,
|
||||
);
|
||||
});
|
||||
|
||||
it('exports JSON as one array', async () => {
|
||||
const { service, auditRepository } = makeService();
|
||||
auditRepository.findExportBatch.mockResolvedValueOnce([
|
||||
{
|
||||
id: 3,
|
||||
userId: null,
|
||||
actorUsername: 'system',
|
||||
action: AuditAction.BookRequestImport,
|
||||
resource: AuditResource.BookRequest,
|
||||
resourceId: 9,
|
||||
resourceLabel: null,
|
||||
description: 'Imported',
|
||||
ip: null,
|
||||
meta: { format: 'm4b' },
|
||||
createdAt: new Date('2026-10-14T23:56:00Z'),
|
||||
},
|
||||
]);
|
||||
|
||||
let body = '';
|
||||
for await (const chunk of service.exportEvents({}, 'json')) body += chunk;
|
||||
|
||||
expect(JSON.parse(body)).toEqual([
|
||||
{
|
||||
id: 3,
|
||||
occurredAt: '2026-10-14T23:56:00.000Z',
|
||||
actorUsername: 'system',
|
||||
userId: null,
|
||||
action: 'book_request.import',
|
||||
category: 'requests',
|
||||
resource: 'book_request',
|
||||
resourceId: 9,
|
||||
targetLabel: null,
|
||||
ip: null,
|
||||
description: 'Imported',
|
||||
meta: { format: 'm4b' },
|
||||
},
|
||||
]);
|
||||
});
|
||||
|
||||
it('delegates actor suggestions to the repository', async () => {
|
||||
const { service, auditRepository } = makeService();
|
||||
const actors = [{ userId: 12, username: 'admin' }];
|
||||
auditRepository.findActors.mockResolvedValue(actors);
|
||||
auditRepository.findActors.mockResolvedValue([
|
||||
{ userId: 2, username: 'test1' },
|
||||
{ userId: null, username: 'system' },
|
||||
]);
|
||||
|
||||
await expect(service.getActors('adm', 25)).resolves.toEqual(actors);
|
||||
await expect(service.getActors('adm', 25)).resolves.toEqual([
|
||||
{ userId: 2, username: 'test1', name: 'Test One' },
|
||||
{ userId: null, username: 'system', name: null },
|
||||
]);
|
||||
expect(auditRepository.findActors).toHaveBeenCalledWith('adm', 25);
|
||||
});
|
||||
|
||||
|
||||
@@ -1,12 +1,31 @@
|
||||
import { Injectable, Logger, OnModuleInit, OnModuleDestroy } from '@nestjs/common';
|
||||
import { getAuditCategory, type AuditLogSummary, type AuditSignInPage } from '@bookorbit/types';
|
||||
|
||||
import { APP_SETTING_KEYS, DEFAULT_AUDIT_RETENTION_DAYS } from '../../common/constants/app-settings.constants';
|
||||
import { sanitizeLogValue } from '../../common/utils/log-sanitize.utils';
|
||||
import { AUDIT_EVENT, AuditEventPayload, AuditEventsService } from './audit-events.service';
|
||||
import { AuditRepository, AuditLogQuery } from './audit.repository';
|
||||
import { AuditRepository, AuditLogFilters, AuditLogQuery, AuditSignInQuery, hasTargetLabels } from './audit.repository';
|
||||
|
||||
export type { AuditLogFilters };
|
||||
import { AuditTargetLabelService, type AuditLogRow } from './audit-target-label.service';
|
||||
import { AppSettingsService } from '../app-settings/app-settings.service';
|
||||
|
||||
const MAX_CONCURRENT_WRITES = 20;
|
||||
const EXPORT_BATCH_SIZE = 500;
|
||||
const EXPORT_COLUMNS = [
|
||||
'id',
|
||||
'occurred_at',
|
||||
'actor',
|
||||
'user_id',
|
||||
'action',
|
||||
'category',
|
||||
'target_type',
|
||||
'target_id',
|
||||
'target',
|
||||
'ip',
|
||||
'description',
|
||||
'meta',
|
||||
];
|
||||
|
||||
@Injectable()
|
||||
export class AuditService implements OnModuleInit, OnModuleDestroy {
|
||||
@@ -18,6 +37,7 @@ export class AuditService implements OnModuleInit, OnModuleDestroy {
|
||||
private readonly auditEvents: AuditEventsService,
|
||||
private readonly auditRepository: AuditRepository,
|
||||
private readonly appSettings: AppSettingsService,
|
||||
private readonly targetLabels: AuditTargetLabelService,
|
||||
) {
|
||||
this.boundHandler = this.handleAuditEvent.bind(this);
|
||||
}
|
||||
@@ -37,44 +57,100 @@ export class AuditService implements OnModuleInit, OnModuleDestroy {
|
||||
return;
|
||||
}
|
||||
|
||||
const write = this.auditRepository
|
||||
.insert({
|
||||
userId: payload.userId,
|
||||
actorUsername: payload.actorUsername,
|
||||
action: payload.action,
|
||||
resource: payload.resource ?? null,
|
||||
resourceId: payload.resourceId ?? null,
|
||||
description: payload.description,
|
||||
ip: payload.ip ?? null,
|
||||
meta: payload.meta ?? null,
|
||||
})
|
||||
.catch((err: unknown) => {
|
||||
const errorClass = err instanceof Error ? err.constructor.name : 'Error';
|
||||
const error = err instanceof Error ? err.message : String(err);
|
||||
this.logger.error(
|
||||
`[audit.write] [fail] action=${payload.action} errorClass=${errorClass} error="${sanitizeLogValue(error)}" - audit write failed`,
|
||||
);
|
||||
});
|
||||
const write = this.record(payload).catch((err: unknown) => {
|
||||
const errorClass = err instanceof Error ? err.constructor.name : 'Error';
|
||||
const error = err instanceof Error ? err.message : String(err);
|
||||
this.logger.error(
|
||||
`[audit.write] [fail] action=${payload.action} errorClass=${errorClass} error="${sanitizeLogValue(error)}" - audit write failed`,
|
||||
);
|
||||
});
|
||||
|
||||
this.pendingWrites.add(write);
|
||||
void write.finally(() => this.pendingWrites.delete(write));
|
||||
}
|
||||
|
||||
getAuditLogs(query: AuditLogQuery) {
|
||||
return this.auditRepository.findAll(query);
|
||||
async getAuditLogs(query: AuditLogQuery): Promise<{ data: AuditLogRow[]; total: number }> {
|
||||
const { data, total } = await this.auditRepository.findAll(query);
|
||||
return { data: await this.targetLabels.attach(data), total };
|
||||
}
|
||||
|
||||
getActors(query: string | undefined, limit: number) {
|
||||
return this.auditRepository.findActors(query, limit);
|
||||
async getSummary(filters: AuditLogFilters): Promise<AuditLogSummary> {
|
||||
const [summary, retentionDays] = await Promise.all([this.auditRepository.summarize(filters), this.getRetentionDays()]);
|
||||
return {
|
||||
kinds: summary.kinds,
|
||||
categories: summary.categories,
|
||||
securityGroups: summary.securityGroups,
|
||||
retentionDays,
|
||||
oldestAt: summary.oldestAt?.toISOString() ?? null,
|
||||
};
|
||||
}
|
||||
|
||||
async getSignIns(query: AuditSignInQuery): Promise<AuditSignInPage> {
|
||||
const { data, total } = await this.auditRepository.findSignInGroups(query);
|
||||
const names = await this.targetLabels.actorNames(data.map((group) => group.userId));
|
||||
return {
|
||||
data: data.map((group) => ({
|
||||
...group,
|
||||
actorName: group.userId !== null ? (names.get(group.userId) ?? null) : null,
|
||||
firstAt: new Date(group.firstAt).toISOString(),
|
||||
lastAt: new Date(group.lastAt).toISOString(),
|
||||
})),
|
||||
total: Number(total),
|
||||
};
|
||||
}
|
||||
|
||||
/** Every matching event, oldest first, in batches so memory stays flat however long the log is. */
|
||||
async *exportEvents(filters: AuditLogFilters, format: 'csv' | 'json'): AsyncGenerator<string> {
|
||||
const start = Date.now();
|
||||
let exported = 0;
|
||||
let afterId = 0;
|
||||
this.logger.log(`[audit.export] [start] format=${format} - audit export started`);
|
||||
try {
|
||||
yield format === 'csv' ? EXPORT_COLUMNS.join(',') + '\n' : '[';
|
||||
for (;;) {
|
||||
const batch = await this.auditRepository.findExportBatch(filters, afterId, EXPORT_BATCH_SIZE);
|
||||
if (batch.length === 0) break;
|
||||
const rows = await this.targetLabels.attach(batch);
|
||||
for (const row of rows) {
|
||||
if (format === 'csv') yield csvLine(row) + '\n';
|
||||
else yield (exported > 0 ? ',' : '') + JSON.stringify(jsonRow(row));
|
||||
exported++;
|
||||
}
|
||||
afterId = batch[batch.length - 1].id;
|
||||
if (batch.length < EXPORT_BATCH_SIZE) break;
|
||||
}
|
||||
if (format === 'json') yield ']\n';
|
||||
this.logger.log(`[audit.export] [end] format=${format} exported=${exported} durationMs=${Date.now() - start} - audit export finished`);
|
||||
} catch (err: unknown) {
|
||||
const errorClass = err instanceof Error ? err.constructor.name : 'Error';
|
||||
const error = err instanceof Error ? err.message : String(err);
|
||||
this.logger.error(
|
||||
`[audit.export] [fail] format=${format} exported=${exported} durationMs=${Date.now() - start} errorClass=${errorClass} error="${sanitizeLogValue(error)}" - audit export failed`,
|
||||
);
|
||||
throw err;
|
||||
}
|
||||
}
|
||||
|
||||
async getActors(query: string | undefined, limit: number) {
|
||||
const actors = await this.auditRepository.findActors(query, limit);
|
||||
const names = await this.targetLabels.actorNames(actors.map((actor) => actor.userId));
|
||||
return actors.map((actor) => ({ ...actor, name: actor.userId !== null ? (names.get(actor.userId) ?? null) : null }));
|
||||
}
|
||||
|
||||
record(payload: AuditEventPayload): Promise<void> {
|
||||
const needsLabel = payload.resourceLabel === undefined && hasTargetLabels(payload.resource) && Number.isInteger(payload.resourceId);
|
||||
if (!needsLabel) return this.insert(payload, payload.resourceLabel ?? null);
|
||||
return this.targetLabels.resolveForWrite(payload.resource, payload.resourceId).then((label) => this.insert(payload, label));
|
||||
}
|
||||
|
||||
private insert(payload: AuditEventPayload, resourceLabel: string | null): Promise<void> {
|
||||
return this.auditRepository.insert({
|
||||
userId: payload.userId,
|
||||
actorUsername: payload.actorUsername,
|
||||
action: payload.action,
|
||||
resource: payload.resource ?? null,
|
||||
resourceId: payload.resourceId ?? null,
|
||||
resourceLabel,
|
||||
description: payload.description,
|
||||
ip: payload.ip ?? null,
|
||||
meta: payload.meta ?? null,
|
||||
@@ -120,3 +196,48 @@ export class AuditService implements OnModuleInit, OnModuleDestroy {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function jsonRow(row: AuditLogRow) {
|
||||
return {
|
||||
id: row.id,
|
||||
occurredAt: new Date(row.createdAt).toISOString(),
|
||||
actorUsername: row.actorUsername,
|
||||
userId: row.userId,
|
||||
action: row.action,
|
||||
category: getAuditCategory(row.action),
|
||||
resource: row.resource,
|
||||
resourceId: row.resourceId,
|
||||
targetLabel: row.targetLabel,
|
||||
ip: row.ip,
|
||||
description: row.description,
|
||||
meta: row.meta,
|
||||
};
|
||||
}
|
||||
|
||||
function csvLine(row: AuditLogRow): string {
|
||||
const r = jsonRow(row);
|
||||
return [
|
||||
r.id,
|
||||
r.occurredAt,
|
||||
r.actorUsername,
|
||||
r.userId,
|
||||
r.action,
|
||||
r.category,
|
||||
r.resource,
|
||||
r.resourceId,
|
||||
r.targetLabel,
|
||||
r.ip,
|
||||
r.description,
|
||||
r.meta ? JSON.stringify(r.meta) : '',
|
||||
]
|
||||
.map(csvCell)
|
||||
.join(',');
|
||||
}
|
||||
|
||||
/** Quotes a CSV cell, and defuses spreadsheet formulas so an exported name can never run as one. */
|
||||
function csvCell(value: string | number | null | undefined): string {
|
||||
if (value === null || value === undefined) return '';
|
||||
const raw = String(value);
|
||||
const text = /^[=+\-@\t\r]/.test(raw) ? `'${raw}` : raw;
|
||||
return /[",\n\r]/.test(text) || text !== raw ? `"${text.replace(/"/g, '""')}"` : text;
|
||||
}
|
||||
|
||||
@@ -4,7 +4,7 @@ import { plainToInstance } from 'class-transformer';
|
||||
import { validate } from 'class-validator';
|
||||
import { AuditAction, AuditResource } from '@bookorbit/types';
|
||||
|
||||
import { AuditActorQueryDto, AuditLogQueryDto } from './audit-log-query.dto';
|
||||
import { AuditActorQueryDto, AuditExportQueryDto, AuditLogQueryDto, AuditSignInQueryDto } from './audit-log-query.dto';
|
||||
|
||||
describe('AuditLogQueryDto', () => {
|
||||
it('transforms numeric query strings and validates enums/date strings', async () => {
|
||||
@@ -47,6 +47,43 @@ describe('AuditLogQueryDto', () => {
|
||||
expect((await validate(plainToInstance(AuditLogQueryDto, { actorUsername: 'x'.repeat(256) }))).length).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
it('accepts the view, category, security group and target filters', async () => {
|
||||
const dto = plainToInstance(AuditLogQueryDto, {
|
||||
kind: 'changes',
|
||||
category: 'requests',
|
||||
securityGroup: 'failed',
|
||||
resourceId: '88',
|
||||
ip: '192.168.8.230',
|
||||
});
|
||||
|
||||
expect(await validate(dto)).toHaveLength(0);
|
||||
expect(dto.resourceId).toBe(88);
|
||||
});
|
||||
|
||||
it('rejects unknown views, categories and groups', async () => {
|
||||
expect((await validate(plainToInstance(AuditLogQueryDto, { kind: 'everything' }))).length).toBeGreaterThan(0);
|
||||
expect((await validate(plainToInstance(AuditLogQueryDto, { category: 'clubs' }))).length).toBeGreaterThan(0);
|
||||
expect((await validate(plainToInstance(AuditLogQueryDto, { securityGroup: 'admins' }))).length).toBeGreaterThan(0);
|
||||
expect((await validate(plainToInstance(AuditLogQueryDto, { resourceId: '0' }))).length).toBeGreaterThan(0);
|
||||
expect((await validate(plainToInstance(AuditLogQueryDto, { ip: 'x'.repeat(46) }))).length).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
it('defaults sign-in grouping to UTC and rejects unknown time zones', async () => {
|
||||
const defaults = plainToInstance(AuditSignInQueryDto, {});
|
||||
expect(await validate(defaults)).toHaveLength(0);
|
||||
expect(defaults.tz).toBe('UTC');
|
||||
expect(await validate(plainToInstance(AuditSignInQueryDto, { tz: 'America/Denver' }))).toHaveLength(0);
|
||||
expect((await validate(plainToInstance(AuditSignInQueryDto, { tz: 'Mars/Olympus' }))).length).toBeGreaterThan(0);
|
||||
expect((await validate(plainToInstance(AuditSignInQueryDto, { tz: "UTC'; drop table audit_log; --" }))).length).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
it('exports CSV unless JSON is asked for', async () => {
|
||||
const defaults = plainToInstance(AuditExportQueryDto, {});
|
||||
expect(await validate(defaults)).toHaveLength(0);
|
||||
expect(defaults.format).toBe('csv');
|
||||
expect((await validate(plainToInstance(AuditExportQueryDto, { format: 'xlsx' }))).length).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
it('validates actor lookup queries and applies the default limit', async () => {
|
||||
const defaults = plainToInstance(AuditActorQueryDto, {});
|
||||
const valid = plainToInstance(AuditActorQueryDto, { q: 'read', limit: '75' });
|
||||
|
||||
@@ -1,8 +1,20 @@
|
||||
import { Transform } from 'class-transformer';
|
||||
import { IsDateString, IsEnum, IsInt, IsOptional, IsPositive, IsString, Max, MaxLength, Min } from 'class-validator';
|
||||
import { AuditAction, AuditResource } from '@bookorbit/types';
|
||||
import { IsDateString, IsEnum, IsIn, IsInt, IsOptional, IsPositive, IsString, IsTimeZone, Max, MaxLength, Min } from 'class-validator';
|
||||
import {
|
||||
AUDIT_CATEGORIES,
|
||||
AUDIT_LOG_KINDS,
|
||||
AUDIT_SECURITY_GROUP_KEYS,
|
||||
AuditAction,
|
||||
AuditResource,
|
||||
type AuditCategory,
|
||||
type AuditLogKind,
|
||||
type AuditSecurityGroup,
|
||||
} from '@bookorbit/types';
|
||||
|
||||
export class AuditLogQueryDto {
|
||||
const toInt = ({ value }: { value: string }) => parseInt(value, 10);
|
||||
|
||||
/** Filters shared by the list, the summary and the export. Every one is optional and narrows the result. */
|
||||
export class AuditFilterQueryDto {
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
@MaxLength(200)
|
||||
@@ -14,7 +26,7 @@ export class AuditLogQueryDto {
|
||||
actorUsername?: string;
|
||||
|
||||
@IsOptional()
|
||||
@Transform(({ value }: { value: string }) => parseInt(value, 10))
|
||||
@Transform(toInt)
|
||||
@IsInt()
|
||||
@IsPositive()
|
||||
userId?: number;
|
||||
@@ -27,6 +39,75 @@ export class AuditLogQueryDto {
|
||||
@IsEnum(AuditResource)
|
||||
resource?: AuditResource;
|
||||
|
||||
@IsOptional()
|
||||
@Transform(toInt)
|
||||
@IsInt()
|
||||
@IsPositive()
|
||||
resourceId?: number;
|
||||
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
@MaxLength(45)
|
||||
ip?: string;
|
||||
|
||||
@IsOptional()
|
||||
@IsIn(AUDIT_LOG_KINDS)
|
||||
kind?: AuditLogKind;
|
||||
|
||||
@IsOptional()
|
||||
@IsIn(AUDIT_CATEGORIES)
|
||||
category?: AuditCategory;
|
||||
|
||||
@IsOptional()
|
||||
@IsIn(AUDIT_SECURITY_GROUP_KEYS)
|
||||
securityGroup?: AuditSecurityGroup;
|
||||
|
||||
@IsOptional()
|
||||
@IsDateString()
|
||||
dateFrom?: string;
|
||||
|
||||
@IsOptional()
|
||||
@IsDateString()
|
||||
dateTo?: string;
|
||||
}
|
||||
|
||||
export class AuditLogQueryDto extends AuditFilterQueryDto {
|
||||
@IsOptional()
|
||||
@Transform(toInt)
|
||||
@IsInt()
|
||||
@Min(1)
|
||||
page?: number = 1;
|
||||
|
||||
@IsOptional()
|
||||
@Transform(toInt)
|
||||
@IsInt()
|
||||
@Min(1)
|
||||
@Max(200)
|
||||
pageSize?: number = 50;
|
||||
}
|
||||
|
||||
export class AuditExportQueryDto extends AuditFilterQueryDto {
|
||||
@IsOptional()
|
||||
@IsIn(['csv', 'json'])
|
||||
format?: 'csv' | 'json' = 'csv';
|
||||
}
|
||||
|
||||
export class AuditSignInQueryDto {
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
@MaxLength(200)
|
||||
search?: string;
|
||||
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
@MaxLength(255)
|
||||
actorUsername?: string;
|
||||
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
@MaxLength(45)
|
||||
ip?: string;
|
||||
|
||||
@IsOptional()
|
||||
@IsDateString()
|
||||
dateFrom?: string;
|
||||
@@ -35,14 +116,19 @@ export class AuditLogQueryDto {
|
||||
@IsDateString()
|
||||
dateTo?: string;
|
||||
|
||||
/** IANA zone the days are cut in; the viewer's own, so the groups match the dates they read. */
|
||||
@IsOptional()
|
||||
@Transform(({ value }: { value: string }) => parseInt(value, 10))
|
||||
@IsTimeZone()
|
||||
tz?: string = 'UTC';
|
||||
|
||||
@IsOptional()
|
||||
@Transform(toInt)
|
||||
@IsInt()
|
||||
@Min(1)
|
||||
page?: number = 1;
|
||||
|
||||
@IsOptional()
|
||||
@Transform(({ value }: { value: string }) => parseInt(value, 10))
|
||||
@Transform(toInt)
|
||||
@IsInt()
|
||||
@Min(1)
|
||||
@Max(200)
|
||||
@@ -56,7 +142,7 @@ export class AuditActorQueryDto {
|
||||
q?: string;
|
||||
|
||||
@IsOptional()
|
||||
@Transform(({ value }: { value: string }) => parseInt(value, 10))
|
||||
@Transform(toInt)
|
||||
@IsInt()
|
||||
@Min(1)
|
||||
@Max(100)
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
{
|
||||
"totalRoutes": 651,
|
||||
"totalRoutes": 654,
|
||||
"byPermission": {
|
||||
"(none)": 283,
|
||||
"Permission.ManageMetadataConfig": 38,
|
||||
@@ -15,7 +15,7 @@
|
||||
"Permission.DemoRestricted": 6,
|
||||
"Permission.KoreaderSync": 9,
|
||||
"Permission.OpdsAccess": 4,
|
||||
"Permission.ViewAuditLog": 1,
|
||||
"Permission.ViewAuditLog": 4,
|
||||
"Permission.ViewUserActivity": 6,
|
||||
"Permission.LibraryDeleteBooks": 14,
|
||||
"Permission.LibraryDownload": 4,
|
||||
|
||||
@@ -275,6 +275,42 @@
|
||||
"allowDefault": false,
|
||||
"useGuards": []
|
||||
},
|
||||
{
|
||||
"file": "server/src/modules/audit/audit.controller.ts",
|
||||
"className": "AuditController",
|
||||
"methodName": "getSummary",
|
||||
"httpMethod": "GET",
|
||||
"path": "/audit-log/summary",
|
||||
"permissions": ["Permission.ViewAuditLog"],
|
||||
"libraryAccess": [],
|
||||
"isPublic": false,
|
||||
"allowDefault": false,
|
||||
"useGuards": []
|
||||
},
|
||||
{
|
||||
"file": "server/src/modules/audit/audit.controller.ts",
|
||||
"className": "AuditController",
|
||||
"methodName": "getSignIns",
|
||||
"httpMethod": "GET",
|
||||
"path": "/audit-log/sign-ins",
|
||||
"permissions": ["Permission.ViewAuditLog"],
|
||||
"libraryAccess": [],
|
||||
"isPublic": false,
|
||||
"allowDefault": false,
|
||||
"useGuards": []
|
||||
},
|
||||
{
|
||||
"file": "server/src/modules/audit/audit.controller.ts",
|
||||
"className": "AuditController",
|
||||
"methodName": "exportEvents",
|
||||
"httpMethod": "GET",
|
||||
"path": "/audit-log/export",
|
||||
"permissions": ["Permission.ViewAuditLog"],
|
||||
"libraryAccess": [],
|
||||
"isPublic": false,
|
||||
"allowDefault": false,
|
||||
"useGuards": []
|
||||
},
|
||||
{
|
||||
"file": "server/src/modules/auth/auth.controller.ts",
|
||||
"className": "AuthController",
|
||||
|
||||
Reference in New Issue
Block a user