The audit log

What the kit records in an organization's audit log and how, how admins read, filter and export it, how long entries are kept, and how a slice names or adds an action.

Every organization has an audit log: who did what to which member, invitation, setting or subscription, when, from which client, and the values before and after. An entry is written in the same transaction as the change it records, so a change that rolls back leaves no entry and an entry never describes a change that did not happen. Admins and owners read, filter and export their organization's log; nobody reads another organization's log through it.

What is recorded

ActionWhen
organization.created, organization.updated, organization.deletedAn organization is created, its name, address, logo or settings change, or an owner deletes it
organization.ownership_transferred, member.role_changedOwnership moves to another member, and the previous owner becomes an admin; an admin changes a member's role
member.joined, member.removed, member.leftThe creator or an invitee joins; an admin removes a member; a member leaves
invitation.created, invitation.resent, invitation.revoked, invitation.accepted, invitation.declinedAn invitation's life
subscription.trial_started, subscription.cancel_scheduled, subscription.resumedAn owner starts the trial, cancels at the end of the period, or keeps the plan

Each entry carries:

  • the actor: user, or system for webhooks, jobs and message handlers; apiKey arrives with API keys;
  • during an impersonation, the super-admin behind the user;
  • the target's type and identifier (a member is identified by its user id);
  • the client's IP address and user agent;
  • the request's correlation identifier;
  • the values before and after, as JSON.

The values leave out keys, the tenant, the creation and change stamps, binary values and any property marked [NotAudited], such as the invitation's token hash. An update records only the fields that changed. Owned values appear under their name, for example settings.locale.

Entries belong to an organization. An action outside any organization, such as a person's profile change, is not in this log.

Reading the log

All three endpoints need the audit:read permission (admins and owners) and stay inside the organization of the route.

RequestAnswers
GET /v1/organizations/{organizationId}/audit-logThe entries, newest first, paged (page, pageSize), with the actor's and impersonator's email and name
GET .../audit-log/{entryId}One entry with changes: each field with its value before and after
GET .../audit-log/exportThe matching entries as CSV, newest first, streamed

The list and the export take the same filters:

  • actor: a user id, matching entries by that user or by the super-admin behind an impersonation;
  • action: an exact action (member.role_changed) or its noun (member);
  • from (inclusive) and to (exclusive): ISO 8601 times. A to that is not after from is a 400 with a field error on to.

The CSV quotes values that contain commas, quotes or line breaks. It prefixes a value that a spreadsheet would read as a formula (starting with =, +, -, @ or a tab) with an apostrophe.

Retention

The worker's audit-log-retention job runs every night at 03:45 UTC. It deletes, across organizations, the entries older than Audit:RetentionDays, which defaults to 365 and can be 1 to 3650. Change the schedule with Jobs:Recurring:audit-log-retention:Cron, as for any recurring job.

Recording from a slice

  • Mark the entity. [Audited("member")] on a tenant-scoped entity, or on the organization itself, records its creations, changes and deletions automatically when the context saves. The default action is <type>.created, .updated or .deleted. Use TargetIdProperty when another property identifies the target better than the key. Mark secrets, hashes and large payloads [NotAudited].
  • Name the change. Inject IAuditWriter and call audit.Describe(entity, "member.role_changed") before saving; the name applies to that entity's next save only. Actions are noun.verb in snake case.
  • Record an action without an audited entity. Call audit.Record(organizationId, action, targetType, targetId, before, after); the entry is saved with the caller's next save. The trial and the cancellation do this, because the subscription mirror also changes through Stripe's webhooks, which are not actions of the organization.
  • The actor is automatic. In the API it is the request's person, client and correlation; in the worker it is the platform.
  • A failed save records nothing. Its entries are discarded. A retried save records the change once, under the action it was given.

Testing

packages/core/tests/Colden.Core.Tests/Persistence/AuditLogTests.cs covers the interceptor and the writer on PostgreSQL:

  • changed fields only;
  • creations and deletions;
  • owned values;
  • described actions;
  • impersonation;
  • rollback;
  • a failed save retried;
  • tenant isolation.

apps/api/tests/Colden.Api.Tests/Features/AuditLog/ covers the endpoints, the filters, the permissions, the CSV escaping and the billing actions. apps/worker/tests/Colden.Worker.Tests/Handlers/Audit/ covers the retention. The PostgreSQL tests need a Docker engine and report as skipped without one (see the testing guide).