Skip to content

How-to

Audit log schema design

An audit event is a public interface. Once a customer builds an alert on an action name, renaming it is a breaking change on their side, not a refactor on yours. This guide is about getting the schema right on the first pass, because the second pass is expensive.

The shape of an event

Every useful audit event answers four questions: who did it, what did they do, to what, and when. Everything else is context that helps during an incident but is not part of the contract.

FieldHoldsNotes
actorWho performed the actionA user, an API key, an agent or the system itself
actionWhat was donePast tense, stable forever, dot-namespaced
targetsWhat it was done toAn array, because one action can touch several things
occurredAtWhen it happenedThe time of the action, not the time you sent the event
organizationIdWhose log it belongs inMissing this makes the event invisible to the customer
contextWhere it came fromIP address, user agent, request ID
metadataEverything elseFree-form, and where most mistakes live

Naming actions

Use resource.past_tense_verb, namespaced by area. Past tense matters: an audit log records what happened, and an action named in the imperative reads like an instruction that may or may not have succeeded.

Record successes. If you also record failed attempts — and for authentication you should — give them their own action name rather than a success flag, so an alert on sign-in failures does not require reading a boolean.

GoodPoorWhy
user.signed_inloginNamespaced and unambiguous
user.sign_in_faileduser.signed_in (failed: true)Alertable without inspecting fields
organization.member_removeddelete_userSays which relationship changed
role.permission_grantedupdate_roleNames the actual change
vault.object_decryptedreadMeaningless without a namespace
api_key.createdkey_eventAn event type is not a category

Actors that are not people

A great many audit events are not performed by a human. Scheduled jobs, API keys, directory syncs and agents all act, and an event that attributes their work to a person is worse than one that admits it does not know.

Type the actor explicitly, and where an agent acts for somebody, record both: the agent as the actor and the person as the principal. During an incident, being able to distinguish what somebody did from what something did on their behalf is exactly the question being asked.

actors.json
1{ "actor": { "type": "user", "id": "user_01HQZX8N4T", "name": "avery@foo-corp.example" } }
2
3{ "actor": { "type": "api_key", "id": "key_01HQZX8N4T", "name": "billing-worker" } }
4
5{ "actor": { "type": "system", "id": "directory_sync", "name": "Directory Sync" } }
6
7{
8 "actor": { "type": "agent", "id": "agent_01HQZX8N4T", "name": "Support Assistant" },
9 "principal": { "type": "user", "id": "user_01HQZX8N4T" }
10}

What belongs in metadata

The test is whether a field helps answer a question during an incident. Which plan a subscription moved from and to: yes. The entire subscription record: no.

The second test is whether it would be a problem if the field appeared in a customer's SIEM, in a support screenshot and in an export sitting on somebody's laptop. Audit logs are copied more widely than any other data your product holds.

  • Include: the before and after values of what changed, the request ID, the reason if one was given, and the identifiers of anything else affected.
  • Exclude: passwords, tokens, session values, decrypted secrets, full personal records, and anything you would not paste into a support ticket.
  • Be careful with: free-text fields written by users, which can carry personal data you did not intend to store, and error messages, which sometimes carry credentials from the layer below.

Redacting an audit log after the fact is difficult by design — the record is append-only, which is the property that makes it trustworthy. Deciding what not to write is the only reliable control.

Changing the schema later

You will need to add fields; you should almost never remove or rename them. Treat the action name and the shape of actor and targets as frozen, and put anything you are unsure about into metadata, where additions are safe.

When an action genuinely must change meaning, introduce a new name and emit both for a period, rather than redefining the old one. A customer's alert firing on a subtly different meaning is worse than one that stops firing, because the second is noticed.

lib/audit.ts
1import { Paycux } from "@paycux/node";
2
3const paycux = new Paycux(process.env.PAYCUX_API_KEY);
4
5// Tek giris noktasi: her cagri ayni sozlesmeden gecer.
6export async function audit(input: {
7 organizationId: string;
8 action: string;
9 actor: { type: string; id: string; name?: string };
10 targets: { type: string; id: string; name?: string }[];
11 occurredAt?: string;
12 context?: { location?: string; userAgent?: string; requestId?: string };
13 metadata?: Record<string, string | number | boolean>;
14}) {
15 await paycux.auditLogs.createEvent({
16 ...input,
17 occurredAt: input.occurredAt ?? new Date().toISOString(),
18 });
19}

Which events to emit

Start with what a security reviewer will ask for, then add what your own support team keeps guessing at. That list is shorter than instrumenting everything and considerably more useful.

  • Authentication: sign-in, sign-in failure, sign-out, MFA enrolment and reset, session revocation.
  • Access changes: role granted or revoked, member added or removed, invitation sent and accepted.
  • Configuration: SSO connection created or deactivated, directory connected, API key created or revoked.
  • Data movement: export created, bulk delete performed, secret decrypted, integration connected.
  • Anything irreversible in your own product, whatever it is.