Skip to content

Audit log

Every security and administration event - logins, password changes, role and member changes, invitations, organization and unit changes, billing changes - is written to an append-only audit_events table. Read this when you need to answer “who did what, when”, or when your product adds an action worth recording.

Reading it

The Audit log page (/audit, under Administration) shows to a platform admin, or to a caller holding Audit.Read organization-wide or in a unit. What it shows follows the level (Admin pages per level):

  • Platform admins see every event, GET /api/audit-events, with an Organization column and organization[], unit[] and member[] filters (the sidebar link opens on every organization; a platform admin has no active one). In the page’s filter bar the Units (“Organization / Unit”) and Members filters appear once an organization is chosen, and removing the organization clears them.
  • Organization admins (Audit.Read organization-wide) see the active organization’s events, GET /api/organization/audit-events.
  • Unit admins (Audit.Read in a unit; the built-in UNIT_ADMIN holds it) see their subtree’s history and never what is above them:
    • Their own events, always - whatever they happened to (removing a member, deleting a unit).
    • Events by people of their subtree: live members of their unit or its sub-units, and past members (a membership, ended or not, still linked to one of those units). Events without an actor (failed logins, refresh-token reuse) are organization-wide only.
    • Only when what it happened to is not above them: a person of their subtree (past members included), a unit of it (deleted units below theirs included), an invitation whose every role assignment is in their subtree (revoked and superseded ones included), or a ticket whose reporter is a person of their subtree. A role, the organization, or a person, unit, invitation or ticket outside the subtree hides the event. Any other target (a login has none; your product’s own records such as todos) shows, since its actor is already in their reach.
    • Metadata trimmed: the role entries of MEMBER_ROLES_CHANGED (added, removed), USER_INVITED and USER_INVITATION_ACCEPTED (assignments), MEMBER_REMOVED and MEMBER_LEFT (roles) that name a unit outside their subtree, or no unit (an organization-wide role), are left out. The plain roles list of USER_INVITED and USER_INVITATION_ACCEPTED is rebuilt from the assignments kept, and an invitationId of an invitation they may not see is left out. The page and its total count the events, not the entries.

GET /api/organization/audit-events is @Entity({ name: 'Audit', units: true }); with billing on, it needs a plan with the audit feature. It also takes unit[] and member[]: events whose actor is one of those people, within the caller’s reach (the page offers them in its filter bar to callers who also hold Members.Read). Platform events (no organization) never show there.

Both lists search (action, IP, request id, actor email), filter (action, actorId, targetType, targetId, from, to), sort by time (newest first) and page. from and to take a date (2026-10-02, a date-only to covers the whole UTC day) or an ISO timestamp. No route updates or deletes an event.

What an event holds

FieldMeaning
actionwhat happened, e.g. MEMBER_ROLES_CHANGED; the full list is AUDIT_ACTIONS in libs/core/shared/src/lib/audit.ts
actorIdwho did it; null for failed logins and refresh-token reuse (the account is the target, not the author)
targetType, targetIdwhat it happened to: user, role, invitation, organization, unit, …
organizationIdthe organization it belongs to; null for platform events (login, organization creation). A helpdesk status change carries the organization when its staff made it (Helpdesk), none when a platform admin did
metadatathe details: before/after values, names, the attempted email of a failed login
ip, requestId, timethe caller’s IP, the request id (same as the X-Request-Id header and reqId in the logs)

A failed login stores the attempted email even when no such user exists, so brute force against one address shows up. Passwords and tokens are never stored. Reset and resend requests, logout, successful refreshes, todo and profile edits and preference changes are not recorded.

Behind a reverse proxy or load balancer, ip is the proxy’s address unless TRUST_PROXY matches your setup (see Configuration).

Transactional or best-effort

  • Most events are written with audit.record(tx, ...) in the same transaction as the change: if the audit row cannot be written, the change is rolled back. There is never a change without its record.
  • Login successes and failures and refresh-token reuse use audit.recordBestEffort(...): a failed write is logged and the request carries on, so an audit problem cannot lock everyone out.

Adding an action

  1. Add it to AUDIT_ACTIONS in libs/core/shared/src/lib/audit.ts, and give it a label under audit.actions in both en.json and el.json (core’s, or your product’s for a product action).
  2. In the service, call this.audit.record(tx, { action, actorId, targetType, targetId, organizationId, metadata }) inside the transaction that makes the change. Use recordBestEffort only for events that must never fail the request. Never put a password or a token in metadata.
  3. Add an e2e test that checks the event is written.