Skip to content

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 ​

FieldMeaning
entityTypeWhat kind of object was affected, e.g. PersonalAccessToken, User2Tenant, Deployment
entityIDThe object's UUID
actionOne of create, update, delete, view, export, import, login, logout, login_failed, rotate, revoke, expire, other
actorTypeuser, machine_user or system (for example the cron sweeps)
userThe acting user, when actorType is user
projectThe project the entry is scoped to. Only entries with a project appear in the per-project UI and query
descriptionHuman-readable summary (max 500 characters)
metadataExtra 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
changesBefore/after values for updates, as JSON
ipAddress, userAgentRequest origin, when the event came from an HTTP request
createdAtTimestamp
prevHash, rowHashThe 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:

AreaEntity typesActions
AuthenticationUserlogin and login_failed from the proxy's credential validation
Project, application and tenant lifecycleProject, Application, Tenantcreate, 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
TriggersTriggercreate, update, delete, carrying the cron expression, runKey and enabled state before and after
Environment lifecycleEnvironmentcreate, update, delete
EndpointsEndpointcreate, update, delete, with a before/after snapshot and an upstream_auth_changed marker when the upstream credential moved
Endpoint access grantsMachineUser2Endpointcreate, update, delete: which credential may call which endpoint
Configuration cascadeSettings, BackendSettings, FrontendSettingscreate, 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 membershipUser2Projectcreate, update, delete: actor, project, target and the before/after role. Recorded by both addUserToProject/updateUserProjectRole/removeUserFromProject and the generated updateUser2Project/deleteUser2Project
Application enablementTenant2Applicationcreate (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 packagesPiletPackagecreate 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 rolloutsPiletRolloutcreate 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 readsProject, ProjectListview, 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 tokensPersonalAccessTokencreate, 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
BackendsRegisteredBackendcreate 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 usersMachineUsercreate (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-serviceInvite, User2Tenant, TenantRole; Project for the join-policy changecreate, update, delete
Application membershipUser2Applicationcreate, 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 stacksDeployment, Service, DataDependency, TenantDeploymentMode, ProxyConfig (a pfy proxy sync push)create, update, delete
CommunicationsNotification (emit), NotificationRecipient (delivery), CommsPublishcreate, other
System rolesUserSystemRoleupdate
Language packsLanguagePackcreate, 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 surfaceAuditLog (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:

Rowstarget holds
User2Project and User2Application rows written by the Manager's membership mutationsthe 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 rowsthe id of the audited row itself, the same value as the top-level entityID
Deployment, Service, DataDependency, Notification, CommsPublish and the restno 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 ​

  1. Open a project and choose Audit Logs in the navigation (/<project-slug>/audit-logs).
  2. 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.
  3. 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 ​

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:

fieldMatching
entityTypeexact, case-insensitive: Project returns Project rows only, not User2Project
entityIDexact UUID (an invalid UUID matches nothing)
actionexact enum value
descriptioncase-insensitive substring
ipAddresscase-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:

http
GET /api/audit/export?format=jsonl&from=2026-01-01T00:00:00Z&to=2026-02-01T00:00:00Z&entity_type=PersonalAccessToken
Authorization: Bearer <PAT>
ParameterValues
formatjsonl (default, application/x-ndjson) or cef (text/plain)
from, toRFC 3339 timestamps bounding createdAt (optional)
entity_typeexact 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:

http
GET /api/audit/evidence                       # lists the names below
GET /api/audit/evidence/<name>?from=...&to=... # runs one

Names: 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.