Skip to content

feat(audit): add global backend audit trail - #114

Closed
humbeatbox wants to merge 2 commits into
mainfrom
gary
Closed

feat(audit): add global backend audit trail#114
humbeatbox wants to merge 2 commits into
mainfrom
gary

Conversation

@humbeatbox

@humbeatbox humbeatbox commented Jul 21, 2026

Copy link
Copy Markdown
Collaborator

Summary by CodeRabbit

  • New Features
    • Added comprehensive audit tracking for authentication, authorization, claims, items, uploads, notifications, photo sessions, and profile updates.
    • Added request correlation IDs via the X-Request-Id response header.
    • Added scheduled-job run tracking for easier audit correlation.
    • Added safeguards to prevent sensitive data from being recorded in audit details.
  • Bug Fixes
    • Improved transactional consistency between database changes and required audit records.
    • Added audit records for denied actions and failed authentication attempts.
  • Tests
    • Expanded coverage for audit behavior, security, transactions, scheduled jobs, and sensitive-data protection.

@coderabbitai

coderabbitai Bot commented Jul 21, 2026

Copy link
Copy Markdown

Review Change Stack

Warning

Review limit reached

@humbeatbox, you've reached your PR review limit, so we couldn't start this review.

Next review available in: 42 minutes

Enable usage-based reviews in Billing to review now. Otherwise, wait until the next included review is available.
You're only billed for reviews past your plan's rate limits ($0.25/file).

How can I continue?

After more reviews become available, a review can be triggered using the @coderabbitai review command as a PR comment. Alternatively, push new commits to this PR.

To avoid repeated limits, reduce automatic review volume by pausing incremental auto-reviews earlier, using label-based review opt-in, excluding WIP or generated PR titles, or requesting reviews manually when the PR is ready. If your team needs uninterrupted high-volume reviews, an organization admin can enable usage-based reviews.

How do review limits work?

CodeRabbit enforces per-developer PR review limits for each organization. Most developers receive the normal plan review availability.

For paid Pro and Pro+ PR reviews, CodeRabbit uses adaptive limits for sustained high-volume activity. When a developer's recent PR review activity reaches the 95th percentile or higher among CodeRabbit users, additional reviews become available more gradually as earlier reviews age out of the rolling window.

Please refer docs for additional details.

Review details
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro Plus

Run ID: 0dcf3267-dc0d-4cd2-811f-fea9b1571631

📥 Commits

Reviewing files that changed from the base of the PR and between 0e2e990 and 14b0b3e.

📒 Files selected for processing (2)
  • backend/docs/audit-events.md
  • backend/tests/items.integration.test.ts
📝 Walkthrough

Walkthrough

The backend adds a typed append-only audit contract with schema support, request/run correlation, validated structured details, transactional required writes, and best-effort denial logging across authentication, claims, items, uploads, reports, profiles, notifications, and scheduled jobs.

Changes

Audit contract and persistence

Layer / File(s) Summary
Audit schema, event registry, and persistence
backend/prisma/..., backend/src/utils/auditEvents.ts, backend/src/utils/auditLog.ts, backend/tests/audit*
Audit records gain actor, outcome, reason, request, run, and nullable entity fields. Event metadata, detail validation, prohibited-key checks, bulk writes, and best-effort persistence are centralized and tested.
Request context and authorization
backend/src/middleware/*, backend/src/index.ts, backend/src/types/express.d.ts
Requests receive trusted UUID-based audit context and an X-Request-Id response header; insufficient-role responses emit denial audits.

Application workflows

Layer / File(s) Summary
Authentication flows
backend/src/routes/auth.ts, backend/tests/auth.*
Login, registration, email verification, refresh-token rotation, and rate-limit denials are audited, with required mutations and successful outcomes written transactionally.
Claims, items, notifications, and matching
backend/src/routes/claims.ts, backend/src/routes/items.ts, backend/src/lib/{notifications,matching,claimEmailNotifications}.ts, backend/tests/{claims,items}*
Claim and item mutations, status transitions, match suggestions, notification fanout, and email outcomes receive structured request-correlated audits.
Resource routes and profiles
backend/src/routes/{photoSessions,reportLinks,uploads,users}.ts, backend/src/utils/uploadMetadata.ts, related tests
Photo, upload, report-link, and profile operations add transactional or best-effort audit events with sanitized metadata.
Scheduled jobs
backend/src/jobs/*.ts, backend/tests/{cleanupUnverifiedUsers,expireRetainedItems}.test.ts
Cleanup and expiration jobs generate per-run audit records for deletions, status changes, notification fanout, and match refresh operations.

Estimated code review effort: 5 (Critical) | ~120 minutes

Sequence Diagram(s)

sequenceDiagram
  participant Client
  participant Route as backend route
  participant Prisma
  participant Audit as auditLog utilities
  Client->>Route: submit mutation or authentication request
  Route->>Prisma: execute database mutation
  Route->>Audit: create required or best-effort audit event
  Audit->>Prisma: persist correlated audit record
  Route-->>Client: return route response
Loading

Possibly related PRs

Suggested reviewers: 86unj

🚥 Pre-merge checks | ✅ 4 | ❌ 1

❌ Failed checks (1 warning)

Check name Status Explanation Resolution
Docstring Coverage ⚠️ Warning Docstring coverage is 10.34% which is insufficient. The required threshold is 80.00%. Write docstrings for the functions missing them to satisfy the coverage threshold.
✅ Passed checks (4 passed)
Check name Status Explanation
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title check ✅ Passed The title accurately summarizes the main change: adding a global backend audit trail.
Linked Issues check ✅ Passed Check skipped because no linked issues were found for this pull request.
Out of Scope Changes check ✅ Passed Check skipped because no linked issues were found for this pull request.
✨ Finishing Touches
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch gary

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands.

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 5

Caution

Some comments are outside the diff and can’t be posted inline due to platform limitations.

⚠️ Outside diff range comments (1)
backend/src/jobs/expireRetainedItems.ts (1)

48-63: 🩺 Stability & Availability | 🔴 Critical | 🏗️ Heavy lift

Prevent concurrent execution race conditions.

If multiple instances of this backend run simultaneously (e.g., in a multi-pod deployment), this cron job will execute on all instances concurrently. Because Prisma's findMany does not acquire row locks, all instances will fetch the exact same eligibleItems and wait on each other during updateMany. Even though the database will serialize the updateMany calls (meaning only one instance will actually modify the rows), all instances will continue executing downstream logic—writing duplicate audit logs and fanning out duplicate notifications for those same items.

Acquire a row lock on the candidates using $queryRaw at the start of the transaction to guarantee only one instance processes them, similar to the approach taken in cleanupUnverifiedUsers.ts.

🔒️ Proposed fix to add row locking

Ensure you import Prisma at the top of the file:

 import {
   ClaimStatus,
   ItemStatus,
   MatchStatus,
   NotificationType,
+  Prisma,
 } from '`@prisma/client`';

Then, add the lock before fetching eligibleItems:

   const expiredCount = await prisma.$transaction(async (tx) => {
+    // Acquire row locks to prevent duplicate processing by concurrent cron instances
+    const lockedItems = await tx.$queryRaw<Array<{ itemId: string }>>(Prisma.sql`
+      SELECT "item_id" AS "itemId"
+      FROM "item"
+      WHERE "item_id" IN (${Prisma.join(candidateItemIds)})
+      FOR UPDATE SKIP LOCKED
+    `);
+
+    if (lockedItems.length === 0) {
+      return 0;
+    }
+
+    const lockedItemIds = lockedItems.map((i) => i.itemId);
+
     const eligibleItems = await tx.item.findMany({
       where: {
-        itemId: { in: candidateItemIds },
+        itemId: { in: lockedItemIds },
         status: ItemStatus.stored,
         retentionExpiryDate: { lte: today },
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@backend/src/jobs/expireRetainedItems.ts` around lines 48 - 63, Update the
transaction in the expire-retained-items job to acquire row-level locks on the
candidate items with tx.$queryRaw before querying eligibleItems, following the
locking approach used by cleanupUnverifiedUsers.ts. Import Prisma as needed for
the raw query, and ensure the lock targets candidateItemIds so concurrent job
instances cannot process the same rows or emit duplicate downstream effects.
🧹 Nitpick comments (10)
backend/tests/expireRetainedItems.test.ts (1)

27-36: 📐 Maintainability & Code Quality | 🔵 Trivial | 💤 Low value

Include missing status in mocked claim data.

The query in expireDueItems explicitly selects the status field, which is later passed into the audit log payload as previousStatus. To ensure the test accurately reflects runtime behavior and validates correctly if toAuditData strictly enforces schema schemas, please include a valid status in this mock object.

🛠️ Proposed fix
     claim: {
       findMany: vi.fn().mockResolvedValue([
         {
           claimId: '550e8400-e29b-41d4-a716-446655440000',
           studentId: 'student-1',
           itemName: 'iPhone 15',
+          status: 'submitted',
         },
       ]),
       updateMany: vi.fn(),
     },
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@backend/tests/expireRetainedItems.test.ts` around lines 27 - 36, Update the
mocked claim returned by claim.findMany in expireDueItems tests to include a
valid status value, ensuring the fixture matches the query result and supports
the audit payload’s previousStatus field.
backend/src/routes/claims.ts (1)

548-614: 🩺 Stability & Availability | 🔵 Trivial | ⚡ Quick win

Batch the audit-log inserts in these transactions

  • backend/src/routes/claims.ts#L548-L614: replace the individual writeAuditLog(..., tx) calls with a single writeAuditLogs([...], tx).
  • backend/src/routes/claims.ts#L773-L805: do the same for the claim-created and notification-created logs.

Promise.all here doesn’t add parallelism inside the interactive transaction, and batching matches the existing items.ts pattern.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@backend/src/routes/claims.ts` around lines 548 - 614, Replace the Promise.all
of individual writeAuditLog calls in backend/src/routes/claims.ts lines 548-614
with one writeAuditLogs([...], tx) call containing the same entries. Apply the
same batching change to the claim-created and notification-created audit logs at
backend/src/routes/claims.ts lines 773-805, preserving all payloads and
transaction usage.
backend/src/routes/photoSessions.ts (1)

285-297: 🗄️ Data Integrity & Integration | 🔵 Trivial | ⚡ Quick win

photo_upload_authorized entity fields point at the session, not the uploaded image.

entityType: 'photo_session' / entityId: session.sessionId describes the session, while the actual subject of this event (the reserved image) is buried inside details.imageId. The sibling photo_image_registered event a few lines later correctly models this as entityType: 'photo_session_image' / entityId: <imageId>. Keeping the taxonomy consistent makes the audit trail queryable by entity.

♻️ Align entity fields with the image being authorized
       await writeAuditLogBestEffort({
         actorType: 'anonymous',
         action: 'photo_upload_authorized',
-        entityType: 'photo_session',
-        entityId: session.sessionId,
+        entityType: 'photo_session_image',
+        entityId: placeholder.imageId,
         outcome: 'success',
         details: {
-          imageId: placeholder.imageId,
+          sessionId: session.sessionId,
           contentType,
           sizeCategory: getUploadSizeCategory(fileSizeKb),
         },
         ...auditContextFromRequest(req),
       });
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@backend/src/routes/photoSessions.ts` around lines 285 - 297, Update the
photo_upload_authorized audit entry to identify the reserved image: use
entityType 'photo_session_image' and placeholder.imageId as entityId, matching
the photo_image_registered event while leaving the existing details and audit
context unchanged.
backend/tests/reportLinks.integration.test.ts (1)

179-180: 📐 Maintainability & Code Quality | 🔵 Trivial | ⚡ Quick win

New audit events added to routes lack matching test assertions.

Both files mock the audit-log module so the new writeAuditLog/writeAuditLogBestEffort calls don't hit a real DB, but neither adds assertions for the new events themselves — unlike backend/tests/uploads.integration.test.ts, which does assert on its new upload_authorized call and its payload shape.

  • backend/tests/reportLinks.integration.test.ts#L179-L180: extend assertions in the submit test to also verify report_created, item_created, report_link_consumed, and notification_created are called with the expected entityId/details/tx for each.
  • backend/tests/photoSessions.integration.test.ts#L68-L76: add assertions verifying photo_session_created, photo_upload_authorized, photo_image_registered, photo_session_access_denied, and photo_image_deleted are invoked with the expected action/entity/outcome for their respective test cases.
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@backend/tests/reportLinks.integration.test.ts` around lines 179 - 180, The
audit-log mocks lack assertions for the newly emitted events. In
backend/tests/reportLinks.integration.test.ts:179-180, extend the submit test
assertions for report_created, item_created, report_link_consumed, and
notification_created, including expected entityId, details, and tx; in
backend/tests/photoSessions.integration.test.ts:68-76, add case-specific
assertions for photo_session_created, photo_upload_authorized,
photo_image_registered, photo_session_access_denied, and photo_image_deleted,
including expected action, entity, and outcome.
backend/src/routes/reportLinks.ts (1)

700-759: 🗄️ Data Integrity & Integration | 🔵 Trivial | ⚡ Quick win

Avoid Promise.all for these audit writes. Prisma serializes tx calls on one connection, so this adds no throughput and makes the transaction behavior harder to reason about. Use sequential awaits here.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@backend/src/routes/reportLinks.ts` around lines 700 - 759, Replace the
Promise.all wrapper around the four writeAuditLog calls in the report-link
transaction with sequential awaits, preserving their existing order, arguments,
and shared tx client.
backend/src/utils/auditLog.ts (1)

12-24: 📐 Maintainability & Code Quality | 🔵 Trivial | ⚡ Quick win

Consider reusing Prisma's generated enums instead of duplicated literal unions.

actorType/outcome re-declare AuditActorType/AuditOutcome as hand-written string literal unions. Importing the Prisma-generated enum types would keep this contract in sync by construction if the schema enums ever change.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@backend/src/utils/auditLog.ts` around lines 12 - 24, Update the
AuditLogParams interface to use Prisma’s generated AuditActorType and
AuditOutcome enum types for actorType and outcome instead of duplicating string
literal unions. Import and reuse those generated enum types so the audit log
contract remains synchronized with the schema.
backend/prisma/migrations/20260720180000_harden_audit_log/migration.sql (2)

20-44: 📐 Maintainability & Code Quality | 🔵 Trivial | 💤 Low value

Track removal of the staged-rollout compatibility trigger.

The comment on lines 20-21 correctly notes this trigger only exists to keep old application code insert-compatible during rollout. There's no follow-up migration or tracked task here to drop audit_log_fill_contract_defaults_trigger/function once all writers supply the new fields explicitly, so it risks becoming permanent dead logic that silently masks incomplete contract adoption.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@backend/prisma/migrations/20260720180000_harden_audit_log/migration.sql`
around lines 20 - 44, Track and schedule removal of the staged-rollout
compatibility objects created by audit_log_fill_contract_defaults and
audit_log_fill_contract_defaults_trigger once all writers explicitly provide
actor_type and outcome. Add a follow-up migration or tracked cleanup task that
drops the trigger and function, preserving their temporary rollout-only scope.

45-54: 🚀 Performance & Scalability | 🔵 Trivial

Locking risk on an already-live audit_log table.

Static analysis correctly flags production risk: SET NOT NULL on lines 46-47 requires a full-table validation scan holding a blocking lock, and the four CREATE INDEX statements (51-54) run without CONCURRENTLY, blocking writes for their duration. Since this migration adds columns to an existing, presumably actively-written audit_log table, this could cause a noticeable stall on deploy. Note CONCURRENTLY can't be used inside a transaction anyway (which is how Prisma runs migrations by default), so mitigating this requires splitting the rollout into separate non-transactional migration steps (e.g., add nullable columns + backfill in one deploy, add NOT NULL via a validated CHECK constraint in a later deploy, create indexes concurrently outside the transaction).

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@backend/prisma/migrations/20260720180000_harden_audit_log/migration.sql`
around lines 45 - 54, The migration’s constraint validation and non-concurrent
index creation can block writes on the live audit_log table. Split this rollout
into separate non-transactional migration steps: safely add and backfill
nullable fields, validate required values before enforcing NOT NULL, and create
each index with concurrent creation outside Prisma’s default transaction;
preserve the foreign-key removal in the appropriate step.

Source: Linters/SAST tools

backend/src/middleware/requireRole.ts (1)

14-19: 🗄️ Data Integrity & Integration | 🔵 Trivial | 💤 Low value

Unauthenticated (401) denials aren't audited.

Only the role-mismatch (403) branch writes an authorization_denied audit event; the unauthenticated branch returns 401 without any audit record. If the intent is a comprehensive audit trail for protected-route access denials, this may be a coverage gap; if it's intentional (to avoid logging noise from unauthenticated probing), consider noting that explicitly.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@backend/src/middleware/requireRole.ts` around lines 14 - 19, Update the
unauthenticated branch in requireRole to record an authorization_denied audit
event before returning the 401 response, matching the existing role-mismatch
audit behavior while preserving the current status and response payload.
backend/src/utils/auditEvents.ts (1)

1-213: 🗄️ Data Integrity & Integration | 🔵 Trivial | ⚡ Quick win

policy metadata isn't enforced anywhere.

policy: 'required' | 'best_effort' is defined per action but never checked against which write function (writeAuditLog/writeAuditLogs vs writeAuditLogBestEffort) actually persists it. A future call site could log a required action via writeAuditLogBestEffort, silently downgrading a compliance-critical event to best-effort with no compile-time or runtime signal. Consider a cheap runtime guard in writeAuditLogBestEffort (see suggested diff on backend/src/utils/auditLog.ts) that asserts the action's declared policy actually matches the function being used.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@backend/src/utils/auditEvents.ts` around lines 1 - 213, Enforce each action’s
declared policy when using the best-effort audit writer: update
writeAuditLogBestEffort to resolve the action metadata from auditEvents and
reject or assert when its policy is required, while allowing only best_effort
actions. Keep writeAuditLog and writeAuditLogs unchanged and preserve normal
persistence for correctly matched calls.
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In `@backend/src/routes/auth.ts`:
- Around line 226-246: Review writeAuditLog and its audit_log dependencies for
schema-stable, transaction-safe behavior at each listed auth mutation site.
Ensure required audit inserts do not rely on soft dependencies or failure-prone
side effects, while keeping writeAuditLog(..., tx) in the existing
prisma.$transaction so audit rows commit atomically with the primary mutation.

In `@backend/src/routes/photoSessions.ts`:
- Around line 384-397: Change the audit call in the photo image registration
handler from required `writeAuditLog` to the established best-effort logging
pattern used for `photo_upload_authorized` above. Preserve the existing audit
payload and ensure audit persistence failures are handled without propagating
through `next(err)` or turning the successful upload confirmation into a 500.
- Around line 449-458: Update the photoUploadSession.findUnique select used by
the access-denial path to include sessionId, then pass the selected sessionId as
entityId in the photo_session_access_denied audit log instead of null. Preserve
the existing ownership-mismatch handling and audit context.

In `@backend/src/routes/users.ts`:
- Around line 229-259: Update the studentNumber entry in changedFields to
compare the requested value with existing.studentNumber, rather than checking
only studentNumber !== undefined. Preserve the existing undefined guard and use
a bigint-aware comparison consistent with the types used by
existing.studentNumber.

In `@backend/src/utils/auditLog.ts`:
- Around line 137-182: Move the toAuditData(params) call inside the try block in
writeAuditLogBestEffort so contract-validation errors from assertSafeDetails,
assertRequiredDetails, or entity-type validation are caught and handled like
persistence failures. Preserve best-effort behavior by logging the failure and
resolving without rejecting; update the existing auditLog.test.ts expectation
that invalid contracts reject, while retaining coverage that detects invalid
contracts during tests.

---

Outside diff comments:
In `@backend/src/jobs/expireRetainedItems.ts`:
- Around line 48-63: Update the transaction in the expire-retained-items job to
acquire row-level locks on the candidate items with tx.$queryRaw before querying
eligibleItems, following the locking approach used by cleanupUnverifiedUsers.ts.
Import Prisma as needed for the raw query, and ensure the lock targets
candidateItemIds so concurrent job instances cannot process the same rows or
emit duplicate downstream effects.

---

Nitpick comments:
In `@backend/prisma/migrations/20260720180000_harden_audit_log/migration.sql`:
- Around line 20-44: Track and schedule removal of the staged-rollout
compatibility objects created by audit_log_fill_contract_defaults and
audit_log_fill_contract_defaults_trigger once all writers explicitly provide
actor_type and outcome. Add a follow-up migration or tracked cleanup task that
drops the trigger and function, preserving their temporary rollout-only scope.
- Around line 45-54: The migration’s constraint validation and non-concurrent
index creation can block writes on the live audit_log table. Split this rollout
into separate non-transactional migration steps: safely add and backfill
nullable fields, validate required values before enforcing NOT NULL, and create
each index with concurrent creation outside Prisma’s default transaction;
preserve the foreign-key removal in the appropriate step.

In `@backend/src/middleware/requireRole.ts`:
- Around line 14-19: Update the unauthenticated branch in requireRole to record
an authorization_denied audit event before returning the 401 response, matching
the existing role-mismatch audit behavior while preserving the current status
and response payload.

In `@backend/src/routes/claims.ts`:
- Around line 548-614: Replace the Promise.all of individual writeAuditLog calls
in backend/src/routes/claims.ts lines 548-614 with one writeAuditLogs([...], tx)
call containing the same entries. Apply the same batching change to the
claim-created and notification-created audit logs at
backend/src/routes/claims.ts lines 773-805, preserving all payloads and
transaction usage.

In `@backend/src/routes/photoSessions.ts`:
- Around line 285-297: Update the photo_upload_authorized audit entry to
identify the reserved image: use entityType 'photo_session_image' and
placeholder.imageId as entityId, matching the photo_image_registered event while
leaving the existing details and audit context unchanged.

In `@backend/src/routes/reportLinks.ts`:
- Around line 700-759: Replace the Promise.all wrapper around the four
writeAuditLog calls in the report-link transaction with sequential awaits,
preserving their existing order, arguments, and shared tx client.

In `@backend/src/utils/auditEvents.ts`:
- Around line 1-213: Enforce each action’s declared policy when using the
best-effort audit writer: update writeAuditLogBestEffort to resolve the action
metadata from auditEvents and reject or assert when its policy is required,
while allowing only best_effort actions. Keep writeAuditLog and writeAuditLogs
unchanged and preserve normal persistence for correctly matched calls.

In `@backend/src/utils/auditLog.ts`:
- Around line 12-24: Update the AuditLogParams interface to use Prisma’s
generated AuditActorType and AuditOutcome enum types for actorType and outcome
instead of duplicating string literal unions. Import and reuse those generated
enum types so the audit log contract remains synchronized with the schema.

In `@backend/tests/expireRetainedItems.test.ts`:
- Around line 27-36: Update the mocked claim returned by claim.findMany in
expireDueItems tests to include a valid status value, ensuring the fixture
matches the query result and supports the audit payload’s previousStatus field.

In `@backend/tests/reportLinks.integration.test.ts`:
- Around line 179-180: The audit-log mocks lack assertions for the newly emitted
events. In backend/tests/reportLinks.integration.test.ts:179-180, extend the
submit test assertions for report_created, item_created, report_link_consumed,
and notification_created, including expected entityId, details, and tx; in
backend/tests/photoSessions.integration.test.ts:68-76, add case-specific
assertions for photo_session_created, photo_upload_authorized,
photo_image_registered, photo_session_access_denied, and photo_image_deleted,
including expected action, entity, and outcome.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro Plus

Run ID: bdcfb346-eec1-407b-837d-f070a0a75025

📥 Commits

Reviewing files that changed from the base of the PR and between 2a3708e and 0e2e990.

📒 Files selected for processing (37)
  • backend/README.md
  • backend/prisma/migrations/20260720180000_harden_audit_log/migration.sql
  • backend/prisma/schema.prisma
  • backend/src/index.ts
  • backend/src/jobs/cleanupUnverifiedUsers.ts
  • backend/src/jobs/expireRetainedItems.ts
  • backend/src/lib/claimEmailNotifications.ts
  • backend/src/lib/matching/suggestions.ts
  • backend/src/lib/notifications.ts
  • backend/src/middleware/requestContext.ts
  • backend/src/middleware/requireRole.ts
  • backend/src/routes/auth.ts
  • backend/src/routes/claims.ts
  • backend/src/routes/items.ts
  • backend/src/routes/photoSessions.ts
  • backend/src/routes/reportLinks.ts
  • backend/src/routes/uploads.ts
  • backend/src/routes/users.ts
  • backend/src/types/express.d.ts
  • backend/src/utils/auditEvents.ts
  • backend/src/utils/auditLog.ts
  • backend/src/utils/uploadMetadata.ts
  • backend/tests/auditCoverage.test.ts
  • backend/tests/auditLog.test.ts
  • backend/tests/auditMigration.test.ts
  • backend/tests/auth.extra.integration.test.ts
  • backend/tests/auth.login.integration.test.ts
  • backend/tests/claims.integration.test.ts
  • backend/tests/cleanupUnverifiedUsers.test.ts
  • backend/tests/expireRetainedItems.test.ts
  • backend/tests/items.integration.test.ts
  • backend/tests/photoSessions.integration.test.ts
  • backend/tests/reportLinks.integration.test.ts
  • backend/tests/requestContext.test.ts
  • backend/tests/requireRole.test.ts
  • backend/tests/uploads.integration.test.ts
  • backend/tests/users.integration.test.ts

Comment on lines +226 to +246
const context = auditContextFromRequest(req);
await prisma.$transaction(async (tx) => {
await tx.refreshTokenLog.create({
data: {
userId: user.userId,
tokenHash,
expiresAt: new Date(Date.now() + refreshDays * 24 * 60 * 60 * 1000),
},
});
await writeAuditLog(
{
actorId: user.userId,
actorType: 'user',
action: 'user_login',
entityType: 'user',
entityId: user.userId,
outcome: 'success',
...context,
},
tx
);

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🩺 Stability & Availability | 🟠 Major | 🏗️ Heavy lift

Required audit write shares a transaction with the core mutation — an audit failure now fails login/registration/verification/refresh.

At each of these sites, writeAuditLog(..., tx) runs inside the same prisma.$transaction as the primary mutation (refresh-token creation, user creation, rollback deletion, email-verification update, token rotation). If the audit insert throws for any reason (e.g. a constraint issue, pool exhaustion), the whole transaction rolls back and the request 500s even though the credentials/token were valid. For the rollback path (421-435) this is especially bad: a failing audit write there also undoes the compensating tx.user.delete, leaving an un-cleaned-up user row and no rollback audit trail.

This matches the documented intent in backend/README.md ("Required audit rows commit with their database mutation"), so it may be an accepted trade-off — but it's worth double-checking that audit_log writes are resilient enough (schema-stable, no soft dependencies) that this coupling doesn't turn transient audit-table hiccups into full auth outages.

Also applies to: 366-397, 421-435, 509-542, 667-692

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@backend/src/routes/auth.ts` around lines 226 - 246, Review writeAuditLog and
its audit_log dependencies for schema-stable, transaction-safe behavior at each
listed auth mutation site. Ensure required audit inserts do not rely on soft
dependencies or failure-prone side effects, while keeping writeAuditLog(..., tx)
in the existing prisma.$transaction so audit rows commit atomically with the
primary mutation.

Comment on lines +384 to +397
await writeAuditLog({
actorType: 'anonymous',
action: 'photo_image_registered',
entityType: 'photo_session_image',
entityId: image.imageId,
outcome: 'success',
details: {
sessionId: session.sessionId,
fileType,
sizeCategory: getUploadSizeCategory(fileSizeKb),
},
...auditContextFromRequest(req),
});

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🩺 Stability & Availability | 🟠 Major | ⚡ Quick win

Required (non-best-effort) audit write with no accompanying mutation or transaction.

This handler only reads/validates an already-persisted image (created earlier in the presigned-url step) — no create/update happens here. Using writeAuditLog (which throws and propagates to next(err) on failure) means a transient audit-log DB error turns a legitimate, already-successful upload confirmation into a 500 for the caller, even though nothing needs to be rolled back. This is inconsistent with the best-effort pattern used for photo_upload_authorized just above, which is the same kind of "record an event, don't gate the response" case.

🔧 Use best-effort logging since there's no mutation to protect
-      await writeAuditLog({
+      await writeAuditLogBestEffort({
         actorType: 'anonymous',
         action: 'photo_image_registered',
         entityType: 'photo_session_image',
         entityId: image.imageId,
         outcome: 'success',
         details: {
           sessionId: session.sessionId,
           fileType,
           sizeCategory: getUploadSizeCategory(fileSizeKb),
         },
         ...auditContextFromRequest(req),
       });
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
await writeAuditLog({
actorType: 'anonymous',
action: 'photo_image_registered',
entityType: 'photo_session_image',
entityId: image.imageId,
outcome: 'success',
details: {
sessionId: session.sessionId,
fileType,
sizeCategory: getUploadSizeCategory(fileSizeKb),
},
...auditContextFromRequest(req),
});
await writeAuditLogBestEffort({
actorType: 'anonymous',
action: 'photo_image_registered',
entityType: 'photo_session_image',
entityId: image.imageId,
outcome: 'success',
details: {
sessionId: session.sessionId,
fileType,
sizeCategory: getUploadSizeCategory(fileSizeKb),
},
...auditContextFromRequest(req),
});
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@backend/src/routes/photoSessions.ts` around lines 384 - 397, Change the audit
call in the photo image registration handler from required `writeAuditLog` to
the established best-effort logging pattern used for `photo_upload_authorized`
above. Preserve the existing audit payload and ensure audit persistence failures
are handled without propagating through `next(err)` or turning the successful
upload confirmation into a 500.

Comment on lines +449 to +458
await writeAuditLogBestEffort({
actorId: req.user!.user_id,
actorType: 'user',
action: 'photo_session_access_denied',
entityType: 'photo_session',
entityId: null,
outcome: 'denied',
reasonCode: 'session_ownership_mismatch',
...auditContextFromRequest(req),
});

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🗄️ Data Integrity & Integration | 🟡 Minor | ⚡ Quick win

Denial audit log has no entityId because sessionId isn't selected.

The photoUploadSession.findUnique call above (lines 422-438) selects createdBy, expiresAt, and images, but never sessionId, so entityId: null is unavoidable here. Include sessionId in the select and use it, so this denial event can be correlated to the specific session it targeted.

🔧 Select sessionId and use it as entityId
       const session = await prisma.photoUploadSession.findUnique({
         where: { token },
         select: {
+          sessionId: true,
           createdBy: true,
           expiresAt: true,
           images: {
             ...
           },
         },
       });
       ...
       if (session.createdBy !== req.user!.user_id) {
         await writeAuditLogBestEffort({
           actorId: req.user!.user_id,
           actorType: 'user',
           action: 'photo_session_access_denied',
           entityType: 'photo_session',
-          entityId: null,
+          entityId: session.sessionId,
           outcome: 'denied',
           reasonCode: 'session_ownership_mismatch',
           ...auditContextFromRequest(req),
         });
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@backend/src/routes/photoSessions.ts` around lines 449 - 458, Update the
photoUploadSession.findUnique select used by the access-denial path to include
sessionId, then pass the selected sessionId as entityId in the
photo_session_access_denied audit log instead of null. Preserve the existing
ownership-mismatch handling and audit context.

Comment on lines +229 to +259
const context = auditContextFromRequest(req);
const changedFields = [
...(existing.firstName !== firstName ? ['firstName'] : []),
...(existing.lastName !== lastName ? ['lastName'] : []),
...(isStudent && studentNumber !== undefined
? ['studentNumber']
: []),
];
updated = await prisma.$transaction(async (tx) => {
const profile = await tx.user.update({
where: { userId },
data,
select: {
...userProfileSelect,
campus: { select: { campusName: true } },
},
});
await writeAuditLog(
{
actorId: userId,
actorType: 'user',
action: 'user_profile_updated',
entityType: 'user',
entityId: userId,
outcome: 'success',
details: { changedFields },
...context,
},
tx
);
return profile;

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🗄️ Data Integrity & Integration | 🟡 Minor | ⚡ Quick win

changedFields marks studentNumber as changed even when it's unchanged.

firstName/lastName are added to changedFields only via direct comparison against existing, but studentNumber is added whenever the field was explicitly present in the request (studentNumber !== undefined), with no comparison to existing.studentNumber. Resubmitting the same value logs a spurious "changed" entry in the audit trail.

🔧 Compare against existing value (bigint-aware)
         const changedFields = [
           ...(existing.firstName !== firstName ? ['firstName'] : []),
           ...(existing.lastName !== lastName ? ['lastName'] : []),
-          ...(isStudent && studentNumber !== undefined
-            ? ['studentNumber']
-            : []),
+          ...(isStudent &&
+          studentNumber !== undefined &&
+          (studentNumber === null
+            ? existing.studentNumber !== null
+            : existing.studentNumber !== BigInt(studentNumber))
+            ? ['studentNumber']
+            : []),
         ];
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
const context = auditContextFromRequest(req);
const changedFields = [
...(existing.firstName !== firstName ? ['firstName'] : []),
...(existing.lastName !== lastName ? ['lastName'] : []),
...(isStudent && studentNumber !== undefined
? ['studentNumber']
: []),
];
updated = await prisma.$transaction(async (tx) => {
const profile = await tx.user.update({
where: { userId },
data,
select: {
...userProfileSelect,
campus: { select: { campusName: true } },
},
});
await writeAuditLog(
{
actorId: userId,
actorType: 'user',
action: 'user_profile_updated',
entityType: 'user',
entityId: userId,
outcome: 'success',
details: { changedFields },
...context,
},
tx
);
return profile;
const context = auditContextFromRequest(req);
const changedFields = [
...(existing.firstName !== firstName ? ['firstName'] : []),
...(existing.lastName !== lastName ? ['lastName'] : []),
...(isStudent &&
studentNumber !== undefined &&
(studentNumber === null
? existing.studentNumber !== null
: existing.studentNumber !== BigInt(studentNumber))
? ['studentNumber']
: []),
];
updated = await prisma.$transaction(async (tx) => {
const profile = await tx.user.update({
where: { userId },
data,
select: {
...userProfileSelect,
campus: { select: { campusName: true } },
},
});
await writeAuditLog(
{
actorId: userId,
actorType: 'user',
action: 'user_profile_updated',
entityType: 'user',
entityId: userId,
outcome: 'success',
details: { changedFields },
...context,
},
tx
);
return profile;
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@backend/src/routes/users.ts` around lines 229 - 259, Update the studentNumber
entry in changedFields to compare the requested value with
existing.studentNumber, rather than checking only studentNumber !== undefined.
Preserve the existing undefined guard and use a bigint-aware comparison
consistent with the types used by existing.studentNumber.

Comment on lines +137 to +182
export async function writeAuditLogBestEffort(
params: AuditLogParams
): Promise<void> {
const data = toAuditData(params);
try {
const record = await prisma.auditLog.create({ data });
logger.info(
{
logId: record.logId,
action: params.action,
actorId: params.actorId,
entityType: params.entityType,
entityId: params.entityId,
outcome: params.outcome ?? 'success',
requestId: params.requestId,
runId: params.runId,
},
params.action
);
} catch (error) {
const errorMetadata =
error instanceof Error
? {
errorName: error.name,
errorCode:
'code' in error && typeof error.code === 'string'
? error.code
: undefined,
}
: { errorName: 'UnknownError' };
logger.error(
{
...errorMetadata,
action: params.action,
outcome: params.outcome ?? 'success',
actorType: resolveActorType(params),
entityType: params.entityType,
entityId: params.entityId,
reasonCode: params.reasonCode,
requestId: params.requestId,
runId: params.runId,
},
'audit_log_persistence_failed'
);
}
}

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🩺 Stability & Availability | 🟠 Major | ⚡ Quick win

writeAuditLogBestEffort can still throw on contract-validation errors, breaking its own guarantee.

toAuditData(params) on line 140 runs outside the try block, so assertSafeDetails/assertRequiredDetails/entity-type-mismatch errors are not caught, unlike persistence errors which are. Every best-effort call site (e.g. requireRole.ts's authorization_denied write before sending the 403, or the denial-logging paths in photoSessions.ts/uploads.ts) awaits this function with no surrounding try/catch, assuming it never rejects. A future typo in a details payload (a detail key not registered for that action, or a prohibited key) would throw synchronously and leave those security-denial responses unsent instead of degrading gracefully. This is confirmed intentional by auditLog.test.ts's "best-effort writes reject invalid event contracts before persistence" test, but that test asserts exactly the risky behavior described here.

🛡️ Proposed fix: make validation failures fail closed instead of propagating
 export async function writeAuditLogBestEffort(
   params: AuditLogParams
 ): Promise<void> {
-  const data = toAuditData(params);
   try {
+    const data = toAuditData(params);
     const record = await prisma.auditLog.create({ data });

If contract bugs should still fail CI/tests loudly, keep coverage in auditCoverage.test.ts/auditLog.test.ts as the safety net for catching them pre-merge, rather than at runtime in a security-critical path.

📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
export async function writeAuditLogBestEffort(
params: AuditLogParams
): Promise<void> {
const data = toAuditData(params);
try {
const record = await prisma.auditLog.create({ data });
logger.info(
{
logId: record.logId,
action: params.action,
actorId: params.actorId,
entityType: params.entityType,
entityId: params.entityId,
outcome: params.outcome ?? 'success',
requestId: params.requestId,
runId: params.runId,
},
params.action
);
} catch (error) {
const errorMetadata =
error instanceof Error
? {
errorName: error.name,
errorCode:
'code' in error && typeof error.code === 'string'
? error.code
: undefined,
}
: { errorName: 'UnknownError' };
logger.error(
{
...errorMetadata,
action: params.action,
outcome: params.outcome ?? 'success',
actorType: resolveActorType(params),
entityType: params.entityType,
entityId: params.entityId,
reasonCode: params.reasonCode,
requestId: params.requestId,
runId: params.runId,
},
'audit_log_persistence_failed'
);
}
}
export async function writeAuditLogBestEffort(
params: AuditLogParams
): Promise<void> {
try {
const data = toAuditData(params);
const record = await prisma.auditLog.create({ data });
logger.info(
{
logId: record.logId,
action: params.action,
actorId: params.actorId,
entityType: params.entityType,
entityId: params.entityId,
outcome: params.outcome ?? 'success',
requestId: params.requestId,
runId: params.runId,
},
params.action
);
} catch (error) {
const errorMetadata =
error instanceof Error
? {
errorName: error.name,
errorCode:
'code' in error && typeof error.code === 'string'
? error.code
: undefined,
}
: { errorName: 'UnknownError' };
logger.error(
{
...errorMetadata,
action: params.action,
outcome: params.outcome ?? 'success',
actorType: resolveActorType(params),
entityType: params.entityType,
entityId: params.entityId,
reasonCode: params.reasonCode,
requestId: params.requestId,
runId: params.runId,
},
'audit_log_persistence_failed'
);
}
}
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@backend/src/utils/auditLog.ts` around lines 137 - 182, Move the
toAuditData(params) call inside the try block in writeAuditLogBestEffort so
contract-validation errors from assertSafeDetails, assertRequiredDetails, or
entity-type validation are caught and handled like persistence failures.
Preserve best-effort behavior by logging the failure and resolving without
rejecting; update the existing auditLog.test.ts expectation that invalid
contracts reject, while retaining coverage that detects invalid contracts during
tests.

@humbeatbox humbeatbox closed this Jul 22, 2026
This was referenced Jul 25, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant