Files
pn-new-crm/src/lib/audit.ts
Matt eaab14943b feat(post-audit): Phase 3 EOI overrides + 3c spawn + 3d promote + Phase 4 worker
Phase 3b — EOI dialog field overrides:
- New EoiOverridesInput shape (clientEmail / clientPhone / yachtName)
  threaded through generate-and-sign validator + both pathways
  (in-app pdf-lib fill, Documenso template generate).
- src/lib/services/eoi-overrides.service.ts applies side-effects in one
  transaction: useOnlyForThisEoi writes documents.override_* and stops;
  setAsDefault demotes the prior primary + promotes (existing contactId)
  or inserts + promotes (fresh value); neither flag inserts a non-primary
  client_contacts row for future dropdown reuse.
- Document override columns persisted post-insert, with a 1-minute
  source_document_id backfill on freshly inserted contact rows.
- eoi-context route returns available.{emails, phones} so the dialog
  can render combobox options.
- <OverridableContactField> in eoi-generate-dialog.tsx renders the
  combobox + manual input + 2 checkboxes per field with mutually
  exclusive intent semantics.

Phase 3c — yacht spawn from EOI dialog:
- YachtForm gains createExtras + onCreated callbacks; the EOI dialog
  opens it as a nested Sheet pre-filled with the linked client as owner.
  On save the new yacht is stamped source='eoi-generated' and the
  interest is PATCHed with the new yachtId so the EOI context reflows.

Phase 3d — promote-to-primary + audit + [EOI] badge:
- POST /api/v1/clients/:id/contacts/:contactId/promote-to-primary
  (transactional demote+promote via promoteContactToPrimary).
- src/lib/audit.ts AuditAction type adds eoi_field_override,
  promote_to_primary, eoi_spawn_yacht (DB column is free-text).
- ContactsEditor surfaces an [EOI] badge on non-primary rows where
  source='eoi-custom-input'.

Phase 4 — worker + TOD picker:
- processOverdueReminders refactored to UPDATE...RETURNING with a
  fired_at IS NULL gate so parallel workers can't double-fire. Uses
  the idx_reminders_due_unfired partial index from migration 0072.
- /settings gets a "Default reminder time" time-of-day picker; the
  value lands in user_profiles.preferences.digestTimeOfDay (validated
  HH:MM at the route). <ReminderForm> seeds its dueAt from this
  preference via a React-Query me-prefs fetch.

Phase 6 hardening:
- IMAP bounce poller strips whitespace from IMAP_PASS so a copy-paste
  of Google Workspace's 16-char App Password formatted as
  "abcd efgh ijkl mnop" still authenticates. Workspace activation
  procedure documented in MASTER-PLAN §Phase 6 (was previously written
  to CLAUDE.md, which was bloat — moved to the plan).

Quality gates: 1374/1374 vitest, tsc clean, lint 0 errors.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-18 16:18:03 +02:00

298 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 lowercase + snake/kebab normalization.
// Substring match catches `recipientEmail`, `sent_to_email`, `userEmail`,
// `attempted_email`, `from_address`, `phone_number`, `passwordHash`, etc.
const SENSITIVE_KEY_FRAGMENTS = [
'email',
'phone',
'password',
'token',
'credentials',
'secret',
'api_key',
'apikey',
'auth',
'authorization',
'cookie',
'address', // physical/mailing addresses
'dob',
'date_of_birth',
'birthdate',
'tax_id',
'taxid',
'national_id',
'ssn',
'passport',
'iban',
'card_number',
'cvv',
'recipient', // e.g. recipientEmail catches the parent too — preserves intent
'first_name',
'last_name',
'full_name',
'fullname',
];
function isSensitiveKey(key: string): boolean {
const k = key.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',
);
}
}