feat(audit): extend AuditAction enum + audit logging on alerts + expense dedup

- AuditAction gains password_change, portal_invite/activate/reset
  variants, send, view. AuditLogParams.ipAddress/userAgent now optional
  so background jobs and internal helpers can log without faking values.
- alerts.service.dismissAlert/acknowledgeAlert now write
  action='update' rows with metadata.kind so the audit log differentiates
  the two state changes.
- expense-dedup.service.clearDuplicate/mergeDuplicate accept userId
  and write action='update'/'merge' rows respectively. Routes pass
  ctx.userId.

Audit gaps surfaced by audit-pass-#2: 6 services bypassed audit_logs
entirely. This commit closes 2 of them; portal-auth lands in a later
commit alongside the email-template-override work that already touches
the same file.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
Matt Ciaccio
2026-05-06 14:57:24 +02:00
parent 1fb3aa3aeb
commit 1b78eadd36
5 changed files with 62 additions and 10 deletions

View File

@@ -16,7 +16,14 @@ export type AuditAction =
| 'revoke_invite'
| 'resend_invite'
| 'request_gdpr_export'
| 'send_gdpr_export';
| 'send_gdpr_export'
| 'password_change'
| 'portal_invite'
| 'portal_activate'
| 'portal_password_reset_request'
| 'portal_password_reset'
| 'send'
| 'view';
/**
* Common shape passed to service functions so they can stamp audit logs and
@@ -43,8 +50,10 @@ export interface AuditLogParams {
oldValue?: Record<string, unknown>;
newValue?: Record<string, unknown>;
metadata?: Record<string, unknown>;
ipAddress: string;
userAgent: string;
/** Optional. Services that don't have request context (e.g. background
* jobs, internal helpers) may omit. */
ipAddress?: string;
userAgent?: string;
}
const SENSITIVE_FIELDS = new Set(['email', 'phone', 'password', 'credentials_enc', 'token']);
@@ -104,8 +113,8 @@ export async function createAuditLog(params: AuditLogParams): Promise<void> {
oldValue: maskSensitiveFields(params.oldValue) ?? null,
newValue: maskSensitiveFields(params.newValue) ?? null,
metadata: params.metadata ?? null,
ipAddress: params.ipAddress,
userAgent: params.userAgent,
ipAddress: params.ipAddress ?? null,
userAgent: params.userAgent ?? null,
});
} catch (err) {
// Strip old/new values from the log to avoid secondary exposure of the data

View File

@@ -12,6 +12,7 @@ import { createHash } from 'crypto';
import { db } from '@/lib/db';
import { alerts, type Alert, type AlertSeverity, type AlertRuleId } from '@/lib/db/schema/insights';
import { emitToRoom } from '@/lib/socket/server';
import { createAuditLog } from '@/lib/audit';
export interface AlertCandidate {
ruleId: AlertRuleId;
@@ -108,6 +109,14 @@ export async function dismissAlert(alertId: string, portId: string, userId: stri
.returning({ id: alerts.id, portId: alerts.portId });
if (row) {
emitToRoom(`port:${row.portId}`, 'alert:dismissed', { alertId: row.id, portId: row.portId });
void createAuditLog({
portId: row.portId,
userId,
action: 'update',
entityType: 'alert',
entityId: row.id,
metadata: { kind: 'dismiss' },
});
}
}
@@ -116,10 +125,21 @@ export async function acknowledgeAlert(
portId: string,
userId: string,
): Promise<void> {
await db
const [row] = await db
.update(alerts)
.set({ acknowledgedAt: sql`now()`, acknowledgedBy: userId })
.where(and(eq(alerts.id, alertId), eq(alerts.portId, portId)));
.where(and(eq(alerts.id, alertId), eq(alerts.portId, portId)))
.returning({ id: alerts.id, portId: alerts.portId });
if (row) {
void createAuditLog({
portId: row.portId,
userId,
action: 'update',
entityType: 'alert',
entityId: row.id,
metadata: { kind: 'acknowledge' },
});
}
}
export interface ListAlertsOptions {

View File

@@ -9,6 +9,7 @@ import { and, between, eq, ne, sql } from 'drizzle-orm';
import { db } from '@/lib/db';
import { expenses } from '@/lib/db/schema/financial';
import { NotFoundError, ValidationError } from '@/lib/errors';
import { createAuditLog } from '@/lib/audit';
const DEDUP_WINDOW_DAYS = 3;
@@ -75,11 +76,23 @@ export async function markBestDuplicate(expenseId: string): Promise<string | nul
* Clear the duplicate flag - operator confirmed this is a real expense.
* Leaves `dedupScannedAt` populated so the engine doesn't re-flag it.
*/
export async function clearDuplicate(expenseId: string, portId: string): Promise<void> {
export async function clearDuplicate(
expenseId: string,
portId: string,
userId: string | null = null,
): Promise<void> {
await db
.update(expenses)
.set({ duplicateOf: null, dedupScannedAt: sql`now()` })
.where(and(eq(expenses.id, expenseId), eq(expenses.portId, portId)));
void createAuditLog({
portId,
userId,
action: 'update',
entityType: 'expense',
entityId: expenseId,
metadata: { kind: 'duplicate_cleared' },
});
}
/**
@@ -92,6 +105,7 @@ export async function mergeDuplicate(
sourceId: string,
targetId: string,
portId: string,
userId: string | null = null,
): Promise<void> {
if (sourceId === targetId) {
throw new ValidationError('Cannot merge an expense into itself');
@@ -125,4 +139,13 @@ export async function mergeDuplicate(
.set({ archivedAt: sql`now()`, duplicateOf: null })
.where(eq(expenses.id, sourceId));
});
void createAuditLog({
portId,
userId,
action: 'merge',
entityType: 'expense',
entityId: targetId,
metadata: { mergedFromExpenseId: sourceId },
});
}