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

Audit Logging — Behavior

Invariants

  1. Append-only. No code path updates or soft-deletes an audit_events row. The only deletion is the retention cron, and it archives first.
  2. Capture is unflagged. AuditService.record() has no feature-flag gate, and since 2026-08-22 neither does the read surface — the audit-logging flag was merged permanently ON and deleted.
  3. An audit failure never fails the request. record() catches everything, logs a warn, 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.
  4. Every read is org-scoped. AuditQueryService.buildWhere starts its condition list with eq(auditEvents.organizationId, orgId) and that entry is never optional.
  5. No secrets in metadata. See data-model.md § metadata convention.
  6. actor_type and actor_user_id never 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

SituationResult
Authenticated requestuser, with IP / user-agent / request id from the request
@Public() route, or unauthenticatedsystem, but provenance (IP etc.) is still recorded
Cron, queue worker, no HTTP request at allsystem, 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:

  • ipAddress omitted → take the ALS value (the normal case)
  • ipAddress: '…' → use this value
  • ipAddress: 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.createdauth.login. session.removed and session.revokedauth.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 auth category 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 holds none on 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.

ActionSource
auth.loginClerk session.created webhook
auth.logoutClerk session.removed / session.revoked webhook
auth.user_updatedClerk user.updated webhook

membership

ActionSiteBefore/after
membership.activatedAuditEventsListener on MEMBERSHIP_ACTIVATEDafter only
membership.role_changedMembershipsService.updateMembershiprole
membership.status_changedMembershipsService.updateMembershipstatus
membership.removedMembershipsService.updateMembership (status → cancelled) and MemberErasureService.eraseMemberstatus / deletedAt
membership.invitation_revokedMembershipsService.revokeInvitationstatus

Two notes:

  • Member-added is a listener, not an inline call. MEMBERSHIP_ACTIVATED has three producers (invitation acceptance, join-link acceptance, lead conversion, the last of which lives in a different module). One @OnEvent covers all three; adding inline calls would double-log.
  • updateMembership diffs before recording. Its UPDATE always sets updatedAt, so it fires even on a profile-only or email-only PATCH. Logging unconditionally would fill the trail with no-ops.

billing

ActionSiteActor
subscription.createdSubscriptionsService.createSubscriptionALS (staff enrolment / member checkout) or system (webhook activation)
subscription.cancelledSubscriptionsService.applyImmediateCancelexplicit actor
subscription.cancelledSubscriptionsService.sweepDueCancellationssystem (period-end cron)
subscription.cancellation_scheduledSubscriptionsService.memberCancelAtPeriodEndstaff (recording the member’s notice)
subscription.cancellation_requestedCancellationRequestsService.createmember
subscription.cancellation_approvedCancellationRequestsService.approvestaff
subscription.cancellation_rejectedCancellationRequestsService.rejectstaff
subscription.pausedSubscriptionsService.freezeSubscriptionstaff
subscription.resumedSubscriptionsService.resumeSubscriptionstaff
subscription.renewedAuditEventsListener on SUBSCRIPTION_RENEWEDsystem
subscription.plan_change_scheduledPlanChangeService.scheduleChangeexplicit actor
subscription.plan_change_cancelledPlanChangeService.cancelScheduledChangestaff
subscription.plan_change_appliedPlanChangeService.afterImmediateChangeexplicit actor
subscription.plan_change_appliedRecurringChargeService.applyScheduledSwapsystem (boundary cron)

Placement rules followed here:

  • After the race guard, never before. memberCancelAtPeriodEnd and scheduleChange both 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. applyImmediateCancel covers both the direct cancel and the request-approval path. scheduleChange covers both org- and member-initiated scheduled changes. afterImmediateChange covers both the charge and comp variants of an immediate swap, and it is the only code that runs post-commit on both.

payment

ActionSiteNotes
plan.createdPlansService.create
plan.updatedPlansService.updateDiffs price, name, credits, booking caps — only what moved
plan.archivedPlansService.removeremove only flips isActive; the action name says archive, not delete
payment.charge_succeededPaymentTransactionService (the choke point)Every path. See § Charges below
payment.charge_failedPaymentTransactionService (the choke point)Same, with a bounded errorClass
payment.refund_issuedPaymentService.processAutomaticRefund, openManualRefundTask, completeManualRefundTask; WebhookProcessingService.handleRefundCompleted; AdminActionsService.refundproviderResponse excluded (raw gateway payload). See § Refunds below
payment.method_addedPaymentMethodService.storePaymentMethodCard token and ciphertext never leave the method
payment.method_removedPaymentMethodService.deactivatePaymentMethod
payment.provider_configuredPaymentProviderConfigService.configure / update / deactivateRecords 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:

WriterWhen 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. type must be charge or recurring. A refund row landing completed is not a charge succeeding.
  • Charge outcomes only. completedpayment.charge_succeeded, failedpayment.charge_failed. refunded, refund_pending and cancelled are 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.completed for an already-completed transaction must not produce a second row. The read is gated on this.audit so an un-audited graph pays for nothing — the same stance PaymentMethodService takes.
  • metadata.source is 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_renewal was 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.

PathRowmetadata.stage
processAutomaticRefundExisting(one-shot)
openManualRefundTaskExistinginitiated
completeManualRefundTaskAdded — the money actually leavingsettled
WebhookProcessingService.handleRefundCompletedAdded — refunded in the provider’s own portal, system actor, IP suppressedsettled
AdminActionsService.refundAdded — Taikan staff refunding on the gym’s behalfsettled

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

ActionSiteNotes
organization.updatedOrganizationsService.updateThe update was blind (no prior read); a getOrgOrThrow was added specifically so the row can say what the value was
organization.updatedOrganizationsService.setJoinLinkEnabledRecords joinEnabled and whether a token was minted — never the token
audit.viewedAuditController.listAuditAccessService.recordViewCoalesced per reader per 15 min
audit.exportedAuditController.exportAuditAccessService.recordExportAlways, never coalesced
audit.access_deniedAuditController.requireAuditViewerBoth 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.

ActionSiteBefore/after
class_session.cancelledClassSessionsService.cancelstatus; metadata carries the class name, start time and how many bookings went with it
class_session.deletedClassSessionsService.deleteSessionstatus + deletedAt
class_session.bulk_deletedClassSessionsService.bulkDeleteNone — one row for the batch, keyed on batchId, with deletedCount, sessionIds and the filter
booking.cancelled_by_staffBookingsService.adminCancelstatus; metadata carries sessionId, the member’s membershipId and whether a credit came back
workout.deletedWorkoutsService.removedeletedAt; name in metadata
program.deactivatedProgramsService.removeisActive
program_template.deletedProgramTemplatesService.removedeletedAt
class_type.deactivatedClassTypesService.removeisActive
form_template.archivedFormsService.archiveTemplatearchivedAt
lead.deletedOrganizationLeadsService.eraseLeadId-shaped snapshot only, no lead PII
lead.bulk_deletedOrganizationLeadsService.bulkEraseLeadsNone — one row for the batch with counts and the erased ids
announcement.deletedAnnouncementsService.deleteAnnouncementdeletedAt; 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 @Delete endpoint that only flips isActive or stamps archivedAt. They are named deactivated / archived accordingly, following plan.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, while adminCancel is. “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.deleted records the org-lead id, pipeline and stage — never the name, email or phone that was just erased. Same line MemberErasureService draws.

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.

ActionFiresCoalescedMetadata
audit.exportedEvery CSV exportNoendpoint, filters, rowCount
audit.viewedList accessYes — one row per (org, actor) per 15 minendpoint, filters, returnedCount, matchedCount, coalesceWindowMinutes
audit.access_deniedA refused requestNoendpoint, reason, role, outcome

Three placement rules:

  • audit.viewed is recorded after the query, so the row can never appear in the result set it describes.
  • audit.exported is 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. orgId comes 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:

  1. requireMembership(orgId, clerkId)403 if not an active member of the org.
  2. PermissionsService.enforce(orgId, role, 'audit', 'view') — 403 for everyone but the owner. Records audit.access_denied with reason: '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:

  1. AuditService is injected @Optional() at every domain call site, and AuditModule is @Global(). The alternative — listing AuditModule in each consuming module’s imports and making the dep required — creates a cycle: AuditModule imports MembershipsModule for the controller’s requireMembership, so MembershipsModule importing back would need forwardRef on 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 existing events?: EventTrackingService in PlansService set this precedent.

    The cost is real and it bit twice during this build, so it is worth stating plainly rather than as a hypothetical:

    • AuditAccessService was written, imported into the controller and left out of providers. Every unit spec stayed green while the API refused to boot. apps/api/src/audit/audit.module.unit.spec.ts now guards this: it asserts statically that every class dependency of everything the module declares is resolvable, then makes Nest actually build the graph with useMocker standing 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. PaymentMethodService therefore gates its org lookup on this.audit being present, and orgIdForMembership returns null instead of throwing — an audit-only lookup must never fail a card write that already committed.
  2. AuditEntry grew ipAddress / userAgent overrides 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.

  3. PaymentTransactionService is 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 why metadata.source is copied off the transaction rather than inferred, and why refunds — where the reason and the actor matter — were deliberately left at their business sites.

  4. The action catalog is larger than the agreed list. subscription.cancellation_approved, subscription.cancellation_rejected and subscription.plan_change_cancelled were 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.