Audit cleanup completion plan, all tiers shipped: Tier 1 (security + data integrity) - A.7 RTBF true wipe: redact email_messages body/subject/addresses for threads owned by deleted client; redact document_sends.recipient_email; collect file storage keys + delete blobs post-commit. - A.8 user_permission_overrides FK: documented inline why cascade is correct (not set-null as audit suggested) — overrides have no value without their user. - W2.14 PII redaction: camelCase normalization in audit.ts + error-events.service.ts isSensitiveKey; added city/postal/country/ birth fragments. firstName/lastName/dateOfBirth/postalCode etc. now caught in BOTH masker paths. 12 new test cases lock the coverage. Tier 2 (Documenso completion + refactor) - C.2: documentEvents.recipient_email column + partial unique index for per-recipient webhook dedup (migration 0075). handleDocumentSigned now sets recipient_email on insert. - Phase 2: completion_cc_emails distribution. handleDocumentCompleted reads documents.completionCcEmails, filters out signer-duplicates case-insensitively, fans signed PDF out to non-signer recipients. - C.4: extracted createPublicInterest() service from the 346-line api/public/interests route. Route becomes a thin shell (rate-limit, port resolution, audit log, email fan-out). The trio creation logic is now unit-testable without an HTTP fixture. - Phase 4: POST /api/v1/document-templates/[id]/detect-fields wired to document-field-detector.detectFields(). Sparkles "Auto-detect" button added to template-editor.tsx — maps DetectedField → marker with best-guess merge token (DATE / NAME / EMAIL); user retags. Tier 3 (reporting + recommender snapshot lockfiles) - W7.reports: extracted rollupStageRevenue / rollupStageCounts / computeTotalForecast / computeOccupancyRate / rollupBerthStatusCounts into src/lib/services/report-math.ts (pure functions). 16 new tests including an inline-snapshot lockfile on a representative 7-stage forecast. report-generators.ts now delegates. - W7.recommender: 18 new toMatchSnapshot tripwires on classifyTier boundaries + computeHeat at canonical input points. Tier 4 (rolling) - W6.attach: fixed outdated CLAUDE.md claim — threshold banner is informational and never depended on IMAP; bounce monitoring (the IMAP poller) is separate. - D.1 + D.2: documented deferral inline with full why-not-build-it reasoning so a future engineer sees the rationale. - G.1: representative formatDate sweep (audit-log-list, user-list, document-templates merge tokens, document-signing email). Rest of the ~100 sites stay rolling. Quality gates: 1420/1420 vitest (46 new tests above baseline of 1374), tsc clean, 0 lint errors. Plan: docs/superpowers/plans/2026-05-18-audit-cleanup-completion.md Migration: 0075_c2_document_events_recipient_email.sql (applied to dev DB). Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
305 lines
10 KiB
TypeScript
305 lines
10 KiB
TypeScript
import { db } from '@/lib/db';
|
|
import { auditLogs } from '@/lib/db/schema';
|
|
import { logger } from '@/lib/logger';
|
|
|
|
/**
|
|
* Widen a Drizzle row (or any object) to the shape audit_logs.oldValue /
|
|
* newValue expects. Centralizes the structurally-safe `Record<string,
|
|
* unknown>` cast 20+ services were doing inline via
|
|
* `as unknown as Record<string, unknown>`. Mirrors gdpr-bundle-builder's
|
|
* `toJsonRow` helper (same audit-found motivation).
|
|
*/
|
|
export function toAuditJson<T extends object>(row: T): Record<string, unknown> {
|
|
return row as unknown as Record<string, unknown>;
|
|
}
|
|
|
|
export type AuditAction =
|
|
| 'create'
|
|
| 'update'
|
|
| 'delete'
|
|
| 'archive'
|
|
| 'restore'
|
|
| 'merge'
|
|
| 'login'
|
|
| 'logout'
|
|
| 'permission_denied'
|
|
| 'revert'
|
|
| 'revoke_invite'
|
|
| 'resend_invite'
|
|
| 'request_gdpr_export'
|
|
| 'send_gdpr_export'
|
|
| 'password_change'
|
|
| 'portal_invite'
|
|
| 'portal_activate'
|
|
| 'portal_password_reset_request'
|
|
| 'portal_password_reset'
|
|
| 'send'
|
|
| 'view'
|
|
| 'request_hard_delete_code'
|
|
| 'hard_delete'
|
|
// Branding (port logo upload pipeline).
|
|
| 'branding.logo.uploaded'
|
|
| 'branding.logo.archived'
|
|
// System / background events.
|
|
| 'webhook_delivered'
|
|
| 'webhook_failed'
|
|
| 'webhook_dead_letter'
|
|
| 'webhook_retried'
|
|
| 'job_failed'
|
|
| 'cron_run'
|
|
// Berth-rule decision trace: emitted by the rules engine on every
|
|
// evaluateRule() call so admins can debug "why did this fire / not fire"
|
|
// without reading server logs. Distinct from the actual `update` audit
|
|
// row the auto-applied path emits when it mutates berth status.
|
|
| 'rule_evaluated'
|
|
// M-AU04: distinct verbs for outcome-set / outcome-cleared. The pre-fix
|
|
// path used a generic `update` row with `metadata.type = 'outcome_set'`,
|
|
// which the audit filter dropdown couldn't surface as its own bucket
|
|
// and the FTS GENERATED index missed entirely.
|
|
| 'outcome_set'
|
|
| 'outcome_cleared'
|
|
// Phase 3 — EOI override / contact promote / yacht spawn from EOI.
|
|
// The DB column is free-text per migration 0073; these strings just
|
|
// formalise the catalogue so the audit-log filter dropdown can surface
|
|
// them as their own buckets.
|
|
| 'eoi_field_override'
|
|
| 'promote_to_primary'
|
|
| 'eoi_spawn_yacht';
|
|
|
|
/**
|
|
* Common shape passed to service functions so they can stamp audit logs and
|
|
* propagate request context. Every authenticated route resolves these from
|
|
* the session + headers; services accept them rather than reaching into
|
|
* Next.js APIs themselves.
|
|
*/
|
|
export interface AuditMeta {
|
|
userId: string;
|
|
portId: string;
|
|
ipAddress: string;
|
|
userAgent: string;
|
|
}
|
|
|
|
export type AuditSeverity = 'info' | 'warning' | 'error' | 'critical';
|
|
export type AuditSource = 'user' | 'system' | 'auth' | 'webhook' | 'cron' | 'job';
|
|
|
|
export interface AuditLogParams {
|
|
/** Null for system-generated events. */
|
|
userId: string | null;
|
|
/** Null for system-level events not tied to a port. */
|
|
portId: string | null;
|
|
action: AuditAction;
|
|
entityType: string;
|
|
entityId: string;
|
|
fieldChanged?: string;
|
|
oldValue?: Record<string, unknown>;
|
|
newValue?: Record<string, unknown>;
|
|
metadata?: Record<string, unknown>;
|
|
/** Optional. Services that don't have request context (e.g. background
|
|
* jobs, internal helpers) may omit. */
|
|
ipAddress?: string;
|
|
userAgent?: string;
|
|
/** Defaults to 'info'. Bump to 'warning' for permission_denied,
|
|
* 'error' for failed background jobs / webhook DLQ, 'critical' for
|
|
* hard-deletes / security-relevant events. */
|
|
severity?: AuditSeverity;
|
|
/** Defaults to 'user'. Use 'auth' for session lifecycle,
|
|
* 'webhook' for delivery events, 'job' / 'cron' / 'system' for
|
|
* background work. The inspector filters on this column. */
|
|
source?: AuditSource;
|
|
}
|
|
|
|
// Lower-cased key fragments. A metadata key is masked if any fragment is
|
|
// contained as a substring after camelCase→snake + lowercase + kebab→snake
|
|
// normalization. Substring match catches `recipientEmail`, `sent_to_email`,
|
|
// `userEmail`, `from_address`, `phone_number`, `passwordHash`, `firstName`,
|
|
// `postalCode`, `dateOfBirth`, etc.
|
|
const SENSITIVE_KEY_FRAGMENTS = [
|
|
'email',
|
|
'phone',
|
|
'password',
|
|
'token',
|
|
'credentials',
|
|
'secret',
|
|
'api_key',
|
|
'apikey',
|
|
'auth',
|
|
'authorization',
|
|
'cookie',
|
|
'address', // physical/mailing addresses
|
|
'city',
|
|
'postal',
|
|
'country',
|
|
'dob',
|
|
'date_of_birth',
|
|
'birth',
|
|
'tax_id',
|
|
'taxid',
|
|
'national_id',
|
|
'ssn',
|
|
'passport',
|
|
'iban',
|
|
'card_number',
|
|
'cvv',
|
|
'recipient',
|
|
'first_name',
|
|
'last_name',
|
|
'full_name',
|
|
'fullname',
|
|
];
|
|
|
|
function isSensitiveKey(key: string): boolean {
|
|
const k = key
|
|
.replace(/([a-z0-9])([A-Z])/g, '$1_$2')
|
|
.toLowerCase()
|
|
.replace(/[-]/g, '_');
|
|
return SENSITIVE_KEY_FRAGMENTS.some((frag) => k.includes(frag));
|
|
}
|
|
|
|
function maskString(val: string): string {
|
|
return val.length > 4 ? `${val.slice(0, 2)}***${val.slice(-2)}` : '***';
|
|
}
|
|
|
|
function maskValue(value: unknown, depth: number): unknown {
|
|
if (depth > 4) return '[depth-limit]';
|
|
if (value === null || value === undefined) return value;
|
|
if (typeof value === 'string') return maskString(value);
|
|
if (Array.isArray(value)) return value.map((v) => maskValue(v, depth + 1));
|
|
if (typeof value === 'object') {
|
|
// Recurse into nested object — only mask keys that themselves look
|
|
// sensitive. Parents stay traversable.
|
|
return maskObject(value as Record<string, unknown>, depth + 1);
|
|
}
|
|
// Non-string primitives (number/boolean/bigint/symbol) at sensitive keys
|
|
// are passed through unchanged. The original contract was "only mask
|
|
// strings" — a number at an `email` key is a type error upstream and
|
|
// shouldn't be silently replaced with `***`.
|
|
return value;
|
|
}
|
|
|
|
function maskObject(data: Record<string, unknown>, depth: number): Record<string, unknown> {
|
|
if (depth > 4) return { _truncated: '[depth-limit]' };
|
|
const masked: Record<string, unknown> = {};
|
|
for (const [key, value] of Object.entries(data)) {
|
|
if (isSensitiveKey(key)) {
|
|
masked[key] = maskValue(value, depth + 1);
|
|
} else if (value && typeof value === 'object' && !Array.isArray(value)) {
|
|
masked[key] = maskObject(value as Record<string, unknown>, depth + 1);
|
|
} else if (Array.isArray(value)) {
|
|
masked[key] = value.map((v) =>
|
|
v && typeof v === 'object' && !Array.isArray(v)
|
|
? maskObject(v as Record<string, unknown>, depth + 1)
|
|
: v,
|
|
);
|
|
} else {
|
|
masked[key] = value;
|
|
}
|
|
}
|
|
return masked;
|
|
}
|
|
|
|
/**
|
|
* Masks sensitive field values to prevent PII or secrets from being stored
|
|
* verbatim in the audit log (SECURITY-GUIDELINES.md §5.2).
|
|
*
|
|
* Strings are replaced with a partial mask - first 2 chars + *** + last 2 chars.
|
|
* Walks nested objects/arrays so e.g. `{recipient: {email: "a@b"}}` masks
|
|
* the inner value too.
|
|
*/
|
|
export function maskSensitiveFields(
|
|
data?: Record<string, unknown>,
|
|
): Record<string, unknown> | undefined {
|
|
if (!data) return undefined;
|
|
return maskObject(data, 0);
|
|
}
|
|
|
|
/**
|
|
* Computes a field-level diff between two records.
|
|
* Returns one entry per changed field with the old and new values.
|
|
*/
|
|
export function diffFields(
|
|
oldRecord: Record<string, unknown>,
|
|
newRecord: Record<string, unknown>,
|
|
): Array<{ field: string; oldValue: unknown; newValue: unknown }> {
|
|
const changes: Array<{ field: string; oldValue: unknown; newValue: unknown }> = [];
|
|
for (const key of Object.keys(newRecord)) {
|
|
if (JSON.stringify(oldRecord[key]) !== JSON.stringify(newRecord[key])) {
|
|
changes.push({ field: key, oldValue: oldRecord[key], newValue: newRecord[key] });
|
|
}
|
|
}
|
|
return changes;
|
|
}
|
|
|
|
/**
|
|
* Inserts an audit log entry into the database.
|
|
*
|
|
* This function NEVER throws - errors are caught and logged so that an audit
|
|
* failure never rolls back or disrupts the parent operation.
|
|
*/
|
|
// Some actions get a default severity bump so callers don't have to
|
|
// remember; explicit `severity` on the call still wins.
|
|
const DEFAULT_SEVERITY_BY_ACTION: Partial<Record<AuditAction, AuditSeverity>> = {
|
|
permission_denied: 'warning',
|
|
hard_delete: 'critical',
|
|
// L-AU01: explicit severities so the row badge in /admin/audit lights
|
|
// up correctly. Without these, security-relevant verbs landed as
|
|
// generic 'info' grey rows next to read events.
|
|
password_change: 'warning',
|
|
portal_invite: 'info',
|
|
portal_activate: 'info',
|
|
portal_password_reset_request: 'warning',
|
|
portal_password_reset: 'warning',
|
|
revoke_invite: 'warning',
|
|
request_gdpr_export: 'info',
|
|
send_gdpr_export: 'info',
|
|
request_hard_delete_code: 'warning',
|
|
outcome_set: 'info',
|
|
outcome_cleared: 'info',
|
|
// Webhook lifecycle defaults to warning when a delivery fails.
|
|
webhook_failed: 'warning',
|
|
webhook_dead_letter: 'error',
|
|
job_failed: 'error',
|
|
};
|
|
const AUTH_ACTIONS = new Set<AuditAction>(['login', 'logout', 'password_change']);
|
|
|
|
export async function createAuditLog(params: AuditLogParams): Promise<void> {
|
|
try {
|
|
const severity = params.severity ?? DEFAULT_SEVERITY_BY_ACTION[params.action] ?? 'info';
|
|
const source = params.source ?? (AUTH_ACTIONS.has(params.action) ? 'auth' : 'user');
|
|
|
|
await db.insert(auditLogs).values({
|
|
portId: params.portId,
|
|
userId: params.userId,
|
|
action: params.action,
|
|
entityType: params.entityType,
|
|
entityId: params.entityId,
|
|
fieldChanged: params.fieldChanged ?? null,
|
|
oldValue: maskSensitiveFields(params.oldValue) ?? null,
|
|
newValue: maskSensitiveFields(params.newValue) ?? null,
|
|
// Mask metadata too — the audit found portal-auth, crm-invite,
|
|
// hard-delete, and email-accounts services were writing raw emails
|
|
// into this column.
|
|
metadata: maskSensitiveFields(params.metadata) ?? null,
|
|
ipAddress: params.ipAddress ?? null,
|
|
userAgent: params.userAgent ?? null,
|
|
severity,
|
|
source,
|
|
});
|
|
} catch (err) {
|
|
// Strip old/new values from the log to avoid secondary exposure of the data
|
|
// that just failed to persist.
|
|
logger.error(
|
|
{
|
|
err,
|
|
audit: {
|
|
userId: params.userId,
|
|
portId: params.portId,
|
|
action: params.action,
|
|
entityType: params.entityType,
|
|
entityId: params.entityId,
|
|
},
|
|
},
|
|
'Failed to write audit log',
|
|
);
|
|
}
|
|
}
|