feat(audit): DB-backed audit trail вместо Noop (#496)

Событие comment.suggestion_applied/dismissed раньше улетало в
NoopAuditService и молча терялось; при childless-ветке apply/dismiss
комментарий hard-delete'ится, поэтому восстановить, кто и что решил,
было невозможно.

- DatabaseAuditService пишет в уже существующую таблицу `audit`
  (миграция 20260228T223532); actor/workspace/ip берутся из CLS
  AuditContext, вне HTTP — из явного контекста (logWithContext).
  Аудит — побочная запись: сбой записи не ломает исходный запрос,
  EXCLUDED_AUDIT_EVENTS отбрасываются.
- Биндинг AUDIT_SERVICE переключён с Noop на DatabaseAuditService.
- payload apply/dismiss дополнен suggestedText/selection/commentAuthor/
  decidedBy — на childless-ветке это единственная уцелевшая запись.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-07-11 17:48:08 +03:00
parent fe5bd159c4
commit c1dee15258
7 changed files with 384 additions and 9 deletions
+2 -2
View File
@@ -25,7 +25,7 @@ import { CacheModule } from '@nestjs/cache-manager';
import KeyvRedis from '@keyv/redis';
import { LoggerModule } from './common/logger/logger.module';
import { ClsModule } from 'nestjs-cls';
import { NoopAuditModule } from './integrations/audit/audit.module';
import { AuditModule } from './integrations/audit/audit.module';
import { ThrottleModule } from './integrations/throttle/throttle.module';
import { McpModule } from './integrations/mcp/mcp.module';
import { SandboxModule } from './integrations/sandbox/sandbox.module';
@@ -55,7 +55,7 @@ try {
middleware: { mount: true },
}),
LoggerModule,
NoopAuditModule,
AuditModule,
CoreModule,
DatabaseModule,
EnvironmentModule,
@@ -146,11 +146,19 @@ describe('CommentService — applySuggestion', () => {
'page-1',
expect.objectContaining({ operation: 'commentDeleted', commentId: 'c-1' }),
);
// #496: hard-deleted row → the audit payload is the only surviving record.
expect(auditService.log).toHaveBeenCalledWith(
expect.objectContaining({
event: AuditEvent.COMMENT_SUGGESTION_APPLIED,
resourceType: AuditResource.COMMENT,
resourceId: 'c-1',
metadata: expect.objectContaining({
pageId: 'page-1',
suggestedText: 'new text',
selection: 'old text',
commentAuthor: 'user-1',
decidedBy: 'user-1',
}),
}),
);
expect(result.outcome).toBe('deleted');
@@ -107,11 +107,21 @@ describe('CommentService — dismissSuggestion', () => {
'page-1',
expect.objectContaining({ operation: 'commentDeleted', commentId: 'c-1' }),
);
// #496: the row is hard-deleted, so the audit payload must carry the
// decision's substance (what was suggested, the anchored text, who authored
// it, who decided) — it is the only surviving record.
expect(auditService.log).toHaveBeenCalledWith(
expect.objectContaining({
event: AuditEvent.COMMENT_SUGGESTION_DISMISSED,
resourceType: AuditResource.COMMENT,
resourceId: 'c-1',
metadata: expect.objectContaining({
pageId: 'page-1',
suggestedText: 'new text',
selection: 'old text',
commentAuthor: 'user-1',
decidedBy: 'user-1',
}),
}),
);
expect(result.outcome).toBe('deleted');
@@ -524,7 +524,7 @@ export class CommentService {
resourceType: AuditResource.COMMENT,
resourceId: comment.id,
spaceId: comment.spaceId,
metadata: { pageId: comment.pageId },
metadata: this.suggestionAuditMetadata(comment, user),
});
return { ...updatedComment, outcome: 'resolved' };
}
@@ -538,7 +538,7 @@ export class CommentService {
resourceType: AuditResource.COMMENT,
resourceId: comment.id,
spaceId: comment.spaceId,
metadata: { pageId: comment.pageId },
metadata: this.suggestionAuditMetadata(comment, user),
});
return settled;
}
@@ -597,7 +597,7 @@ export class CommentService {
resourceType: AuditResource.COMMENT,
resourceId: comment.id,
spaceId: comment.spaceId,
metadata: { pageId: comment.pageId },
metadata: this.suggestionAuditMetadata(comment, user),
});
return { ...updatedComment, outcome: 'resolved' };
@@ -616,7 +616,7 @@ export class CommentService {
resourceType: AuditResource.COMMENT,
resourceId: comment.id,
spaceId: comment.spaceId,
metadata: { pageId: comment.pageId },
metadata: this.suggestionAuditMetadata(comment, user),
});
return settled;
@@ -732,6 +732,27 @@ export class CommentService {
return this.generalQueue.add(QueueJob.COMMENT_MARK_UPDATE, jobData);
}
/**
* Build the audit metadata for a suggestion apply/dismiss decision (#496).
* The subject comment is HARD-DELETED on the childless path, so the audit row
* is the only surviving record — capture the decision's substance (what was
* suggested, the anchored text it replaced, who authored it, who decided)
* before the row can vanish. `decidedBy` is the acting user; `commentAuthor`
* is the suggestion's creator.
*/
private suggestionAuditMetadata(
comment: Comment,
user: User,
): Record<string, any> {
return {
pageId: comment.pageId,
suggestedText: comment.suggestedText ?? null,
selection: comment.selection ?? null,
commentAuthor: comment.creatorId ?? null,
decidedBy: user.id,
};
}
private async queueCommentNotification(
content: any,
oldMentionIds: string[],
@@ -1,14 +1,19 @@
import { Global, Module } from '@nestjs/common';
import { AUDIT_SERVICE, NoopAuditService } from './audit.service';
import { AUDIT_SERVICE } from './audit.service';
import { DatabaseAuditService } from './database-audit.service';
// #496: bind the audit token to a real DB-backed trail (was NoopAuditService,
// which silently dropped every event). Kysely (@Global DatabaseModule) and
// ClsService (@Global ClsModule) are both globally available, so this module
// needs no extra imports.
@Global()
@Module({
providers: [
{
provide: AUDIT_SERVICE,
useClass: NoopAuditService,
useClass: DatabaseAuditService,
},
],
exports: [AUDIT_SERVICE],
})
export class NoopAuditModule {}
export class AuditModule {}
@@ -0,0 +1,171 @@
import { DatabaseAuditService } from './database-audit.service';
import { AUDIT_CONTEXT_KEY } from '../../common/middlewares/audit-context.middleware';
import { AuditEvent, AuditResource } from '../../common/events/audit-events';
/**
* Observable-property coverage for the DB-backed audit trail (#496): every
* assertion pins what actually reaches the `audit` table (or that nothing does),
* driven through a chainable Kysely mock that captures the inserted rows.
*/
describe('DatabaseAuditService', () => {
function makeService(clsContext: any) {
const inserted: any[] = [];
const updated: any[] = [];
let failNextInsert = false;
const db: any = {
insertInto: jest.fn(() => ({
values: jest.fn((rows: any) => ({
execute: jest.fn(async () => {
if (failNextInsert) {
failNextInsert = false;
throw new Error('boom');
}
inserted.push(rows);
}),
})),
})),
updateTable: jest.fn(() => ({
set: jest.fn((patch: any) => ({
where: jest.fn(() => ({
execute: jest.fn(async () => {
updated.push(patch);
}),
})),
})),
})),
};
const store: Record<string, any> = { [AUDIT_CONTEXT_KEY]: clsContext };
const cls: any = {
get: jest.fn((key: string) => store[key]),
set: jest.fn((key: string, val: any) => {
store[key] = val;
}),
};
const service = new DatabaseAuditService(db, cls);
return {
service,
inserted,
updated,
cls,
store,
failInsert: () => {
failNextInsert = true;
},
};
}
const applyPayload = () => ({
event: AuditEvent.COMMENT_SUGGESTION_APPLIED,
resourceType: AuditResource.COMMENT,
resourceId: 'c-1',
spaceId: 'space-1',
metadata: { pageId: 'p-1', suggestedText: 'new', decidedBy: 'u-2' },
});
const ctx = (over?: any) => ({
workspaceId: 'ws-1',
actorId: 'u-2',
actorType: 'user',
ipAddress: '10.0.0.1',
userAgent: 'jest',
...over,
});
it('log() persists a row with the CLS context merged onto the payload', async () => {
const { service, inserted } = makeService(ctx());
service.log(applyPayload());
// log() is fire-and-forget; flush the microtask queue.
await Promise.resolve();
await Promise.resolve();
expect(inserted).toHaveLength(1);
expect(inserted[0]).toMatchObject({
workspaceId: 'ws-1',
actorId: 'u-2',
actorType: 'user',
event: AuditEvent.COMMENT_SUGGESTION_APPLIED,
resourceType: AuditResource.COMMENT,
resourceId: 'c-1',
spaceId: 'space-1',
ipAddress: '10.0.0.1',
metadata: { pageId: 'p-1', suggestedText: 'new', decidedBy: 'u-2' },
});
});
it('log() is a no-op when there is no workspace in scope', async () => {
const { service, inserted } = makeService(ctx({ workspaceId: null }));
service.log(applyPayload());
await Promise.resolve();
expect(inserted).toHaveLength(0);
});
it('log() drops EXCLUDED_AUDIT_EVENTS (e.g. comment.created)', async () => {
const { service, inserted } = makeService(ctx());
service.log({
event: AuditEvent.COMMENT_CREATED,
resourceType: AuditResource.COMMENT,
resourceId: 'c-1',
spaceId: 'space-1',
});
await Promise.resolve();
await Promise.resolve();
expect(inserted).toHaveLength(0);
});
it('a failed insert never throws to the caller (audit is a side-record)', async () => {
const { service, failInsert } = makeService(ctx());
failInsert();
// Must not reject / throw.
expect(() => service.log(applyPayload())).not.toThrow();
await Promise.resolve();
await Promise.resolve();
});
it('logWithContext() persists with an explicit (non-request) context', async () => {
const { service, inserted } = makeService(undefined);
service.logWithContext(applyPayload(), ctx({ actorType: 'system' }) as any);
await Promise.resolve();
await Promise.resolve();
expect(inserted).toHaveLength(1);
expect(inserted[0].actorType).toBe('system');
expect(inserted[0].workspaceId).toBe('ws-1');
});
it('logBatchWithContext() inserts only non-excluded events', async () => {
const { service, inserted } = makeService(undefined);
service.logBatchWithContext(
[
applyPayload(),
{
event: AuditEvent.COMMENT_CREATED,
resourceType: AuditResource.COMMENT,
resourceId: 'c-2',
},
],
ctx() as any,
);
await Promise.resolve();
await Promise.resolve();
// Batch is a single insert call carrying only the applied event.
expect(inserted).toHaveLength(1);
expect(inserted[0]).toHaveLength(1);
expect(inserted[0][0].event).toBe(AuditEvent.COMMENT_SUGGESTION_APPLIED);
});
it('setActorId / setActorType mutate the ambient CLS context', () => {
const { service, store } = makeService(ctx({ actorId: null }));
service.setActorId('u-9');
service.setActorType('api_key');
expect(store[AUDIT_CONTEXT_KEY].actorId).toBe('u-9');
expect(store[AUDIT_CONTEXT_KEY].actorType).toBe('api_key');
});
it('updateRetention() writes the workspace retention window', async () => {
const { service, updated } = makeService(ctx());
await service.updateRetention('ws-1', 30);
expect(updated).toEqual([{ auditRetentionDays: 30 }]);
});
});
@@ -0,0 +1,160 @@
import { Injectable, Logger } from '@nestjs/common';
import { InjectKysely } from 'nestjs-kysely';
import { ClsService } from 'nestjs-cls';
import { KyselyDB } from '@docmost/db/types/kysely.types';
import {
AuditContext,
AUDIT_CONTEXT_KEY,
} from '../../common/middlewares/audit-context.middleware';
import {
AuditLogPayload,
ActorType,
EXCLUDED_AUDIT_EVENTS,
} from '../../common/events/audit-events';
import { AuditLogContext, IAuditService } from './audit.service';
/**
* Minimal DB-backed audit trail (#496). Replaces NoopAuditService so that
* decision-bearing events — notably comment.suggestion_applied /
* comment.suggestion_dismissed, whose subject comment is HARD-DELETED on the
* childless path — leave a durable record of who decided what. Without this the
* events were emitted (comment.service / *.controller) but swallowed, so an
* applied/dismissed suggestion was unrecoverable once the row was gone.
*
* Rows land in the pre-existing `audit` table (migration 20260228T223532). The
* per-request actor/workspace/ip come from the CLS AuditContext populated by
* AuditContextMiddleware + AuditActorInterceptor; callers that run OUTSIDE a
* request (queue workers, imports) pass an explicit context via
* logWithContext / logBatchWithContext.
*
* Audit is a side-record: a write failure MUST NOT break the originating
* request, so every persistence path swallows its error with a warn. Events in
* EXCLUDED_AUDIT_EVENTS (high-volume/low-signal) are dropped.
*/
@Injectable()
export class DatabaseAuditService implements IAuditService {
private readonly logger = new Logger(DatabaseAuditService.name);
constructor(
@InjectKysely() private readonly db: KyselyDB,
private readonly cls: ClsService,
) {}
/**
* Persist a single event using the ambient request-scoped AuditContext. A
* no-op when there is no workspace in scope (the table's workspace_id is NOT
* NULL) or the event is excluded. Fire-and-forget: the returned promise is not
* awaited by hot callers, and its rejection is swallowed here.
*/
log(payload: AuditLogPayload): void {
const context = this.cls?.get<AuditContext>(AUDIT_CONTEXT_KEY);
if (!context?.workspaceId) {
// No workspace in scope — nothing we can attribute the row to. This is
// expected for events emitted outside an HTTP request; those callers must
// use logWithContext instead.
return;
}
void this.persist(payload, {
workspaceId: context.workspaceId,
actorId: context.actorId ?? undefined,
actorType: context.actorType,
ipAddress: context.ipAddress ?? undefined,
userAgent: context.userAgent ?? undefined,
});
}
/** Persist a single event with an explicit (non-request) context. */
logWithContext(payload: AuditLogPayload, context: AuditLogContext): void {
if (!context?.workspaceId) return;
void this.persist(payload, context);
}
/** Persist a batch of events sharing one explicit context (imports). */
logBatchWithContext(
payloads: AuditLogPayload[],
context: AuditLogContext,
): void {
if (!context?.workspaceId || payloads.length === 0) return;
const rows = payloads
.filter((p) => !EXCLUDED_AUDIT_EVENTS.has(p.event))
.map((p) => this.toRow(p, context));
if (rows.length === 0) return;
this.db
.insertInto('audit')
.values(rows)
.execute()
.catch((err: any) =>
this.logger.warn(`Failed to persist ${rows.length} audit events: ${err?.message}`),
);
}
/** Update the ambient request actor (e.g. after login resolves the user). */
setActorId(actorId: string): void {
const context = this.cls?.get<AuditContext>(AUDIT_CONTEXT_KEY);
if (context) {
context.actorId = actorId;
this.cls.set(AUDIT_CONTEXT_KEY, context);
}
}
/** Update the ambient request actor type (user | system | api_key). */
setActorType(actorType: ActorType): void {
const context = this.cls?.get<AuditContext>(AUDIT_CONTEXT_KEY);
if (context) {
context.actorType = actorType;
this.cls.set(AUDIT_CONTEXT_KEY, context);
}
}
/** Persist a workspace's audit-log retention window (days). */
async updateRetention(
workspaceId: string,
retentionDays: number,
): Promise<void> {
try {
await this.db
.updateTable('workspaces')
.set({ auditRetentionDays: retentionDays })
.where('id', '=', workspaceId)
.execute();
} catch (err: any) {
this.logger.warn(
`Failed to update audit retention for workspace ${workspaceId}: ${err?.message}`,
);
}
}
private async persist(
payload: AuditLogPayload,
context: AuditLogContext,
): Promise<void> {
if (EXCLUDED_AUDIT_EVENTS.has(payload.event)) return;
try {
await this.db
.insertInto('audit')
.values(this.toRow(payload, context))
.execute();
} catch (err: any) {
// Audit is a side-record; never let a failed write surface to the caller.
this.logger.warn(
`Failed to persist audit event ${payload.event}: ${err?.message}`,
);
}
}
private toRow(payload: AuditLogPayload, context: AuditLogContext) {
return {
workspaceId: context.workspaceId,
actorId: context.actorId ?? null,
actorType: context.actorType ?? 'user',
event: payload.event,
resourceType: payload.resourceType,
resourceId: payload.resourceId ?? null,
spaceId: payload.spaceId ?? null,
// jsonb columns: node-postgres serializes plain objects to JSON.
changes: payload.changes ? (payload.changes as any) : null,
metadata: payload.metadata ? (payload.metadata as any) : null,
ipAddress: context.ipAddress ?? null,
};
}
}