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:
neon
2026-10-16 16:38:08 +00:00
parent e21a92b8a4
commit 096bcea5cf
61 changed files with 28322 additions and 1445 deletions
@@ -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
}
]
}
+3
View File
@@ -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);
});
});
+56 -10
View File
@@ -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!,
});
+4 -2
View File
@@ -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 {}
+328 -54
View File
@@ -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) };
}
+167 -7
View File
@@ -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);
});
+144 -23
View File
@@ -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",