Audit Logs
The audit log records who did what, to which object, and when. Use it to answer questions such as "who gave this user the owner role", "when was this machine user's token rotated", or "which PATs expired last month".
Authentication, credentials, membership, invites, deployments, exports and retention are recorded, and so is the create/update/delete lifecycle of a project, tenant, application, environment, endpoint, machine user, trigger, tenant offer, language pack, pilet package, pilet rollout and settings row, together with project, tenant and application membership through every mutation that can change it. Two values of the action enum have no writer: logout, because the Manager never sees it, and revoke, because revocations are written as update or delete.
What an entry contains
| Field | Meaning |
|---|---|
entityType | What kind of object was affected, e.g. PersonalAccessToken, User2Tenant, Deployment |
entityID | The object's UUID |
action | One of create, update, delete, view, export, import, login, logout, login_failed, rotate, revoke, expire, other |
actorType | user, machine_user or system (for example the cron sweeps) |
user | The acting user, when actorType is user |
project | The project the entry is scoped to. Only entries with a project appear in the per-project UI and query |
description | Human-readable summary (max 500 characters) |
metadata | Extra context as JSON, for example {"actor": ..., "target": ..., "tenant": ...}. actor is always the acting user; target does not mean the same thing in every stream, see Reading metadata.target |
changes | Before/after values for updates, as JSON |
ipAddress, userAgent | Request origin, when the event came from an HTTP request |
createdAt | Timestamp |
prevHash, rowHash | The tamper-evidence hash chain (see below) |
Secrets are redacted before an entry is stored: keys such as password, token or secret in metadata and changes are replaced, and credentials embedded in URLs are stripped.
What is recorded
Redaction and the hash chain are enforced by a create hook on the audit table, so they apply to every writer. Entity types and actions written:
| Area | Entity types | Actions |
|---|---|---|
| Authentication | User | login and login_failed from the proxy's credential validation |
| Project, application and tenant lifecycle | Project, Application, Tenant | create, update, delete, each with a before/after snapshot. The delete row is written before the delete, inside the request transaction, so it survives what it describes |
| Triggers | Trigger | create, update, delete, carrying the cron expression, runKey and enabled state before and after |
| Environment lifecycle | Environment | create, update, delete |
| Endpoints | Endpoint | create, update, delete, with a before/after snapshot and an upstream_auth_changed marker when the upstream credential moved |
| Endpoint access grants | MachineUser2Endpoint | create, update, delete: which credential may call which endpoint |
| Configuration cascade | Settings, BackendSettings, FrontendSettings | create, update, delete, recording the shape of what changed. A layer that hangs off no owner records no project, so it is not visible on a project's page |
| Project membership | User2Project | create, update, delete: actor, project, target and the before/after role. Recorded by both addUserToProject/updateUserProjectRole/removeUserFromProject and the generated updateUser2Project/deleteUser2Project |
| Application enablement | Tenant2Application | create (an offer brought into existence, including by a setApplicationEnabledForTenant(enabled: false) on a pair that had no row), update (the enablement flip, the offer settings, a config copy), delete (a withdrawal, recording the shape of the configuration leaf destroyed and never its values) |
| Pilet packages | PiletPackage | create on upload (name, version, size, SHA-256 hash, signing key id, and whether it is served on landing); update on enable, disable and description edits, from GraphQL and from the REST DELETE route alike. Never the bundle bytes or the signature |
| Pilet rollouts | PiletRollout | create for the first rollout of a pilet, update for every later setPiletRollout, promotePiletCanary and rollbackPilet, with the strategy, stable and canary versions and cohort before and after |
| Administrator reads | Project, ProjectList | view, when a system administrator reads or lists projects they are not a member of. Coalesced to one row per hour, on separate keys |
| Personal access tokens | PersonalAccessToken | create, update, delete (self-service and CLI browser login); expire written by the expiry sweep, one per lapsed token. A revocation is written as update, not delete and not revoke; the revoke action exists in the enum but nothing emits it |
| Backends | RegisteredBackend | create on registration; a re-registration is also a create, distinguished by re_register: true in the metadata rather than by a different action, and it is the only thing that rotates the callback token; other from the credential-staleness sweep when the token passes credential_rotation_max_age |
| Machine users | MachineUser | create (the mint, which records no token, hash or prefix); update; delete; rotate from rotateMachineUserToken, which also sets key_rotated in the metadata; other from the credential-staleness sweep when the credential passes credential_rotation_max_age |
| Tenant self-service | Invite, User2Tenant, TenantRole; Project for the join-policy change | create, update, delete |
| Application membership | User2Application | create, update, delete: addUserToApplication/updateUserApplicationRole/removeUserFromApplication, the generated updateUser2Application/deleteUser2Application, and the grant written when an uninvited login is auto-provisioned under the project's join policy |
| Deployments and stacks | Deployment, Service, DataDependency, TenantDeploymentMode, ProxyConfig (a pfy proxy sync push) | create, update, delete |
| Communications | Notification (emit), NotificationRecipient (delivery), CommsPublish | create, other |
| System roles | UserSystemRole | update |
| Language packs | LanguagePack | create, update (including updateLanguagePackContent), delete, recording the pack's name, display name and entry count. The translated strings themselves are never recorded, because the audit table is exported to a SIEM |
| Compliance surface | AuditLog (export), AccessReview (export of the report, other for an attestation), AuditChain (anchor), AuditRetention (prune) | export, other, delete |
There is no REST write surface for language packs, so the four GraphQL resolvers are the whole family.
Reading metadata.target
metadata.target is not one field with one meaning. Two conventions are in the table at once, and reading a row under the wrong one silently names the wrong principal:
| Rows | target holds |
|---|---|
User2Project and User2Application rows written by the Manager's membership mutations | the granted user. The thing access was granted on is under a separate resource key |
User2Tenant, Tenant2Application, TenantRole, Invite, Project, Application, Tenant, MachineUser, Endpoint, MachineUser2Endpoint, Environment, PersonalAccessToken, LanguagePack, PiletPackage, PiletRollout and the settings rows | the id of the audited row itself, the same value as the top-level entityID |
Deployment, Service, DataDependency, Notification, CommsPublish and the rest | no target key at all; do not treat its absence as a missing value |
The login-time auto-provisioning grant is the exception inside the exception: its User2Application row follows the second convention, putting the grant's row id in target, not the user.
So target cannot be read as "the principal this happened to" without first knowing the row's entityType. For a report that answers "who was given access", read the user edge for the actor and entityID plus the row's own type for the subject.
The ESB keeps a separate audit table, written per request according to each endpoint's logLevel (NONE, BASIC, FULL). See ESB.
Viewing audit logs
In the UI
- Open a project and choose Audit Logs in the navigation (
/<project-slug>/audit-logs). - The table shows Timestamp, Action, Entity Type, User and Description. Use the search box ("Search by description...") and the Filter by action / Filter by entity type selects.
- Click the info button on a row to open Audit Log Details with the entity ID, IP address, metadata and changes.
The Recent activity panels on the dashboards show the same rows and link back to this page.
Only project owners see the project audit log
The auditLogs query (and therefore the page) requires the owner project role or the administrator system role. Other members get insufficient permissions: only project owners can access audit logs.
With GraphQL
query ProjectAudit($projectID: ID!) {
auditLogs(
projectID: $projectID
filters: [{ field: "entityType", value: "User2Tenant" }]
order: { field: "createdAt", direction: DESC }
pagination: { limit: 50, offset: 0 }
) {
id
entityType
entityID
action
actorType
description
metadata
changes
ipAddress
userAgent
createdAt
user {
id
externalName
}
}
}All four arguments are required; pass filters: [] for no filter.
Filters are {field, value} pairs with no operator:
field | Matching |
|---|---|
entityType | exact, case-insensitive: Project returns Project rows only, not User2Project |
entityID | exact UUID (an invalid UUID matches nothing) |
action | exact enum value |
description | case-insensitive substring |
ipAddress | case-insensitive substring |
Any other field name is rejected with invalid filter field: <name>. There is no date-range or user filter; page through createdAt-ordered results instead. Sort fields: createdAt, action, entityType; anything else is rejected with invalid order field.
Cross-project and system-level events
Entries without a project (for example a login_failed, or a PAT event for a user with no project context) are not returned by auditLogs. Use the export below, which is not project-scoped.
Exporting for a SIEM
System administrators can stream the whole audit log (all projects, and entries with no project) over HTTP. The route sits behind the proxy secret and accepts a PAT or a forwarded session:
GET /api/audit/export?format=jsonl&from=2026-01-01T00:00:00Z&to=2026-02-01T00:00:00Z&entity_type=PersonalAccessToken
Authorization: Bearer <PAT>| Parameter | Values |
|---|---|
format | jsonl (default, application/x-ndjson) or cef (text/plain) |
from, to | RFC 3339 timestamps bounding createdAt (optional) |
entity_type | exact entity type (optional) |
Rows include row_hash and prev_hash so the receiving system can re-verify the chain segment. Each export is itself audited (AuditLog / export). A non-administrator gets 403 {"error":"audit export requires system administrator"}.
Evidence queries
The same trust boundary exposes ready-made compliance queries:
GET /api/audit/evidence # lists the names below
GET /api/audit/evidence/<name>?from=...&to=... # runs oneNames: privilege_changes_in_period, login_failures_in_period, credential_rotations_in_period, credential_expiries_in_period, retention_prunes_in_period, chain_verification_status, last_access_review_per_scope.
GET /api/access/review and POST /api/access/review/attest produce and sign off an access review; both are recorded as AccessReview entries.
Tamper evidence and retention
Hooks on the Ent client reject any update or delete of an audit row, except the retention sweep.
Every row stores prevHash (the previous row's hash; 64 zeros for the first row) and rowHash, the SHA-256 of the row's core fields plus prevHash. Appends are serialised on a Postgres advisory lock held until the writer's transaction commits, so two concurrent requests cannot both link onto the same head and fork the chain; a writer that tries to append outside a transaction is refused. The executor re-verifies the recent tail every cron.backend_heartbeat_timeout (window: cron.audit_anchor_tail_window, default 24 h), scans the whole table for forks regardless of the window, and writes an AuditChain anchor entry. A break increments the pfy_executor_audit_chain_verification_failures_total metric, and the log line says whether it found an out-of-band edit or a fork, which need different responses.
Almost every row commits in the same transaction as the change it describes, so a rolled-back request leaves no row. Three writers record something a rollback cannot take back and are appended after the request settles instead: proxy-sync pushes (the push has already reached Caddy), PAT rows (the token itself survives a failed request) and administrator cross-membership reads. A deferred append that fails is logged and counted in pfy_audit_deferred_append_total{outcome="failed"}; nothing can report it to the caller.
Set audit.external_anchor_endpoint and audit.external_anchor_signing_key to have the verified head hash signed and POSTed to an out-of-band witness. Without it the chain only detects partial tampering, not a rewrite by someone with full database access who recomputes every later hash; the Manager logs a warning at startup in that case.
The retention sweep deletes rows older than cron.audit_retention_default (default 1 year), and view events older than cron.audit_retention_view (default 90 days). It re-anchors first and records the prune as an AuditRetention / delete entry.
All of this runs in a process with run_mode executor or both; see Configuration.
Troubleshooting
An action I performed is not in the log
Check What is recorded. If it is a recorded type, confirm the entry carries your project (system-level entries only appear in the export), and remember that entityType filtering is exact: filtering by Project will not show you a User2Project grant.
The page says I lack permission
You need the owner role on the project, or the administrator system role.
The export returns 403
Your user's system role is not administrator. The export is not project-scoped by design.
Related
- Dashboard: activity feeds built on the audit log
- Users: roles that gate who can read it
- Configuration: retention, anchoring and sweep settings
- Compliance Controls: how the audit substrate maps to controls