# Audit Log Audit (AU-01-14) — agent #4 **Headline:** Core write path solid; major mutations all audit; mask helper covers expected PII; FTS indexed; AU-11 fix complete. Two HIGH issues: encrypted credential ciphertext bypasses masking (key is `"value"`) and `toggleAccount` mutation is silent. **Counts:** 0 critical · 2 high · 4 medium · 4 low --- ## 🟠 HIGH AU-01a: `toggleAccount` writes no audit row - **File:** `src/lib/services/email-accounts.service.ts:86-116` - **What:** Sets `isActive` on email account with no `createAuditLog` call. `connectAccount` (line 70) and `disconnectAccount` (line 139) do, but enable/disable in between is silent. - **Why it matters:** Silently disabling an email account suppresses bounce-detection or reroutes replies — compliance gap on a security-relevant config change. - **Suggested fix:** Add `void createAuditLog({ action: 'update', entityType: 'email_account', entityId: accountId, newValue: { isActive: data.isActive }, ... })` inside `toggleAccount`. ## 🟠 HIGH AU-02: Encrypted credential ciphertext stored in audit log without masking - **File:** `src/lib/services/settings.service.ts:66-76` + `src/lib/services/sales-email-config.service.ts:281-299` - **What:** `updateSalesEmailConfig` calls `upsertSetting('sales_smtp_pass_encrypted', , portId, meta)`. `upsertSetting` records `newValue: { value: '' }`. `maskSensitiveFields` checks JSON keys against `SENSITIVE_KEY_FRAGMENTS`; the wrapping key `"value"` isn't in the list. Ciphertext lands verbatim in `audit_logs.new_value`. - **Why it matters:** Audit log is readable by all admins with `admin.view_audit_log`. DB read access exfils ciphertext; if `EMAIL_CREDENTIAL_KEY` is ever compromised, the historical audit log becomes a credential store. Industry standard: store only `credentialUpdated: true` for credential changes. - **Suggested fix:** In `upsertSetting`, detect when key ends with `_encrypted` (or accept `redactValue?: boolean` flag) and record `newValue: { value: '[redacted]' }`. ## 🟡 MEDIUM AU-03: FTS `search_text` covers only 4 fields; placeholder text misleads - **File:** `src/lib/db/migrations/0014_black_banshee.sql:47-55` + `src/components/admin/audit/audit-log-list.tsx:360` - **What:** `search_text` GENERATED ALWAYS = `action || entity_type || entity_id || user_id`. Search input placeholder reads "entity id, action, vendor…" — implies you can search inside `metadata`/`new_value`. Searching "vendor" returns zero rows silently. - **Suggested fix:** Change placeholder to "action name, entity id, user id…" OR add `metadata` to GENERATED expression with `jsonb_to_tsvector` (larger index). ## 🟡 MEDIUM AU-08: Admin audit log shows field names but no old→new diff - **File:** `src/components/admin/audit/audit-log-list.tsx:290-305` + `src/components/admin/audit/audit-log-card.tsx:84-91` - **What:** "Changes" column renders `Object.keys(newValue).slice(0,3).join(', ')` — no old→new diff, no row-expand. Dashboard `activity-feed.tsx` has working `buildDiffLine()` with 3 diff shapes, unused here. - **Why it matters:** Compliance audits can't confirm before/after state from UI alone; admins must dig into raw JSON. - **Suggested fix:** Add row-expand or detail sheet using `buildDiffLine` from activity-feed.tsx. ## 🟠 AU-10: Cascade-archived interests produce no individual audit rows - **File:** `src/lib/services/clients.service.ts:578-618` - **What:** `archiveClient` batch-archives open interests, writes ONE `entityType: 'client'` row with `newValue: { cascadedInterestIds: [...] }`. No per-interest rows. `search_text` doesn't include `new_value`, so searching for an interest ID returns nothing. - **Why it matters:** Auditor querying for a specific archived interest sees no archive event; must know to look at parent client row. - **Suggested fix:** Loop over `archivedInterestIds` and emit per-interest `createAuditLog({ action: 'archive', entityType: 'interest', entityId, metadata: { cascadeSource: 'client_archive', clientId } })` (fire-and-forget). ## 🟡 MEDIUM AU-12: No audit log CSV export endpoint - **File:** (absent — no `src/app/api/v1/admin/audit/export/route.ts`) - **What:** No download button, no API. Expenses domain has reference impl at `src/app/api/v1/expenses/export/csv/route.ts`. - **Why it matters:** GDPR / marina licensing audits often require exports. - **Suggested fix:** `GET /api/v1/admin/audit/export/csv` reusing `searchAuditLogs` + filter params. ## 🟡 MEDIUM AU-13: Outcome change uses `action: 'update'`, not distinct verb - **File:** `src/lib/services/interests.service.ts:1047-1058` - **What:** `setInterestOutcome`/`clearInterestOutcome` log `action: 'update'` with `metadata.type: 'outcome_set'/'outcome_cleared'`. No `outcome_change` in `AuditAction` or filter dropdown. `metadata.type` not in `search_text` — FTS can't isolate. - **Suggested fix:** Add `'outcome_change'` to `AuditAction` union; use in both functions; add to dropdown; add to `DEFAULT_SEVERITY_BY_ACTION` as `'warning'`. ## 🟢 LOW AU-14: Tier map sparse; new actions default to 'info' - **File:** `src/lib/audit.ts:220-222` - **What:** Only 2 entries (`permission_denied: 'warning'`, `hard_delete: 'critical'`). `password_change`, `portal_activate`, `revoke_invite`, `branding.logo.uploaded`, `rule_evaluated` all default to `'info'`. Severity≥warning filter misses security-relevant events. - **Suggested fix:** Add `password_change/portal_activate/revoke_invite: 'warning'`. `reconcile_manual` is in `metadata.type` — add `severity: 'warning'` at the call site in `berths.service.ts`. ## 🟢 LOW AU-14b: Action filter dropdown missing 12 verbs - **File:** `src/components/admin/audit/audit-log-list.tsx:393-415` - **What:** Dropdown has 20 actions; missing `branding.logo.*`, `rule_evaluated`, `revoke/resend_invite`, `request/send_gdpr_export`, `password_change`, `portal_invite/activate/password_reset_request/password_reset`. Free-text partially compensates. - **Suggested fix:** Add missing action verbs. ## 🟢 LOW AU-14c: Entity-type filter missing several domains - **File:** `src/components/admin/audit/audit-log-list.tsx:88-102` - **What:** Missing `document_folder`, `file`, `company`, `yacht`, `email_account`, `audit_log`, `backup_job`. Free-text on `entity_type` (in tsvector) works; dropdown is convenience. - **Suggested fix:** Add missing entity types. ## 🟢 LOW AU-14d: Dead code — `listAuditLogs` (ILIKE) in `audit.service.ts` - **File:** `src/lib/services/audit.service.ts` - **What:** `listAuditLogs` exported but zero import sites. Admin route uses `searchAuditLogs` exclusively. ILIKE search is dead. - **Why it matters:** Future dev might wire it up bypassing GIN index → seq scans at scale. - **Suggested fix:** Delete `audit.service.ts` or mark `@deprecated`. --- ## ✅ Passing - AU-01 (10 sampled mutating endpoints all audit: clients/interests/companies/berths/documents/folders/tags/roles/settings/files create + update + archive) - AU-02 password/token fragment masking covers `password`, `passwordHash`, `token`, `secret`, `api_key`, `apikey`, `auth`, `cookie`, `credentials` recursively up to depth 4. `email-accounts.service.ts` correctly logs only `metadata: { emailAddress, provider }`; `credentialsEnc` stripped before any JSON serialization. - AU-04 action filter wired (exact `eq()` filter) - AU-05 entity-type filter wired (same path) - AU-06 user filter wired (UUID exact match) - AU-07 date-range filter (ISO strings → Date → gte/lte; UI validates inversion) - AU-09 reconcile_manual tag in metadata at `berths.service.ts:473` - AU-11 permission_denied feed filter at `src/components/dashboard/activity-feed.tsx:185-189` (`i.action !== 'permission_denied'`); admin page correctly displays them with `'bg-red-800'` badge