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