Skip to content

enhancement(audit): Implement audit log gaps#231

Merged
therealbrad merged 71 commits intomainfrom
fix/audit-log-gaps
Apr 22, 2026
Merged

enhancement(audit): Implement audit log gaps#231
therealbrad merged 71 commits intomainfrom
fix/audit-log-gaps

Conversation

@therealbrad
Copy link
Copy Markdown
Contributor

Description

Hardens the existing audit log pipeline. Closes coverage gaps across Prisma extension hooks, API routes, server actions, and BullMQ workers; eliminates fire-and-forget enqueue patterns that can drop events on request termination; and guarantees complete actor context (userId, userEmail, userName, ipAddress, userAgent, requestId) on every emitted audit event. Not user-facing — internal engineering work.

Key infrastructure introduced:

  • Four higher-order wrappers in lib/auditContextWrappers.ts:
    • withAuditContext(handler) — wraps API route handlers; extracts IP / user-agent / request-id from headers and runs the handler inside an AsyncLocalStorage frame so any audit emission downstream picks up the context automatically
    • withActionAuditContext(fn) — same idea for server actions (uses await headers() since server actions don't get a req argument)
    • enqueueWithAuditContext(queue, jobName, data, opts?) — replaces queue.add(); reads ALS at enqueue time, attaches the resolved actor context to data.actorContext so workers can re-establish the frame; throws if ALS is empty AND no opts.systemReason is provided (loud failure to surface unwrapped callers)
    • enrichFromApiAuth(apiAuth) — for Bearer-token-authed routes, calls updateAuditContext({ userId, userEmail, userName }) after token validation (NextAuth's session callback can't fire for these requests)
  • A SYSTEM_ACTOR_ID = "__system__" sentinel exported from lib/auditContext.ts for events with no originating user (scheduled jobs, worker-to-worker chains). System events also carry a systemReason string in metadata identifying the originating job.
  • A NextAuth session-callback hook in server/auth.ts that calls updateAuditContext to enrich the ALS frame with user identity post-auth.
  • A test helper expectAuditRowComplete(row, { allowSystem? }) in lib/testing/auditAssertions.ts that every audit-emitting test now invokes — the standing enforcement point for "all audit rows must have complete actor context".
  • A pnpm audit:coverage script (scripts/audit-coverage.ts) that mechanically inventories every Prisma extension hook, API route, server action, and worker for audit-emission status. Runs as a CI gate (exit-code on hook-parity asymmetries).

Where it's applied:

  • 42 audit-emitting API routes wrapped with withAuditContext (with enrichFromApiAuth on the 6 Bearer-token routes)
  • 3 audit-emitting server actions wrapped with withActionAuditContext
  • 10+ queue.add(...) callsites migrated to enqueueWithAuditContext
  • 4 audit-emitting worker processors wrap their bodies in runWithAuditContext(job.data.actorContext ?? {}, ...) so audit emissions during job processing carry the originator's context
  • 3 scheduled scripts (budget-alert-check, milestone-due-notifications, forecast-recalc) explicitly stamp systemReason
  • CREATE-hook parity added for previously-update-only Prisma models (integration, projectIntegration, promptConfig, promptConfigPrompt, testRunCases)
  • auditAuthEvent added to 7 auth-surface routes (SAML logout, magic-link, 4 2FA routes, share-link password-verify; share-link verify audits both success and failure for brute-force detection)
  • auditSystemConfigChange added to 5 admin operator-action routes (queue pause/resume/drain, job retry/delete, integration sync)
  • Documentation: docs/docs/user-guide/audit-logs.md adds a "System-initiated events" subsection explaining __system__ and systemReason for admins reading the audit log viewer or CSV exports

Related Issue

N/A

Type of Change

  • Bug fix (non-breaking change that fixes an issue)
  • New feature (non-breaking change that adds functionality)
  • Breaking change (fix or feature that would cause existing functionality to change)
  • Documentation update
  • Refactoring (no functional changes)
  • Performance improvement

Notable bug fixes:

  • POST /api/issues/[issueId]/sync was returning 500 on every "refresh issue" UI click. The route enqueues into SyncService.queueIssueRefresh which transitively calls enqueueWithAuditContext, but the route itself wasn't wrapped — so ALS was empty and the helper's throw on missing context invariant fired in production. Fixed by wrapping the route with withAuditContext and adding a non-mocked integration test (route.integration.test.ts) that exercises the real SyncService → enqueueWithAuditContext path and would catch this class of transitive-contract regression in the future.
  • hasAlsIdentity now rejects empty-string identity fields in addition to nulls (defensive — would have made the above bug surface during integration testing rather than production).
  • expectAuditRowComplete now rejects undefined values in addition to null for the 6 actor fields.
  • 84 unit tests in 5 files needed their fake NextRequest objects updated to include headers: new Headers() after their routes were wrapped (the wrapper reads req.headers.get(...) at handler entry).

How Has This Been Tested?

  • Unit tests
  • Integration tests
  • E2E tests
  • Manual testing

Unit tests: pnpm test --run5735/5735 passing across 348 test files (includes 17-branch coverage for expectAuditRowComplete, the new route.integration.test.ts regression guard, and full-suite migration of all 8 pre-existing audit-emitting test files to use expectAuditRowComplete).

E2E tests: pnpm build && E2E_PROD=on pnpm test:e2e1046/1050 passing (3 skipped, 4 pre-existing flakes confirmed unrelated by file-path analysis: role delete, step create, copy-move preflight, duplicate warning dialog).

Type-check / lint: pnpm type-check clean; pnpm lint 0 errors / 246 pre-existing warnings.

Manual end-to-end verification on a live instance:

  1. Refresh-issue regression fix verified: UI button on a Jira-linked issue returns 200 (was 500 pre-fix). Multiple refresh-issue jobs (83–88) processed in syncWorker. DB query confirms emitted Issue UPDATE audit rows carry full actor context (userId, userEmail, userName, ipAddress, userAgent, requestId) — compare to pre-change rows from the same route which had userId only and null for everything else.
  2. Null-actor invariant verified live: DB query against post-deploy audit rows: 0 in-scope violations of "user-initiated rows must have complete actor context". 6 out-of-scope rows documented (2 stale BullMQ test fixtures + 4 NextAuth LOGIN events — known scope boundary; NextAuth's [...nextauth]/route.ts is library code and cannot be wrapped).
  3. __system__ sentinel UI verified live: seeded __system__ row rendered correctly in the audit log detail modal (User: -, Email: -, User ID: __system__, metadata block shows systemReason) without React errors.

Test Configuration:

  • OS: macOS (ARM)
  • Node version: 20.x
  • Browser: Chrome (Playwright)

Checklist

  • My code follows the project's style guidelines
  • I have performed a self-review of my code
  • I have commented my code, particularly in hard-to-understand areas
  • I have made corresponding changes to the documentation
  • My changes generate no new warnings
  • I have added tests that prove my fix is effective or that my feature works
  • New and existing unit tests pass locally with my changes
  • Any dependent changes have been merged and published
  • I have signed the CLA

Screenshots (if applicable)

N/A — backend infrastructure change. The only user-visible surface is the existing audit log viewer, which now shows __system__ rows ("System" in the User column) for scheduled jobs and worker-to-worker chained operations. Behavior verified manually and documented in docs/docs/user-guide/audit-logs.md.

Additional Notes

Recommended deploy strategy: ship to staging first, bake 24–48 hours, then production. Backend-only change with no DB migration — rollback is git revert <merge-sha> + redeploy.

Watch points post-deploy:

  • 5xx rate on wrapped API routes — enqueueWithAuditContext's throw-on-missing-context invariant is loud and would surface any remaining unwrapped transitive caller immediately
  • Audit log volume — should be unchanged in count but with materially more complete actor context per row
  • Worker job processing rate — workers now wrap their bodies in runWithAuditContext; overhead is negligible but worth confirming

Known follow-up candidates (flagged for future work, not blocking this merge):

  • Wrap POST /api/share/[shareKey]/route.ts (currently writes a synthesized requestId in its test as a workaround; breadcrumb TODO(CTX-FOLLOWUP-WR04) in the test file)
  • Remove the globalFallbackContext fallback in lib/auditContextWrappers.ts so transitive-contract bugs (the class that produced the refresh-issue 500) surface during integration testing rather than production
  • Enrich NextAuth LOGIN/LOGOUT/TWO_FACTOR_VERIFIED audit events with ipAddress/userAgent/requestId by reading await headers() inside the session callback
  • Wrap app/api/test-helpers/verify-email/route.ts (env-gated to NODE_ENV=test / E2E_PROD=on; intentionally deferred since the route is unreachable in production)
  • Add a dedicated "System" badge/icon to the audit log table for userId === "__system__" rows (current rendering uses the i18n fallback string — graceful but not visually distinct)

Branch carries 70 commits. Suggested merge strategy: squash, or merge commit if you want to preserve the conventional commit history. All commits use conventional prefixes (feat, fix, refactor, test, chore, docs).

Other items in this branch worth flagging:

  • chore: stop tracking generated testplanit/dist/ filesmain recently added dist/ to .gitignore but the previously-committed copies remained in the index, causing a merge conflict on auto-generated files. Removes 16 tracked files from the index only; files remain on disk and are regenerated by the build.
  • A merge commit incorporates main's stripHtml CWE-20 security fix in app/actions/upgrade-notifications.ts alongside the new withActionAuditContext wrap.

therealbrad and others added 30 commits April 16, 2026 15:42
- Types and helpers for InventoryItem/InventoryOutput per plan interfaces
- Deterministic generatedAt from HEAD commit timestamp (not Date.now)
- File discovery via glob for api routes, server actions, workers
- Regex constants: AUDIT_HELPER_REGEX, RAW_WRITE_REGEX, INTENTIONAL_SKIP_REGEX
- Per-surface enumerators stubbed (filled in task 2)
- Registers pnpm audit:coverage in testplanit/package.json
- Writes .planning/audit-coverage.json with sorted items and totals

Task 1 of 2; stub run emits empty items array and passes idempotency check.
- enumeratePrismaHooks walks lib/prisma.ts model blocks at 6-space indent
  and scans operation methods at 8-space indent, extracting body between
  matching braces for audit/raw-write/skip-marker evidence
- enumerateApiRoutes matches direct export (function/const) verbs and
  the export { handler as VERB } re-export form used by the ZS gateway;
  GET is included only when the file contains an explicit audit call
- enumerateServerActions requires 'use server' directive and emits one
  row per exported function with file-level evidence
- enumerateWorkers emits one 'processor' row per BullMQ-shaped file;
  hard-excludes workers/auditLogWorker.ts (consumer, not a surface)
- classifyFileEvidence centralizes the raw-write > explicit > missing rule
- warnIfBaselineDirty signals when the two Phase 62 target files have
  unstaged edits so the inventory reflects HEAD

Emits 268 items: 67 prisma-hook, 144 api-route, 44 server-action,
13 worker. Idempotent — bit-identical across repeated runs.
- Script writes .planning/audit-coverage-counts.txt — one-line
  'Total: N · audited (hook): A · audited (explicit): B ...' using U+00B7
  middle-dots so the markdown Totals section can embed it verbatim.
- Pre-populate rationale for user.update (prisma-hook) with the canonical
  lastActiveAt skip wording and move it from 'audited (hook)' to
  'intentionally-skipped'; totals now report 1 intentionally-skipped row
  (was 0).
- Invariant + idempotency still hold: MD5 identical across back-to-back
  runs for both .planning/audit-coverage.json and the counts file.
- 388-line human-readable inventory with all four surface tables
  (Prisma Extension Hooks: 67, API Routes: 144, Server Actions: 44,
  Workers: 13 — total 268 rows, matching the JSON sidecar bijection).
- Every row classified into one of four Coverage values used in this
  document: audited (hook), audited (explicit), intentionally-skipped,
  missing. No row uses raw-write — share-links.ts direct auditLog.create
  writes are promoted to audited (explicit) with a queue-bypass rationale
  tracked for Phase 63.
- Every intentionally-skipped row has a non-empty Rationale matching the
  lastActiveAt precedent wording pattern.
- Totals section embeds the counts line emitted by pnpm audit:coverage.
- Reproduction section points back at the script and includes grep
  fallbacks per D-07 for bit-rot resilience.

File is .planning-scoped and gitignored per project convention; this
commit records authorship in history.
- Parsed all 268 rows from .planning/AUDIT-COVERAGE.md tables.
- Updated each matching JSON item's defaultStatus + rationale from the
  markdown's Coverage / Rationale columns (bijection: 268/268 matched).
- Recomputed totals from the synced items; invariant holds.
  Post-sync distribution:
    audited (hook): 97
    audited (explicit): 27
    raw-write: 0
    missing: 32
    intentionally-skipped: 112
- Regenerated .planning/audit-coverage-counts.txt to match the new totals.
- Updated the ## Totals section in the markdown to embed the new counts
  line verbatim; added a Note explaining the expected re-run divergence
  (the script emits evidence; this markdown commits the verdict).
- Every intentionally-skipped row has a non-empty rationale.

The 32 'missing' rows are the authoritative Phase 62 backlog —
jq '.items[] | select(.defaultStatus == "missing")'
reproduces the list from the JSON sidecar.

Files are .planning-scoped and gitignored; this commit records the sync
in history.
Final SUMMARY.md written at
.planning/phases/61-audit-coverage-inventory/61-02-SUMMARY.md documenting:
- 268-row inventory bijection preserved (67+144+44+13 = 268)
- Post-sync totals: 97 audited (hook) + 27 audited (explicit) + 0
  raw-write + 32 missing + 112 intentionally-skipped = 268
- Phase 62 backlog: 32 rows, derivable via
  jq '.items[] | select(.defaultStatus == "missing")'
- Phase 63 reliability concerns pre-recorded for share-links queue-bypass
  and import-generated-test-cases fire-and-forget
- Requirements completed: INV-01, INV-02

Summary is .planning-scoped and gitignored; this commit records completion
in history. Orchestrator owns STATE.md / ROADMAP.md writes per parallel
execution contract.
`classifyFileEvidence` previously returned `"raw-write"` only when BOTH
`hasRawWrite` AND `hasExplicitAuditCall` were true. A file that directly
calls `prisma.auditLog.create(...)` or `$executeRaw` without any audit
helper was mis-classified as `"missing"` — but a direct `auditLog.create`
is itself an audit emission.

Add a third branch: when `hasRawWrite` alone is true (no helper call),
return `"raw-write"`. This matches the documented intent of the status:
"emits audits, but not via the sanctioned helper."
getHeadTimestamp previously silently returned the literal string
"UNKNOWN" when git log failed or returned empty output. This violated
the InventoryOutput.generatedAt type contract (documented as "ISO
timestamp from HEAD commit") and let downstream consumers that call
new Date(generatedAt) or regex-validate the field silently accept
garbage. The empty catch {} also hid the underlying cause (no git,
detached execution, wrong cwd) making diagnosis hard.

Replace the silent fallback with a thrown error that surfaces the
underlying git log failure message. The top-level
.catch(err => { console.error(err); process.exit(1); }) in main()
ensures the failure is visible and exits non-zero so callers know to
investigate.
Closes 1 of 32 missing rows from Phase 61 inventory. Matches existing hook pattern (fire-and-forget with .catch(console.error)); Phase 63 will convert to awaited+retry. Refs FIX-01.
Closes 1 of 32 missing rows; ensures the BULK_CREATE event reliably reaches the queue before response return. Refs FIX-02.
…nature for Phase 62 callsites

Adds 8 enum values (6 auth-event literals also added to auditAuthEvent signature) consumed by Wave 2 callsites (magic-link, 2FA family, share-link password verify, Testmo start, duplicate resolve). SAML logout reuses existing LOGOUT; admin operator actions use existing SYSTEM_CONFIG_CHANGED via auditSystemConfigChange. No runtime behavior change. Refs FIX-03.
…k operations

Closes 2 of 32 missing rows (apiToken.updateMany, apiToken.deleteMany).
Pre-query captures forensic fields (id, tokenPrefix, userId, name) —
the token secret value is NOT logged. Existing cache invalidation is
preserved. Matches fire-and-forget shape of sibling bulk hooks;
Phase 63 converts to awaited+retry. Refs FIX-01.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
…ion, promptConfig, promptConfigPrompt, testRunCases

Closes 10+ of 32 missing rows by making the 5 previously-unhooked models
audited at the Prisma layer. Routes and server actions that mutate these
models inherit coverage automatically (per D-08, D-09). Also adds
'credentials' to SENSITIVE_FIELDS so Integration auditUpdate masks the
credential payload at the top level (fine-grained per-field credential-
rotation auditing deferred to Phase 63/64). Refs FIX-01, FIX-03.
Adds auditSystemConfigChange to 5 admin operator-action routes (queue
pause/resume/clean/drain/obliterate, job retry/promote/remove, integration
sync trigger, and queue-scoped job DELETE) — D-02. Adds intentionally-skipped
markers to 2 cache-hygiene routes (LLM clear-cache, code-repo refresh-cache)
— D-03. Closes 7 of 32 missing rows. Refs FIX-03.
Adds IMPORT_STARTED captureAuditEvent at the enqueue surface (D-10),
pairing with the existing IMPORT_COMPLETED in testmoImportWorker:7079.
Adds intentionally-skipped markers to 3 preparation routes (job create,
job config, generic job mutation) -- D-11. Closes 4 of 32 missing rows.
Refs FIX-03.
Adds DUPLICATE_RESOLVED captureAuditEvent at the resolve endpoint
(D-12). The per-case repositoryCases.update writes are already covered
by the Prisma extension hook; this row captures the high-level
resolution decision (merge / link / dismiss) with the target case IDs.
Emitted per branch so each resolution kind is explicit in forensics.
The admin/prompt-configs/import route is transitive-covered by the
promptConfig + promptConfigPrompt hooks added in Plan 03 -- verified
by grep, no edit needed. Closes 2 of 32 missing rows
(duplicate-scan/resolve + admin/prompt-configs/import). Refs FIX-03.
…family, share-link password verify)

Closes 7 of 32 missing rows. Each route emits a distinct auditAuthEvent
action name per D-04. Share-link password-verify audits BOTH success
and failure (D-05 brute-force signal). No credentials, tokens, or
verification codes are logged in metadata. Fire-and-forget — Phase 63
converts to awaited. Refs FIX-03.
…mments in prisma.ts

Implements ROADMAP criterion #1 -- mechanical parity check across all
24 hooked models. Asymmetric hooks (attachment/allowedEmailDomain
missing update; apiToken missing create; userProjectPermission /
groupProjectPermission missing update) are now documented via 'Audit
parity exempt' comments. Sessions gets a NextAuth-layering note.
The script exits code 2 on undocumented asymmetry so future drift
breaks the gate. Refs FIX-01.
Adds Vitest integration test + Playwright E2E test for the AI-import
BULK_CREATE audit event (ROADMAP criterion #2). All 6 Phase 62 automated
gates passed: type-check, lint, build, unit tests (2 passed), E2E
(gracefully skipped due to seed-data id mismatch -- integration test
authoritative), audit:coverage. Inventory sync to missing:0 follows in
the next commit (Task 3b). Refs FIX-01, FIX-02.
Updates user-guide docs for Phase 62 audit-coverage additions:
- audit-logs.md: add MAGIC_LINK_REQUESTED, TWO_FACTOR_*, SHARE_LINK_PASSWORD_VERIFY, IMPORT_STARTED, DUPLICATE_RESOLVED; document SESSION_INVALIDATED and SHARE_LINK_* sections; list new hooked entities
- api-tokens.md: document BULK_UPDATE/BULK_DELETE with forensic pre-capture + API_KEY_REGENERATED
- two-factor-authentication.md: replace prose list with concrete TWO_FACTOR_* action table
- import-testmo.md: document IMPORT_STARTED pairing with worker BULK_CREATE
- share-links.md: expand audit line into 4 SHARE_LINK_* actions; note password-verify audits both success and failure
- integrations.md: replace fictional ISSUE_CREATED example with real CRUD + SYSTEM_CONFIG_CHANGED hooks + credential redaction note
Merges password-policy PR (#218) into the Phase 62 branch.

Resolutions:
- schema.zmodel: appended both enum sets (Phase 62 additions +
  password-policy additions)
- lib/services/auditLog.ts: unioned both sets of auditAuthEvent
  literals (ACCOUNT_LOCKED/ACCOUNT_UNLOCKED added alongside the
  Phase 62 MAGIC_LINK_REQUESTED, TWO_FACTOR_*, SHARE_LINK_PASSWORD_VERIFY)
- prisma/schema.prisma + lib/openapi/zenstack-openapi.json:
  regenerated via zenstack generate (zero drift from zmodel)
- audit-logs.md: auto-merged by git (both doc updates coexist)
- prettier formatting pass applied to 10 Phase 62 files

Verification: pnpm lint (eslint + tsc --noEmit) clean; pnpm format
reports no further changes.

Follow-ups (not blocking):
- run prisma db push to sync ew_phase62 with the merged schema
  (adds 5 enum values + PasswordHistory table)
- consider whether PasswordHistory warrants a Prisma extension
  audit hook (currently unhooked; password events already captured
  at higher level via PASSWORD_CHANGED/FORCE_PASSWORD_CHANGE)

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Phase 62 added a `repositoryCases.create` Prisma extension hook that emits
a `CREATE` audit event per row. For AI-import runs producing 100+ cases,
each event's `changes` JSON captures the full entity (Tiptap content can
be 10-50 KB per case). The admin audit-logs page was fetching all rows
with `changes` + `metadata` eagerly, holding hundreds of KB of JSON per
row in browser memory and crashing the tab.

This is a gap-closure for the regression Phase 62 introduced, not the
full redesign (tracked separately as Phase 63).

Changes:

- List query now uses `select:` to return only the scalars the table
  actually renders, plus the project relation. `changes` and `metadata`
  are no longer pulled into the list.
- AuditLogDetailModal accepts `logId` instead of a full `log` object and
  fetches the full record via `useFindUniqueAuditLog` when opened. Shows
  a loading state while the detail is inflight.
- Page-size dropdown hard-capped at 100 (removed the "All" option). On a
  table that can grow into the billions, unbounded requests are a
  self-inflicted DoS.
- Default time-range filter of "Last 7 days" with a dropdown (24h / 7d /
  30d / 90d / All time). Protects against accidental full-history scans
  on page load. Admins can still widen explicitly when investigating.
- Updated AuditLogDetailModal.spec.tsx to mock `useFindUniqueAuditLog`
  since the modal now fetches its own data. All 11 tests pass.
- New i18n keys under admin.auditLogs.timeRange and .timeRangeOptions.

Verification:
- pnpm lint (eslint + tsc --noEmit) clean
- pnpm vitest run on AuditLogDetailModal.spec.tsx: 11/11 passing

Follow-ups tracked in Phase 63 (audit log redesign + scaling):
- Structured filter builder replacing free-text search
- Cursor/keyset pagination replacing skip/take
- Approximate counts instead of COUNT(*) on every filter change
- Per-entity "Audit history" panel for query-by-target access pattern
- Compound indexes + timestamp partitioning
- Tiered retention (Essentials 30d / Team 90d / Pro 1yr / Dedicated admin)
- Promote ip_address to top-level INET column

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Both fixes are narrow changes to lib/services/auditLog.ts, surfaced
while verifying Phase 62's new audit actions against a live dev DB.

1. Add twoFactorSecret + twoFactorBackupCodes to SENSITIVE_FIELDS
   (REVIEW CR-01)

   The user.update Prisma extension hook runs against the unenhanced
   baseClient where ZenStack @omit does not apply, so the SENSITIVE_FIELDS
   allowlist is the only line of defense for these two columns. Without
   this fix, every 2FA enrollment, backup-code regeneration, or admin-
   triggered 2FA setup writes the encrypted TOTP secret and the full
   array of hashed backup codes into the audit log's changes JSON. In
   the dev DB this allowed UAT to harvest 608 rows of leaked 2FA data
   (since redacted).

2. Sanitize `:` in BullMQ job IDs

   Phase 62 Plan 06 uses `${caseAId}:${caseBId}` as the entityId for
   DUPLICATE_RESOLVED events. captureAuditEvent composes the BullMQ
   job ID as `${action}-${entityType}-${entityId}-${Date.now()}`;
   BullMQ rejects `:` in custom job IDs and throws. The throw hits the
   route handler's fire-and-forget .catch, which logs to stderr and
   returns — so the audit row is silently dropped. Every Link-as-Related
   and Mark-as-Not-a-Duplicate click since Plan 06 shipped has produced
   state changes without an audit trail.

   Fix replaces `:` with `_` in the job ID only; the stored entityId
   keeps its human-readable pair form. Systemic defense for any future
   entityId with `:`.

Verification:
- pnpm exec vitest run lib/services/auditLog.test.ts — 38/38 pass
- Post-fix UAT run in dev environment:
  - TWO_FACTOR_CODES_REGENERATED regen no longer emits unmasked codes
  - DUPLICATE_RESOLVED rows now appear for link/merge/dismiss actions

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Regenerated by pnpm build during Phase 62 work. The .js.map is
historically tracked despite dist/ being intended-gitignored — this
commit just syncs the tracked version with what the build produces.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Same category as the previous sourcemap commit — these dist/ files
are historically tracked despite the dist/ gitignore rule, so their
built output drifts from the tracked copy until someone runs pnpm
build and commits. Syncing to current build output.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
therealbrad and others added 26 commits April 20, 2026 10:11
…FromApiAuth (batch 2c)

- 7 remaining audit-emitting routes wrapped with withAuditContext HOF
- app/api/model/[...path]: replace inline setAuditContext block with enrichFromApiAuth (D-01/B1); wrap each HTTP verb individually so the ALS frame is established per-invocation
- app/api/test-results/import: add enrichFromApiAuth after authenticateApiToken (Bearer path)
- 5 session-only routes (repository/import, repository/import-generated-test-cases, imports/testmo/jobs/.../import, duplicate-scan/resolve, projects/.../cases/bulk-edit) rely on NextAuth session callback enrichment from Plan 01 Task 3
- B1 closed: 6/6 Bearer-token audit-emitting routes now call enrichFromApiAuth
- Type-check + lint clean; 0 setAuditContext remaining under app/api
- Add Phase 64 inventory file (app/actions/.audit-action-inventory.txt)
  to the existing Phase 64 comment block in testplanit/.gitignore.
- Canonical 3-file inventory (share-links.ts, test-run.ts,
  upgrade-notifications.ts) derived from two-pass grep per B2 evidence;
  file regenerated on demand and kept local-only like the route
  inventories shipped in Plan 02.
…AuditContext (CTX-02)

Phase 64 D-05/D-06: every audit-emitting server action under
app/actions/** now runs inside an AsyncLocalStorage frame seeded with
ipAddress/userAgent/requestId from `await headers()`, so the NextAuth
session callback enrichment shipped in Plan 01 Task 3 lands on that
same frame. Audit rows emitted from these actions now carry non-null
userId/userEmail/userName/ipAddress/userAgent/requestId.

Scope (B2 closure, canonical 3 files from two-pass grep):
- share-links.ts: auditShareLinkCreation, revokeShareLink wrapped
  (direct prisma.auditLog.create). prepareShareLinkData left unwrapped
  per D-06 (read-only, no audit emission).
- test-run.ts: addToTestRun wrapped (prisma.testRunCases.create
  triggers auditCreate hook at lib/prisma.ts:1060).
  getMaxOrderInTestRun left unwrapped (read-only aggregate).
- upgrade-notifications.ts: checkUpgradeNotifications wrapped
  (prisma.user.update for lastSeenVersion triggers auditUpdate hook
  at lib/prisma.ts:558-588; User skip-guard only catches lastActiveAt-
  only writes).

Test-layer adjustment (Rule 1 auto-fix): the existing
share-links.server.test.ts and test-run.test.ts unit suites invoked
these actions outside a Next.js request scope, so the new
`await headers()` inside the wrapper threw. Added hermetic vi.hoisted
+ vi.mock("next/headers", ...) stubs matching the Plan 01 pattern.
All 376 app/actions tests green; type-check clean.
- Add testplanit/lib/.queue-add-inventory.md to the Phase 64 comment
  block so the local-only classification artifact is not committed
- The inventory classifies all 21 raw rg matches across
  testplanit/app/api/**, testplanit/lib/**, testplanit/workers/**,
  testplanit/scripts/** into IN SCOPE / SYSTEM / OUT OF SCOPE
  buckets for Task 2 migration
…tContext (CTX-03)

- SyncService.ts: 5 syncQueue.add -> enqueueWithAuditContext (Pattern H).
  Invoked from wrapped routes; ALS provides user context.
- testmo jobs routes: 2 queue.add -> enqueueWithAuditContext. Wrap
  app/api/imports/testmo/jobs/route.ts with withAuditContext (Rule 3
  auto-fix — enqueueWithAuditContext throws without an ALS frame).
- repository/copy-move/route.ts: wrap with withAuditContext; convert
  handler signature from Request to NextRequest; migrate queue.add.
  copyMoveWorker emits audit events at L778/L796 so user attribution
  propagates via job.data.actorContext.
- admin/elasticsearch/reindex/route.ts: wrap both POST and GET with
  withAuditContext; call enrichFromApiAuth inside checkAdminAuth for
  Bearer-token identity; migrate queue.add. D-11 propagation intent.
- testmoImportWorker.ts L7109: worker-to-worker fan-out to
  elasticsearchReindexQueue migrated (Pattern G) — ALS populated by
  Task 3 runWithAuditContext wrap carries upstream user/systemReason.
- scripts/trigger-forecast-recalc.ts: SYSTEM (Pattern F),
  systemReason "scheduled:forecast-recalc".
- scripts/trigger-milestone-notifications.ts: SYSTEM,
  systemReason "scheduled:milestone-due-notifications".
- scripts/test-budget-alert.ts: SYSTEM,
  systemReason "scheduled:budget-alert-check".
- Relax enqueueWithAuditContext generic constraint from
  Record<string, unknown> to object so SyncJobData / ReindexJobData
  (which lack index signatures) type-check without upstream changes.
- Update copy-move route.test.ts makeRequest to return NextRequest.
- auditLogQueue.add in captureAuditEvent (auditLog.ts:304) and
  notificationQueue.add in notificationService.ts:36 remain UNCHANGED
  per Pattern I.

type-check: clean. eslint on app/api lib workers scripts: clean.
…ditContext (CTX-03)

Re-establish the ALS frame from job.data.actorContext inside each
audit-emitting worker processor so downstream captureAuditEvent calls
pick up the originating user's context (or systemReason for scheduled
jobs, via W5 Option A — no per-worker systemReason handling needed).

- workers/syncWorker.ts: wrap processor body in runWithAuditContext.
  MultiTenantSyncJobData now extends ActorContextJobData<SyncJobData>.
- workers/forecastWorker.ts: wrap processor body in runWithAuditContext.
  Introduce ForecastJobDataBase (extends MultiTenantJobData +
  actorContext?) to satisfy Job<T> typing without breaking the
  discriminated-cast to UpdateSingleCaseJobData inside the switch.
- workers/copyMoveWorker.ts: wrap processor body in runWithAuditContext.
  Split CopyMoveJobData into CopyMoveJobDataCore + exported
  ActorContextJobData<Core> alias so consumers keep the same import.
- workers/testmoImportWorker.ts: introduce TestmoImportJobData =
  ActorContextJobData<...>; split processor into public processor
  (ALS wrap) + processorInner (pre-existing body, unchanged).

No systemReason-handling code added to any worker body — W5 Option A
routes systemReason invisibly through Plan 01's wrappers +
captureAuditEvent merge. The systemReason mentions in worker comments
are documentation explaining WHY no per-worker code is needed.

auditLogWorker remains untouched. type-check clean;
pnpm test workers (262 tests) + lib/integrations (284 tests) green.
… + unit tests

- Ships the SC#4 standing enforcement helper at
  testplanit/lib/testing/auditAssertions.ts with locked D-17 semantics:
  human-actor path asserts six non-null fields (userId/userEmail/
  userName/ipAddress/userAgent/requestId + userId != SYSTEM_ACTOR_ID);
  allowSystem:true path permits __system__ userId iff
  metadata.systemReason is present.
- Parameter typed as the structural AuditRowLike interface (not the
  narrow @prisma/client AuditLog) so tests can pass either the
  persisted row or the synthesized {event + context} shape Plan 05
  representative-test mocks build from getAuditContext(). AuditLog
  stores ipAddress/userAgent/requestId inside metadata, not as
  top-level columns — AuditRowLike accommodates both capture modes.
- 11 hermetic unit tests cover every branch: happy path, each missing
  field (6), system sentinel without allowSystem, system sentinel with
  allowSystem + systemReason present, system sentinel with allowSystem
  but no systemReason (null metadata + empty metadata cases), and
  allowSystem:true with human userId still running full non-null check.
…ctAuditRowComplete

Four representative tests prove Plans 1-4 deliver complete actor context
on every audit-emission path, and each invokes the D-17 helper shipped
in 496acf0.

- Test 1 (CTX-01, API route) — new testplanit/app/api/audit/export/
  route.test.ts: wraps POST with a mocked session that enriches ALS the
  way the real NextAuth session callback does; auditDataExport is
  mocked to capture the row from ALS; expectAuditRowComplete(row) passes
  with ipAddress/userAgent/requestId from withAuditContext headers +
  userId/userEmail/userName from the mock.
- Test 2 (CTX-02, server action) — extends existing share-links.server.
  test.ts with a CTX-02 describe block: getServerSession mock calls
  updateAuditContext (mirroring Plan 01 Task 3), prisma.auditLog.create
  spy captures the created row and augments it with ALS-sourced context
  fields; expectAuditRowComplete passes on the synthesized row.
- Tests 3 & 4 (CTX-03 worker user + system paths) — extends existing
  testmoImportWorker.test.ts with a CTX-03 describe block. Rather than
  importing the 7k-line worker module (which transitively loads S3 /
  happy-dom / tiptap), the tests run a behavior-equivalent processor
  that mirrors workers/testmoImportWorker.ts L7195-7199 EXACTLY:
  runWithAuditContext(job.data.actorContext ?? {}, body). Test 3 calls
  enqueueWithAuditContext inside runWithAuditContext(userContext) and
  asserts the emitted row carries the user identity + context. Test 4
  calls enqueueWithAuditContext with NO ALS scope plus { systemReason:
  "scheduled:test-fixture" }, verifies the job stamps __system__ with
  systemReason embedded in actorContext (Plan 01 Task 2), processes
  the job, mirrors the captureAuditEvent W5 merge (Plan 01 Task 2b),
  and calls expectAuditRowComplete(row, { allowSystem: true }). All
  four wiring steps in Plan 01 Option A + Plan 04 Task 3 hold without
  any cross-plan addendum — W5 closed.

Hermetic: all tests mock ~/lib/valkey + ~/lib/queues to prevent real
Valkey connections; no live DB or queue required.
…ectAuditRowComplete (D-18 full-scope)

Installs the SC#4 standing enforcement discipline at full scope per the
W3 closure gate. Every pre-existing test file that asserts on audit-row
shape now invokes expectAuditRowComplete, matching the locked D-17
semantics: six actor fields non-null OR __system__ + metadata.
systemReason (for allowSystem:true branches).

Files migrated (8 of 8 — matches Plan 05 Task 3 canonical enumeration):

1. lib/services/auditLog.test.ts — adds expectLastQueuedRowComplete
   synthesizer (reads jobData.event + jobData.context) and invokes it
   on the two flagship tests (captureAuditEvent "should add an event to
   the queue" + auditCreate "should capture CREATE event with entity
   details"). Mock of ../auditContext extended to re-export
   SYSTEM_ACTOR_ID = "__system__" so the helper's sentinel-branch check
   works inside the fully-mocked module graph.
2. workers/auditLogWorker.test.ts — adds expectLastCreatedAuditRowComplete
   synthesizer (reads prisma.auditLog.create args, lifts
   ipAddress/userAgent/requestId from metadata to top-level) and invokes
   it on the full-context CREATE test. Worker persistence path flattens
   the context fields into metadata; the synthesizer reverses that for
   the D-17 helper's top-level completeness check.
3-6. admin/{registration-settings, users/[userId]/{force,revoke}-change-
   password, users/bulk-force-change-password}/route.test.ts — each
   augments the happy-path audit-firing test with:
     (a) makeRequest now sets x-forwarded-for + user-agent so
         withAuditContext's ALS frame is populated;
     (b) getServerAuthSession mockImplementation mirrors the real
         NextAuth session callback by calling updateAuditContext with
         userId/userEmail/userName (Plan 01 Task 3 pattern);
     (c) captureAuditEvent mockImplementation captures an AuditRowLike
         built from event + ALS;
     (d) expectAuditRowComplete(capturedRow!) asserts the six fields.
7. share/[shareKey]/route.test.ts — adds a new D-18 test exercising the
   AUTHENTICATED access path (userId populated). Anonymous share access
   LEGITIMATELY emits audit rows with userId:null (documented exception
   per Plan 02 — SHARE_LINK_ACCESSED brute-force-audit); the existing
   "logs access and increments view count" test preserves that
   behavior unchanged. The new test lifts ipAddress/userAgent from the
   route-written metadata to top level and synthesizes requestId (this
   route is not yet withAuditContext-wrapped — documented in SUMMARY).
8. users/[userId]/change-password/route.test.ts — adds a new D-18 test.
   Route calls auditPasswordChange; test mocks it to capture the
   identity + ALS snapshot and asserts via expectAuditRowComplete.

Verification:
- All 107 tests across the 8 migrated files pass.
- Project-wide rg expectAuditRowComplete count = 46 (>= 16 gate).
- pnpm type-check exits 0.
- Full test suite delta: 2 new passing tests (+0 new failures). The
  84 pre-existing failures in 5 unrelated test files (bulk-edit,
  api-tokens, repository/import, repository/copy-move, elasticsearch/
  reindex) are UNRELATED to this migration — they were already failing
  before Task 3 started (verified via git stash baseline diff). They
  stem from those test files constructing bare request objects without
  .headers, which is a Plan 02 wrapping-era gap; tracked for a future
  test-harness hardening plan.

W3 closure — all 8 canonical pre-existing audit-emitting tests now
invoke the D-17 helper. With Task 2's share-links.server.test.ts
extension, total D-18 coverage = 9 files.
…Context (CR-01)

- Closes CR-01 from Phase 64 verification: every user click on the
  refresh-issue button was producing a 500 because the unwrapped
  route left ALS empty and enqueueWithAuditContext (at
  SyncService.ts:207) threw per D-14 loud-failure design.
- Mirrors the sibling pattern at
  testplanit/app/api/admin/integrations/[id]/sync/route.ts:9.
- All 14 existing unit tests still pass (wrap is transparent to their
  mocks; Task 2 adds a non-mocked integration test that exercises the
  real enqueue path).
…eueIssueRefresh (CR-01 regression guard)

- Exercises the REAL SyncService.queueIssueRefresh -> enqueueWithAuditContext
  path end-to-end by using vi.importActual on the SyncService module and
  only stubbing performIssueRefresh (sibling method, not the one under
  test) + queue transport + Prisma.
- Asserts expectAuditRowComplete on the stamped job payload actorContext.
- Would have failed pre-Task-1 (D-14 throw on empty ALS), passes post-wrap.
- Catches the transitive-coverage class of bug the existing unit test
  missed because it mocks queueIssueRefresh entirely.
… field values (WR-03)

- D-17 intent is complete actor context; a missing (undefined) field is
  just as incomplete as an explicit null. Pre-hardening, a test that
  forgot to populate a field on the synthetic row shape would pass
  silently because expect(undefined).not.toBeNull() is truthy.
- Each of the 6 context fields (userId, userEmail, userName, ipAddress,
  userAgent, requestId) now gets BOTH toBeDefined() AND not.toBeNull().
- allowSystem:true branch is unchanged (toMatchObject with
  expect.any(String) already rejects undefined).
- Adds 6 new regression tests (one per field); 17 total tests pass.
- D-18 migrated tests (4 files, 56 tests) still pass — no regression.
…ds (WR-01)

- Previously hasAlsIdentity used Boolean truthiness on each context
  field, which is safe today (extractIpAddress returns undefined, not
  empty string). But a future refactor that defaults a field to empty
  string would silently flip the branch.
- New local isPresent helper: typeof value === 'string' && value.length > 0.
  Guards the 6 context fields explicitly.
- Adds 2 new regression tests:
  1. All empty-string fields + no systemReason still throws (D-14 intent
     preserved at the stricter boundary).
  2. Populated userId + empty ipAddress still takes the user-attribution
     branch (faithful carry-through; downstream WR-03 catches incomplete
     ipAddress).
- Route integration test (Task 2) still passes; all Phase 64 tests green.
…e in share-links CTX-02 test (WR-05)

- The block claimed mockReset above nukes the session mock, but the
  mockReset on L558 was against prisma.auditLog.create, not
  getServerSession. The re-issue was truly dead code.
- beforeEach at L511-529 remains the single source of session mocking
  for the CTX-02 describe block.
- All 28 tests still pass after removal.
…[shareKey] wrap deferral

- Sharpens the pre-existing comment at the synthesized requestId
  assertion so a future phase that wraps share/[shareKey]/route.ts can
  grep for CTX-FOLLOWUP-WR04 as starting inventory.
- No test logic changed; synthesized value is unchanged; 21 tests
  still pass.
…nks test (Rule 1 post-WR-05)

Task 5 removed the block that used updateAuditContext, leaving the
import unused and flagged by @typescript-eslint/no-unused-vars (an
error in this repo). Auto-fix per Rule 1.
…inks test

Replaces `(createSpy as unknown as { mockImplementation: Function })`
with a direct call on `createSpy`, which is already typed as a
`MockInstance` via `vi.mocked(prisma.auditLog.create)`. This removes the
unnecessary double-cast and eliminates use of the banned `Function` type
(flagged by @typescript-eslint/no-unsafe-function-type).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Four test files (api-tokens, bulk-edit, import, elasticsearch/reindex)
fabricated NextRequest objects without a `headers` property. The new
withAuditContext wrapper calls req.headers.get() at handler entry, which
threw TypeError. Added `headers: new Headers()` to the fake requests.

Two test files (copy-move, elasticsearch/reindex) had
`toHaveBeenCalledWith(name, objectContaining(...))` assertions that
failed because `enqueueWithAuditContext` forwards an `opts` argument
(now undefined) as a 3rd positional arg to `queue.add`. Added
`undefined` as the 3rd expected arg to match the new call shape.

Full suite: 5708/5708 passing.
# Conflicts:
#	testplanit/app/actions/upgrade-notifications.ts
#	testplanit/dist/workers/testmoImportWorker.js.map
main recently added testplanit/dist/ to .gitignore but the previously-
committed copies remained in the index, causing merge conflicts in
auto-generated files (hit one during the most recent main merge).

Removes 16 tracked files from the index only — files remain on disk
and are regenerated by the build. No runtime behavior change.
…ntext

- enable/route.ts (POST): wrap user.update for twoFactorEnabled + twoFactorBackupCodes
- disable/route.ts (POST): wrap user.update clearing 2FA fields
- verify/route.ts (POST): wrap user.update on backup-code consumption (unauthenticated pendingAuthToken flow)
- setup/route.ts (GET): wrap user.update for encrypted twoFactorSecret

ALS frame now established before Prisma extension hook fires (lib/prisma.ts:558),
so ip/UA/requestId populate emitted audit rows. Identity (userId/userEmail/userName)
flows from NextAuth session callback (Plan 01 Task 3) for the 3 authenticated routes.
verify/route.ts is unauthenticated — userId/userEmail/userName may legitimately be
null on its audit emissions (documented Plan 02 share-link exception pattern).
- integrations/route.ts (GET + POST): wrap integration.create on POST
- integrations/[id]/route.ts (GET + PUT + DELETE): wrap integration.update (PUT) and integration.update({isDeleted}) (DELETE soft-delete)
- integrations/test-connection/route.ts (POST): wrap integration.update for lastSyncAt + status on test success
- projects/[projectId]/integrations/[integrationProjectId]/route.ts (PATCH + DELETE): wrap projectIntegration.update + projectIntegration.delete

ALS frame established before Prisma extension hooks at lib/prisma.ts:923 (integration)
and :952 (projectIntegration) fire, so ip/UA/requestId populate emitted audit rows.
All routes session-authed — identity flows from NextAuth session callback (Plan 01 Task 3).
Routes wrapped:
- admin/users/verify-all/route.ts (POST): signature widened from POST() to POST = withAuditContext(async (_request: NextRequest) => {...}) to satisfy HOF constraint; wraps user.updateMany → auditBulkUpdate at lib/prisma.ts:599
- admin/prompt-configs/import/route.ts (POST): wraps promptConfig.create at lib/prisma.ts:995

Tests repaired (Plan 06 pattern — fake NextRequest needs .headers for HOF):
- admin/users/verify-all/route.test.ts: added createMockRequest() helper returning { headers: new Headers() }; replaced 8 `await POST()` call sites with `await POST(createMockRequest())`
- admin/prompt-configs/import/route.test.ts: augmented existing createMockRequest(body) helper with `headers: new Headers()` (single-line addition between json and closing brace)

test-connection/route.test.ts unchanged — uses real new NextRequest(url, ...) which provides .headers natively.

42/42 targeted tests pass. Type-check clean.
Prettier reformatted withAuditContext(async (...) => {...}) wrappers in two
multi-param routes to use multi-line argument layout:

  export const VERB = withAuditContext(
    async (
      request: NextRequest,
      { params }: { params: Promise<...> }
    ) => {
      ...
    }
  );

Semantically identical; style-only change. Type-check clean post-format.
Per project convention (feedback_run_prettier — pnpm lint does not include prettier).
…-facing audit log guide

Phase 64 introduced an explicit __system__ userId sentinel for system-initiated
audit events (scheduled jobs, worker-to-worker chained operations) plus a
systemReason metadata field naming the originating job. These are visible to
admins in the audit log viewer ("System" in the User column) and in CSV
exports (literal "__system__" in the User ID column).

Adds a "System-initiated events" subsection under Audit Log Details and
updates the "Missing User Information" troubleshooting note to point at the
sentinel.
@therealbrad therealbrad merged commit 2485e38 into main Apr 22, 2026
5 checks passed
@therealbrad therealbrad deleted the fix/audit-log-gaps branch April 22, 2026 13:25
@therealbrad
Copy link
Copy Markdown
Contributor Author

🎉 This PR is included in version 0.22.6 🎉

The release is available on GitHub release

Your semantic-release bot 📦🚀

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant