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
| Action | When |
|---|---|
organization.created, organization.updated, organization.deleted | An organization is created, its name, address, logo or settings change, or an owner deletes it |
organization.ownership_transferred, member.role_changed | Ownership moves to another member, and the previous owner becomes an admin; an admin changes a member's role |
member.joined, member.removed, member.left | The creator or an invitee joins; an admin removes a member; a member leaves |
invitation.created, invitation.resent, invitation.revoked, invitation.accepted, invitation.declined | An invitation's life |
subscription.trial_started, subscription.cancel_scheduled, subscription.resumed | An owner starts the trial, cancels at the end of the period, or keeps the plan |
Each entry carries:
- the actor:
user, orsystemfor webhooks, jobs and message handlers;apiKeyarrives 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.
| Request | Answers |
|---|---|
GET /v1/organizations/{organizationId}/audit-log | The 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/export | The 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) andto(exclusive): ISO 8601 times. Atothat is not afterfromis a 400 with a field error onto.
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,.updatedor.deleted. UseTargetIdPropertywhen another property identifies the target better than the key. Mark secrets, hashes and large payloads[NotAudited]. - Name the change. Inject
IAuditWriterand callaudit.Describe(entity, "member.role_changed")before saving; the name applies to that entity's next save only. Actions arenoun.verbin 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).