Skip to Content
Living documentation — last reviewed 2026-05-28
FeaturesAudit LoggingAudit Logging — QA Plan

Audit Logging — QA Plan

Status: Shipped, unflagged since 2026-08-22. The highest-risk areas are (a) owner-only authorization on the read surface, (b) actor attribution across the request / cron / webhook boundary, and (c) the retention cron’s archive-before-delete ordering, which is the only destructive path in the feature.

Prerequisites:

  • Migration 0102 applied (libs/db/drizzle/0102_glossy_domino.sql).
  • Clerk webhook endpoint subscribed to session.created, session.removed, session.revoked (plus the existing user.*). Without this, section 3 cannot pass.

Personas: usePersona('owner'), ('admin'), ('coach'), ('member'), plus a second org to prove isolation.

1. Capture is always-on

#ScenarioExpected
1.1Change a member’s roleRow appears in audit_events (verify via DB)
1.2Open the viewerThe 1.1 row is visible
1.3Force an audit insert failure (e.g. temporarily revoke insert on the table) and change a roleThe role change succeeds; a warn appears in API logs; a Sentry event lands with subsystem: audit

1.3 is the single most important test in this plan. If the request fails, the whole design premise is broken.

2. Actor attribution

#ScenarioExpected
2.1Owner changes a member’s role from the web appactor_type='user', actor_user_id = owner’s users.id, ip_address populated, request_id matches the pino log line for that request
2.2Staff record a member’s period-end noticeactor_type='user', actor = the staffer
2.3Period-end cancellation cron flips a subscriptionactor_type='system', actor_user_id NULL
2.4Recurring-charge cron renews a subscriptionsubscription.renewed, system
2.5Provider webhook activates a pending subscriptionsubscription.created recorded; actor is system, and ip_address is not the provider’s address
2.6Request behind a proxy with x-forwarded-for: a, b, cip_address is a (first hop), not the socket address
2.7Request with a 60-char IPv6 headerStored value is truncated to 45 chars, no insert error

3. Clerk auth events

#ScenarioExpected
3.1A staff user (owner/admin/coach) signs inOne auth.login row per active staff membership that user holds
3.1bA plain member signs inNo row anywhere. The fan-out is staff-only
3.1cA user who staffs org A and is a member of org B signs inExactly one row, in org A. Org B’s viewer shows nothing
3.2A coach belonging to two orgs signs inTwo rows, one per org. Neither org’s viewer reveals the other org
3.3The same user signs outauth.logout
3.4Clerk sends session.ended (idle expiry)No row. Only removed / revoked count as a logout
3.5Staff user changes their email in Clerk’s UIauth.user_updated in every org they staff, and the existing syncFromClerk behavior is unchanged. A member’s change records nothing (staff-only applies here too)
3.6Clerk sends a session event for a user_id we’ve never mirroredNo row, no error, webhook still returns 200
3.7A session payload carries latest_activity.ip_addressThat IP lands on the row
3.8A session payload carries no latest_activityip_address is NULL — not Clerk’s sender IP

4. Read surface: entry points

#ScenarioExpected
4.1Coach opens /dashboard/audit-logRestricted copy; no request to /audit-events fires
4.2Same, sidebarNo “Audit Log” entry for a coach
4.3Owner opens /dashboard/audit-logViewer renders and the list request fires

5. Read surface: authorization

#ScenarioExpected
5.1Owner200 with rows
5.2Admin403
5.3Coach403
5.4Member403
5.5Non-member of the org404 from requireMembership
5.6Turn rbac-v2 ON and repeat 5.1–5.4Identical results. The audit row is the same in both matrices
5.7Admin / coach in the web appSidebar entry absent; direct navigation shows the restricted copy
5.8Export endpoint, as coach403 (not just the list)

6. Cross-org isolation (ADR-0004)

#ScenarioExpected
6.1Seed events in org A and org B; owner of A listsOnly A’s rows, at every page
6.2Same, with each filter applied in turn (category, action, actor, target, date, search)Org scoping survives every filter
6.3Owner of A passes org B’s id in the path404
6.4Export as owner of ACSV contains no B rows

7. Filters, pagination, export

#ScenarioExpected
7.1Filter by each of the five categoriesOnly matching rows
7.2Search by a partial action stringMatches on action
7.3Search by an actor’s emailMatches rows that actor wrote
7.4Search with a % or _ in itNo SQL wildcard leakage into unrelated rows
7.5Date range from = to = todayIncludes events from later the same day (the UI widens to to 23:59:59.999)
7.6limit=500Response limit is 100
7.7Page through 3+ pagesNo duplicates, no gaps, newest first throughout
7.8Export with filters appliedCSV row count matches the filtered total
7.9Export a row whose metadata contains a comma, a quote and a newlineCSV parses correctly in Excel and Google Sheets
7.10Export with 0 matching rowsHeader-only CSV, no crash

8. Viewer UI

#ScenarioExpected
8.1Table renders time, actor, action, target, changes
8.2A system rowActor cell shows the localized “System” badge, not a blank
8.3Expand a row with before/afterBoth blocks render; values match the DB
8.4Expand a row with metadata NULL”No field-level detail recorded”, not an empty box
8.5A row with IP and request idBoth shown in the popover
8.6Type in searchExactly one request after ~300ms, not one per keystroke
8.7he localeFull RTL; prev/next chevrons are not individually mirrored (layout flip carries the meaning)
8.8ru localeAll strings translated; no English leaks
8.9Narrow viewport (375px)The table scrolls inside its own container; the page body does not scroll horizontally
8.10Click ExportFile downloads with an audit-log-YYYY-MM-DD.csv name
8.11Export while the API is downInline error copy, button re-enables

9. Capture catalog spot-checks

Walk one scenario per action and verify the row’s category, action, target_type/target_id, and diff. Priority order:

  1. membership.role_changed — before/after roles both present
  2. membership.status_changed vs membership.removed — a flip to cancelled reads as removed
  3. Profile-only PATCH on a memberno membership row written (the diff guard)
  4. plan.updated with a price change — before.priceInCents / after.priceInCents present
  5. plan.updated with only a name change — price absent from the diff
  6. subscription.cancelled via direct cancel and via request approval — one row each, not two
  7. subscription.plan_change_scheduled on a 409 (race with a pending cancellation) — no row
  8. payment.charge_failed on a declined card — one row, status: 'failed', and metadata.errorClass set to a bounded value (never the gateway’s own text)
  9. payment.charge_succeeded from a desk charge — exactly one row for the whole pending-then-completed pair, and the staffer’s description is absent from metadata
  10. payment.provider_configured after a credential rotation — credentialsRotated: true, and no credential value or ciphertext anywhere in the row
  11. organization.updated — only changed fields; joinToken never present
  12. Member erasure — org row written and the platform audit_logs row still written; no PII in the org row

Item 10 is a security check, not a correctness check. Grep the whole metadata column for known credential key names (apiPassword, apiKeySecret, groupPrivateToken, rivhitApiToken, terminalNumber) and expect zero value hits — the key names may legitimately appear under metadata.config, but every value there must read [redacted].

  1. payment.provider_configured with a config bag — keys preserved, every value [redacted]
  2. Add a card, then remove it — payment.method_added / payment.method_removed, and no token or ciphertext anywhere in the row

9b. Charges and refunds (the choke point)

Every charge attempt must land exactly once, from every path. Run these against a sandbox provider and count rows per target_id.

#ScenarioExpected
9b.1Desk charge that succeedsOne payment.charge_succeeded; metadata.source: 'manual_charge'; actor is the staffer, not system
9b.2Desk charge that the provider declinesOne payment.charge_failed; metadata.errorClass bounded; error_message still verbatim on payment_transactions
9b.3Renewal cron charges a due subscriptionOne payment.charge_succeeded; metadata.source: 'recurring_renewal'; actor_type: 'system', actor_user_id NULL
9b.4Renewal fails three times (past_due → debt)Three payment.charge_failed rows, one per attempt; none of them names the provider’s message
9b.5Clear debt from the deskOne row; metadata.source: 'debt_clear'
9b.6Plan change with an immediate proration chargeOne row; metadata.source: 'plan_change'; plus the separate subscription.plan_change_applied
9b.7Member completes a hosted checkoutOne payment.charge_succeeded when the webhook settles the pending row, not one at checkout and one at settlement
9b.8Provider re-delivers the same payment.completed webhookNo second row — the prior-status guard suppresses it
9b.9Zero-price plan renewalNo charge row at all (the cron skips the transaction entirely)
9b.10Superseded checkout cancelled (status: 'cancelled')No charge row — a supersede is not a resolved attempt
9b.11Automatic refundOne payment.refund_issued; no payment.charge_* row from the refund transaction or the refunded flip
9b.12Manual refund: open the task, then complete itTwo rows, metadata.stage initiated then settled
9b.13Refund issued in the provider’s own portalOne payment.refund_issued, actor_type: 'system', capability: 'provider', ip_address NULL
9b.14Platform-admin refund from the Taikan admin appOne payment.refund_issued in the gym’s trail, capability: 'platform_admin'

Then grep the whole metadata column across every payment.charge_* row for a card number fragment, a cardholder name and any substring of a provider error message. Expect zero hits: the only failure detail that may appear is the errorClass token.

9c. Destructive staff actions (operations)

#ScenarioExpected
9c.1Cancel a class session with 12 booked membersOne class_session.cancelled; cancelledBookings: 12; no per-booking rows
9c.2Delete that sessionclass_session.deleted in the org trail and the existing class_session.delete row still in the platform audit_logs
9c.3Bulk-delete 30 sessions via a filterOne class_session.bulk_deleted keyed on batchId, with deletedCount: 30, the sessionIds array and the filter
9c.4Staff cancel one member’s bookingbooking.cancelled_by_staff, creditRefunded matching whether the seat was confirmed
9c.5The member cancels their own bookingNo row — self-service is deliberately out
9c.6Delete a workout, a program template, a program, a class typeworkout.deleted, program_template.deleted, program.deactivated, class_type.deactivated; the two deactivated ones say so rather than claiming a deletion
9c.7Archive a form templateform_template.archived
9c.8Erase one leadlead.deleted with an id-only before; grep the row for the lead’s name, email and phone and expect zero hits
9c.9Bulk-erase 40 leads, 5 of them already convertedOne lead.bulk_deleted: requestedCount: 40, deletedCount: 35, skippedCount: 5, leadIds listing the 35
9c.10Bulk-erase where every id is converted or missingNo row — nothing was destroyed
9c.11Delete an announcement you wrote, and one someone else wroteTwo rows; authoredByActor true then false
9c.12A coach attempts an owner/admin-only delete and is refusedNo row — a refusal is not a change
9c.13Filter the viewer by “Operations” in en, he and ruEvery action, target and field label translated; no raw dot-strings in the label column

9a. Self-auditing (access to the trail)

#ScenarioExpected
9a.1Owner opens the audit log pageOne audit.viewed row, settings category, actor = owner, metadata.filters reflects the active filters
9a.2Owner then filters, paginates and searches within 15 minutesStill one audit.viewed row — coalesced
9a.3Wait past the coalescing window, reloadA second audit.viewed row
9a.4The audit.viewed row itselfDoes not appear in the result set of the request that created it (recorded after the query)
9a.5Owner exports twice in a rowTwo audit.exported rows — exports are never coalesced
9a.6Inspect an audit.exported rowmetadata.rowCount matches the CSV’s data-line count; metadata.filters matches what was applied
9a.7Coach hits the list endpoint403 and one audit.access_denied row, reason: 'insufficient_role', role: 'coach'
9a.8Admin hits the export endpoint403 and an audit.access_denied row with endpoint: 'export'
9a.10A user who is not a member hits /organizations/<org>/audit-events403 and no row. Deliberate: recording it would let any authenticated user write into any org’s trail
9a.11Break the audit insert, then list as ownerThe list still returns 200. A self-audit failure must never 500 a page load

9b. CSV injection

#ScenarioExpected
9b.1Set a member’s first name to =cmd|'/c calc'!A1, generate an event by them, exportThe cell is prefixed with '; opening in Excel/Sheets shows text and evaluates nothing
9b.2Repeat with names starting +, -, @, and a leading tabSame neutralization
9b.3A metadata value containing a comma, a quote and a newlineStill parses as one field — the quoting rules are unaffected by the guard

10. Retention cron

Run against a scratch DB. This is the only path that deletes.

#ScenarioExpected
10.1CRONS_ENABLED unsetCron does not run
10.2Seed rows at 25 months and 23 months old; runOnly the 25-month rows archived and deleted
10.3Two orgs × two months of expired rowsFour R2 objects at audit-archive/<orgId>/<YYYY-MM>.ndjson
10.4Inspect an archived objectValid NDJSON, one JSON object per line, line count matches the deleted row count, and every column present (incl. metadata, ip_address, request_id, ISO created_at)
10.5Confirm the bucketObjects land in the compliance bucket, not the general one
10.4aSeed a row exactly 24 months old; runNot deleted — 24 months is a floor, and the filter is a strict lt
10.6Make R2 reject the upload; runRows still in Postgres. Nothing deleted
10.710.6, then fix R2 and re-runRows archived and deleted on the second attempt
10.8Hold the advisory lock in another session; runLogs “already running”, no upload, no delete
10.9Kill the process mid-sweepLock released on reconnect (session-scoped); next run resumes cleanly
10.10Run with nothing expiredLogs “nothing older than the cutoff”, zero R2 calls

11. Regression surface

The instrumentation touched thirteen existing services. Re-run these existing suites and confirm the behavior they cover is unchanged:

  • make test-unit-api, make test-integration-api, make test-e2e-api
  • make test-unit-web, make test-integration-web, make test-e2e-web

Manually re-verify, since these paths grew a call:

  • Member invite → accept → activation (the MEMBERSHIP_ACTIVATED listener now also fires)
  • A full paid subscription purchase through the provider’s hosted page
  • A recurring charge cycle including one scheduled plan swap at the boundary
  • Permanent member erasure
  • Org settings save from /dashboard/settings

12. Rollout

Done. The audit-logging flag was merged permanently ON and deleted on 2026-08-22, so the viewer is live for every org and there is no flag-off rollback. Post-deploy check: walk sections 4–8 in prod as the owner.