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 andorganization[],unit[]andmember[]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.Readorganization-wide) see the active organization’s events,GET /api/organization/audit-events. - Unit admins (
Audit.Readin a unit; the built-inUNIT_ADMINholds 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_INVITEDandUSER_INVITATION_ACCEPTED(assignments),MEMBER_REMOVEDandMEMBER_LEFT(roles) that name a unit outside their subtree, or no unit (an organization-wide role), are left out. The plainroleslist ofUSER_INVITEDandUSER_INVITATION_ACCEPTEDis rebuilt from the assignments kept, and aninvitationIdof 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
| Field | Meaning |
|---|---|
action | what happened, e.g. MEMBER_ROLES_CHANGED; the full list is AUDIT_ACTIONS in libs/core/shared/src/lib/audit.ts |
actorId | who did it; null for failed logins and refresh-token reuse (the account is the target, not the author) |
targetType, targetId | what it happened to: user, role, invitation, organization, unit, … |
organizationId | the 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 |
metadata | the details: before/after values, names, the attempted email of a failed login |
ip, requestId, time | the 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
- Add it to
AUDIT_ACTIONSinlibs/core/shared/src/lib/audit.ts, and give it a label underaudit.actionsin bothen.jsonandel.json(core’s, or your product’s for a product action). - In the service, call
this.audit.record(tx, { action, actorId, targetType, targetId, organizationId, metadata })inside the transaction that makes the change. UserecordBestEffortonly for events that must never fail the request. Never put a password or a token inmetadata. - Add an e2e test that checks the event is written.