Audit Logging — Behavior
Invariants
- Append-only. No code path updates or soft-deletes an
audit_eventsrow. The only deletion is the retention cron, and it archives first. - Capture is unflagged.
AuditService.record()has no feature-flag gate, and since 2026-08-22 neither does the read surface — theaudit-loggingflag was merged permanently ON and deleted. - An audit failure never fails the request.
record()catches everything, logs awarn, and reports to Sentry. This is a deliberate trade: a gap in the log is recoverable, a member’s role change that 500s because of a logging bug is not. - Every read is org-scoped.
AuditQueryService.buildWherestarts its condition list witheq(auditEvents.organizationId, orgId)and that entry is never optional. - No secrets in
metadata. See data-model.md § metadata convention. actor_typeandactor_user_idnever disagree. One expression sets both.
Actor resolution
The chain, in order:
RequestContextMiddleware opens an AsyncLocalStorage store per HTTP request
↓ { ip, userAgent, requestId }
AuthGuard.canActivate stamps { actorUserId, clerkId } after the token verifies
↓
…service code… calls AuditService.record(entry)
↓
AuditService.record actorUserId = entry.actorUserId ?? ctx?.actorUserId ?? null
actorType = actorUserId ? 'user' : 'system'Why AsyncLocalStorage and not a parameter: the alternative was threading an actor through ~20 service methods that had no business knowing about auditing, several of which are called from crons where there is no actor at all. Why native node:async_hooks and not nestjs-cls: the whole requirement is one mutable object per request, which is not worth a dependency.
The store is mutable on purpose. Middleware runs before guards, so IP and request id are known at creation time but the actor is not; AuthGuard fills it in.
The three attribution outcomes
| Situation | Result |
|---|---|
| Authenticated request | user, with IP / user-agent / request id from the request |
@Public() route, or unauthenticated | system, but provenance (IP etc.) is still recorded |
| Cron, queue worker, no HTTP request at all | system, everything null |
Provider webhooks: the explicit-null case
A provider webhook does have a request context, but its IP belongs to the provider, not the member. Recording it would be actively misleading. AuditEntry therefore distinguishes:
ipAddressomitted → take the ALS value (the normal case)ipAddress: '…'→ use this valueipAddress: null→ record nothing
AuditAuthService passes explicit values for Clerk session events (lifted from latest_activity when Clerk supplies it, null when it doesn’t).
Clerk auth events
session.created → auth.login. session.removed and session.revoked → auth.logout. user.updated also records auth.user_updated.
session.ended is deliberately not handled: it fires on idle expiry, which is the clock talking, not the person.
Sign-in is a platform act but the trail is org-scoped, so one Clerk event fans out to one row per active staff membership (owner, admin, coach). A coach who works at two gyms appears in both owners’ logs — each owner is entitled to know when their staff signed in, and neither learns about the other gym.
Plain member memberships are excluded, for two reasons that agree:
- Purpose. The
authcategory answers “when did someone with authority over this gym’s data get into the system”. Reg. 10’s access record is about authorized personnel; a member opening the app to book a class holdsnoneon every org-level module, so their session carries no authority to account for. - Cost, both kinds. Members outnumber staff by orders of magnitude and sign in far more often, so member logins would be the highest-volume write in the trail — burying the staff signal the log exists to surface, and filling 24 months of retention with it. Each such row would also persist a member’s IP and user-agent, readable and CSV-exportable by the gym owner: a privacy liability the log gains nothing from.
The same exclusion applies to auth.user_updated; the fan-out is staff-only as a whole, not per action. A user who staffs one gym and is a member of another gets a row for the first and nothing for the second, from the same Clerk event.
An unknown Clerk id (never mirrored, or already erased) records nothing.
Capture catalog
Everything actually instrumented, by category.
auth
Staff memberships only — see above.
| Action | Source |
|---|---|
auth.login | Clerk session.created webhook |
auth.logout | Clerk session.removed / session.revoked webhook |
auth.user_updated | Clerk user.updated webhook |
membership
| Action | Site | Before/after |
|---|---|---|
membership.activated | AuditEventsListener on MEMBERSHIP_ACTIVATED | after only |
membership.role_changed | MembershipsService.updateMembership | role |
membership.status_changed | MembershipsService.updateMembership | status |
membership.removed | MembershipsService.updateMembership (status → cancelled) and MemberErasureService.eraseMember | status / deletedAt |
membership.invitation_revoked | MembershipsService.revokeInvitation | status |
Two notes:
- Member-added is a listener, not an inline call.
MEMBERSHIP_ACTIVATEDhas three producers (invitation acceptance, join-link acceptance, lead conversion, the last of which lives in a different module). One@OnEventcovers all three; adding inline calls would double-log. updateMembershipdiffs before recording. Its UPDATE always setsupdatedAt, so it fires even on a profile-only or email-only PATCH. Logging unconditionally would fill the trail with no-ops.
billing
| Action | Site | Actor |
|---|---|---|
subscription.created | SubscriptionsService.createSubscription | ALS (staff enrolment / member checkout) or system (webhook activation) |
subscription.cancelled | SubscriptionsService.applyImmediateCancel | explicit actor |
subscription.cancelled | SubscriptionsService.sweepDueCancellations | system (period-end cron) |
subscription.cancellation_scheduled | SubscriptionsService.memberCancelAtPeriodEnd | staff (recording the member’s notice) |
subscription.cancellation_requested | CancellationRequestsService.create | member |
subscription.cancellation_approved | CancellationRequestsService.approve | staff |
subscription.cancellation_rejected | CancellationRequestsService.reject | staff |
subscription.paused | SubscriptionsService.freezeSubscription | staff |
subscription.resumed | SubscriptionsService.resumeSubscription | staff |
subscription.renewed | AuditEventsListener on SUBSCRIPTION_RENEWED | system |
subscription.plan_change_scheduled | PlanChangeService.scheduleChange | explicit actor |
subscription.plan_change_cancelled | PlanChangeService.cancelScheduledChange | staff |
subscription.plan_change_applied | PlanChangeService.afterImmediateChange | explicit actor |
subscription.plan_change_applied | RecurringChargeService.applyScheduledSwap | system (boundary cron) |
Placement rules followed here:
- After the race guard, never before.
memberCancelAtPeriodEndandscheduleChangeboth write conditionally and 409 on zero rows. The audit call sits below that check, so a conflicted attempt leaves no trace of a change that didn’t happen. - One site per funnel.
applyImmediateCancelcovers both the direct cancel and the request-approval path.scheduleChangecovers both org- and member-initiated scheduled changes.afterImmediateChangecovers both the charge and comp variants of an immediate swap, and it is the only code that runs post-commit on both.
payment
| Action | Site | Notes |
|---|---|---|
plan.created | PlansService.create | |
plan.updated | PlansService.update | Diffs price, name, credits, booking caps — only what moved |
plan.archived | PlansService.remove | remove only flips isActive; the action name says archive, not delete |
payment.charge_succeeded | PaymentTransactionService (the choke point) | Every path. See § Charges below |
payment.charge_failed | PaymentTransactionService (the choke point) | Same, with a bounded errorClass |
payment.refund_issued | PaymentService.processAutomaticRefund, openManualRefundTask, completeManualRefundTask; WebhookProcessingService.handleRefundCompleted; AdminActionsService.refund | providerResponse excluded (raw gateway payload). See § Refunds below |
payment.method_added | PaymentMethodService.storePaymentMethod | Card token and ciphertext never leave the method |
payment.method_removed | PaymentMethodService.deactivatePaymentMethod | |
payment.provider_configured | PaymentProviderConfigService.configure / update / deactivate | Records credentialsRotated: boolean, never a credential |
PaymentMethodService is membership-scoped end to end and has no org parameter, so it resolves organization_id with one indexed lookup rather than reshaping every caller’s signature.
Charges: one choke point, not nine call sites
Money leaves a member’s card through nine different code paths — desk charge,
renewal cron, debt collection, plan-change proration, hosted plan checkout, course
checkout, staff-triggered renewal, legacy createRecurring, and provider webhook
settlement. Instrumenting each of them would have been nine chances to forget, and a
tenth the next time someone adds a path.
All nine resolve through PaymentTransactionService, the DAL that owns every
payment_transactions write. Three of its writers can move a transaction to a
terminal state, and all three record:
| Writer | When it records |
|---|---|
create() | The inserted row is already terminal (the single-shot PaymentService.charge / createRecurring paths) |
updateStatus() | The row transitions to completed or failed |
completePendingBySubscriptionId() | Always — its lookup already narrowed to pending, so reaching the update IS the transition |
Four rules make this exact rather than approximate:
- Charge types only.
typemust bechargeorrecurring. Arefundrow landingcompletedis not a charge succeeding. - Charge outcomes only.
completed→payment.charge_succeeded,failed→payment.charge_failed.refunded,refund_pendingandcancelledare not outcomes of a charge attempt and produce nothing here. - Prior status is read first, and only when auditing is wired. A provider that
re-delivers
payment.completedfor an already-completed transaction must not produce a second row. The read is gated onthis.auditso an un-audited graph pays for nothing — the same stancePaymentMethodServicetakes. metadata.sourceis copied off the transaction, not inferred. Callers already label their rows (manual_charge,debt_clear,plan_change,recurring_renewal), which is what lets an owner tell a desk charge from a cron renewal without the DAL knowing who called it.recurring_renewalwas added for this; the others already existed.
Actor resolution needs no special handling: a desk charge runs on an authenticated
request so the ALS context names the staffer, while the renewal cron and provider
webhooks have no authenticated actor and land as system. Those system rows do
carry an IP (the provider’s or none at all), which is honest precisely because
actor_type already says nobody here did it.
Failures are recorded, and they carry a class rather than a message.
classifyChargeError() (apps/api/src/payments/charge-error-class.ts) maps the
gateway’s text onto a bounded set — insufficient_funds, card_expired,
card_declined, provider_unavailable, configuration, unknown, … — and an
unrecognised message becomes unknown rather than passing through. Gateway strings
are vendor-specific and have been observed echoing back parts of the request
(cardholder name, masked PAN, the free-text description a staffer typed). The
verbatim message stays in payment_transactions.error_message for support; the
audit row is retained 24 months, exported to CSV and archived to R2, so it gets the
class only.
Why failures at all: they answer the owner’s “why wasn’t this member billed”, and a run of them against one card is a reg. 11 security-event signal.
payment.charge_recorded is retired
The manual desk charge used to record its own payment.charge_recorded. With the
choke point covering the same transaction, keeping it would have double-logged
exactly the charges an owner is most likely to scrutinise. The action is gone from
AUDIT_ACTIONS and from all three dictionaries; ManualChargeService.charge now
records nothing and contributes only its metadata.source. Nothing has ever written
the old string to a database — the feature is unreleased — so there is no legacy row
to keep a label for.
Refunds: still per-site, now complete
Refunds stay at their business sites rather than moving to the choke point, because the reason, the capability (automatic / manual / provider / platform_admin) and the human actor are all known there and none of them reach the DAL. What changed is coverage: three refund paths previously wrote a refunded status with no audit row.
| Path | Row | metadata.stage |
|---|---|---|
processAutomaticRefund | Existing | (one-shot) |
openManualRefundTask | Existing | initiated |
completeManualRefundTask | Added — the money actually leaving | settled |
WebhookProcessingService.handleRefundCompleted | Added — refunded in the provider’s own portal, system actor, IP suppressed | settled |
AdminActionsService.refund | Added — Taikan staff refunding on the gym’s behalf | settled |
A manual refund therefore writes two rows for one refund, and that is the honest
shape: the decision and the settlement are separate acts, often days apart, and
stage plus after.status says which is which.
settings
| Action | Site | Notes |
|---|---|---|
organization.updated | OrganizationsService.update | The update was blind (no prior read); a getOrgOrThrow was added specifically so the row can say what the value was |
organization.updated | OrganizationsService.setJoinLinkEnabled | Records joinEnabled and whether a token was minted — never the token |
audit.viewed | AuditController.list → AuditAccessService.recordView | Coalesced per reader per 15 min |
audit.exported | AuditController.export → AuditAccessService.recordExport | Always, never coalesced |
audit.access_denied | AuditController.requireAuditViewer | Both refusal paths |
operations
Destructive staff actions on the gym’s operating data. Added after the initial build
because there was no honest home for them: they are not money, not
roster, not configuration, and burying them in settings would have made that filter
meaningless.
| Action | Site | Before/after |
|---|---|---|
class_session.cancelled | ClassSessionsService.cancel | status; metadata carries the class name, start time and how many bookings went with it |
class_session.deleted | ClassSessionsService.deleteSession | status + deletedAt |
class_session.bulk_deleted | ClassSessionsService.bulkDelete | None — one row for the batch, keyed on batchId, with deletedCount, sessionIds and the filter |
booking.cancelled_by_staff | BookingsService.adminCancel | status; metadata carries sessionId, the member’s membershipId and whether a credit came back |
workout.deleted | WorkoutsService.remove | deletedAt; name in metadata |
program.deactivated | ProgramsService.remove | isActive |
program_template.deleted | ProgramTemplatesService.remove | deletedAt |
class_type.deactivated | ClassTypesService.remove | isActive |
form_template.archived | FormsService.archiveTemplate | archivedAt |
lead.deleted | OrganizationLeadsService.eraseLead | Id-shaped snapshot only, no lead PII |
lead.bulk_deleted | OrganizationLeadsService.bulkEraseLeads | None — one row for the batch with counts and the erased ids |
announcement.deleted | AnnouncementsService.deleteAnnouncement | deletedAt; metadata carries the title and authoredByActor |
Five rules shaped this list.
- The verb matches the write, not the route. Three of these hang off a
@Deleteendpoint that only flipsisActiveor stampsarchivedAt. They are nameddeactivated/archivedaccordingly, followingplan.archived. A trail that says “deleted” about a row still sitting in the table is worse than no trail. - Bulk operations get one row, never N. A filter that sweeps 300 sessions, or a 100-id lead erase, would otherwise bury the trail under itself. The ids live in metadata so the batch is still reconstructable, and for a bulk delete the filter is the scope of the access that reg. 10 asks to record. This is a deliberate departure from the per-entity behavior of bulk membership changes, which is listed as a gap in the README.
- Member self-service stays out.
BookingsService.cancel— a member releasing their own seat — is not recorded, whileadminCancelis. “Who took this member’s spot away” is only a question when the answer isn’t the member, and self-cancels are the highest-volume write in the product. - Nothing is recorded for an action that didn’t happen. Every call sits after the role gate, the org-scope check and the commit. A refused or rolled-back attempt leaves no row.
- An erasure’s own record carries no PII.
lead.deletedrecords the org-lead id, pipeline and stage — never the name, email or phone that was just erased. Same lineMemberErasureServicedraws.
Two sites already wrote to the platform audit_logs table (deleteSession,
bulkDelete). Both keep doing so. That table is Taikan staff’s cross-org operational
ledger; this one is the gym owner’s. Member erasure already writes to both for the
same reason.
Reading the trail is itself audited
Access to an audit log is a security-relevant event in its own right (ISO/IEC 27001:2022
A.8.15, SOC 2 CC6.1/CC7.2, NIST SP 800-53 AU-9), and the Israeli access-recording duty
makes no exception for the log’s own table. AuditAccessService writes three actions, all
under settings.
Why settings and not auth: an owner filtering auth is asking “who signed in”, and
burying their own page loads in that answer would degrade the more useful signal.
| Action | Fires | Coalesced | Metadata |
|---|---|---|---|
audit.exported | Every CSV export | No | endpoint, filters, rowCount |
audit.viewed | List access | Yes — one row per (org, actor) per 15 min | endpoint, filters, returnedCount, matchedCount, coalesceWindowMinutes |
audit.access_denied | A refused request | No | endpoint, reason, role, outcome |
Three placement rules:
audit.viewedis recorded after the query, so the row can never appear in the result set it describes.audit.exportedis recorded before the CSV is written to the socket, so an aborted download still leaves the record that the data was assembled and handed over.- Denied attempts are recorded only for verified members of the org.
orgIdcomes straight off the URL; recording non-members would let any authenticated user write unbounded rows into any gym’s trail by looping over org ids, turning the control into an injection and storage-exhaustion vector against the table it protects. Documented as a gap in the README’s compliance section rather than papered over.
metadata.reason is insufficient_role — a genuine authorization failure, and the only security signal here. Rows written before 2026-08-22 may carry flag_disabled (the retired rollout gate); nothing writes it any more.
The coalescing lookup is served by audit_events_org_actor_created_idx — one index probe on an owner-only endpoint.
Why views are coalesced and exports are not
A page load, a category filter, a page-2 click and each debounced search keystroke are four requests describing one act of looking. Recording all four would make the trail mostly a record of itself and crowd out the membership and billing rows it exists for. The row carries coalesceWindowMinutes so a reader can see the rule that produced it, and AUDIT_VIEW_COALESCE_MS set to 0 restores per-request recording.
An export is the opposite: rare, deliberate, and the highest-consequence thing anyone can do to this data, because it leaves the system entirely. Never coalesced.
Like AuditService.record(), nothing in AuditAccessService throws — an audit-of-the-audit failure must not turn a working page load into a 500, and must not mask the 403 it was recording.
Secrets and free-form data
metadata must never carry a secret. Credentials, their ciphertext, card tokens and join tokens are excluded by construction at every call site.
One case needed more than construction. ConfigureProviderDto.config is documented as “non-secret provider config” but is a bare @IsObject() with no key schema — whatever an integrator puts there is whatever would land in the row, retained 24 months, exported to CSV and archived to R2. Documentation is not a control, so redactValues() keeps the keys and masks every value.
It masks everything, not just secret-looking names, and that is the point: a deny-list of *token* / *secret* patterns fails open the day a provider ships merchantCode2. The audit-relevant fact — which configuration keys the operator touched — survives intact.
CSV export hardening
Cells whose first character is =, +, -, @, tab or CR are prefixed with a single quote. Excel and Google Sheets both evaluate such cells as formulas on open, and several exported values are attacker-chosen: a member picks their own first and last name, and those are joined onto every row. Without the guard, “export the audit log” is remote code execution against the owner’s laptop (OWASP CSV injection; ASVS 7.3.1 is the general form of the rule).
Read surface
GET /organizations/:orgId/audit-events and GET /organizations/:orgId/audit-events/export.
Both call requireAuditViewer, which gates in this order:
requireMembership(orgId, clerkId)— 403 if not an active member of the org.PermissionsService.enforce(orgId, role, 'audit', 'view')— 403 for everyone but the owner. Recordsaudit.access_deniedwithreason: 'insufficient_role'.
There is no third gate: the audit-logging rollout flag (fail-closed 404, reason: 'flag_disabled') was merged permanently ON and deleted on 2026-08-22. Historical rows may still carry flag_disabled; nothing writes it any more.
requireAuditViewer returns the membership, so the controller gets the actor’s users.id for the self-audit rows without a second lookup.
The audit module is identical in PERMISSION_MATRIX_LEGACY and PERMISSION_MATRIX_V2 (owner: manage, everyone else none), so the rbac-v2 rollout cannot widen it by accident. Owner carries manage only to satisfy the “owner manages every module” invariant in permissions-matrix.unit.spec.ts; no call site ever asks for it, because the ledger is append-only.
Filters: category, action, actorUserId, targetType, targetId, from, to, search, page, limit (capped at 100). search is an ILIKE across action, target id, and the joined actor’s email / first / last name. The actor join is a LEFT JOIN so system rows are never dropped from the trail.
Export streams CSV with the same filters, capped at 50,000 rows.
Retention
Monthly at 0 3 1 * *, gated on cronsEnabled() and wrapped in runWithRetry.
A pg_try_advisory_lock (key fnv1a32('taikan:audit-retention-cron'), distinct from the recurring-charge key) guards the whole sweep. Non-blocking: an overlapping run no-ops. This matters more here than for most crons — two concurrent runs could interleave upload and delete and drop rows that were never archived.
Rows older than 24 months are grouped by (org, YYYY-MM) in JS, not with DATE_TRUNC. That keeps the query pure-Drizzle and the month logic unit-testable, the same call the revenue-trend analytics made (see the Database Policy in CLAUDE.md).
Per bucket: upload NDJSON → on success, delete that bucket’s exact ids in batches of 500, org-scoped. On upload failure: log, skip the delete, continue to the next bucket. The rows get another attempt next month. A duplicate archive is recoverable; a deleted-but-unarchived audit row is not.
The boundary is deliberately conservative. 24 months is the regulatory floor, not a target, so the sweep filters on a strict lt(created_at, cutoff) — a row exactly on the boundary survives. computeRetentionCutoff(now) is exported from the service specifically so that arithmetic is unit-tested rather than asserted in a comment; because the cron only ever fires on the 1st of a month there is no day-of-month overflow to handle.
Design deviations
Three places where the implementation departs from the obvious approach, and why:
-
AuditServiceis injected@Optional()at every domain call site, andAuditModuleis@Global(). The alternative — listingAuditModulein each consuming module’simportsand making the dep required — creates a cycle:AuditModuleimportsMembershipsModulefor the controller’srequireMembership, soMembershipsModuleimporting back would needforwardRefon both sides.@Optional()additionally keeps the existing direct-construction unit drivers (new PlansService(...),createMembershipsServiceDriver()) and partial-graph integration specs compiling untouched, which the “never modify existing tests” rule requires. The existingevents?: EventTrackingServiceinPlansServiceset this precedent.The cost is real and it bit twice during this build, so it is worth stating plainly rather than as a hypothetical:
AuditAccessServicewas written, imported into the controller and left out ofproviders. Every unit spec stayed green while the API refused to boot.apps/api/src/audit/audit.module.unit.spec.tsnow guards this: it asserts statically that every class dependency of everything the module declares is resolvable, then makes Nest actually build the graph withuseMockerstanding in for the app’s globals. It was verified to fail against that exact regression.- Because
this.audit?.record({ … })short-circuits argument evaluation when the service is absent, any DB lookup written inline as an argument silently becomes “runs only when auditing is wired”. Hoisting one out broke three pre-existing payment specs.PaymentMethodServicetherefore gates its org lookup onthis.auditbeing present, andorgIdForMembershipreturnsnullinstead of throwing — an audit-only lookup must never fail a card write that already committed.
-
AuditEntrygrewipAddress/userAgentoverrides beyond the agreed shape. Without them, Clerk webhook rows would carry Clerk’s server IP as if it were the member’s — see “the explicit-null case” above. -
PaymentTransactionServiceis instrumented at the DAL, not at the domain layer. Every other capture site in this feature sits in a service that made a business decision. The charge pair does not, and that is the point: nine callers reach one of three writers, and “exactly once, from every path” is only checkable if there is one place to check. The cost is that the DAL has no idea why it was called, which is whymetadata.sourceis copied off the transaction rather than inferred, and why refunds — where the reason and the actor matter — were deliberately left at their business sites. -
The action catalog is larger than the agreed list.
subscription.cancellation_approved,subscription.cancellation_rejectedandsubscription.plan_change_cancelledwere added because the corresponding mutation sites exist and are exactly as consequential as the ones that were named. Reusing an existing action for them would have made the trail read wrong.