Skip to content

Audit log API

Use this API to find out who did what. It reads the engine’s records (the history of everything that happened), which already name the acting principal: the person or client that made the request. The Audit area of TinyConductor Console uses the same API.

  • Requires the permission audit:read (the built-in operator and admin roles have it). Without it: 403.
  • The tenant always comes from the caller’s credential. It is never a query parameter. In Cluster mode, the database’s row-level security and an explicit tenant filter both limit the read to that tenant.
  • All three modes (Embedded, Bundled and Cluster) serve it the same way, with the same accepted requests and refusals. No mode answers mode-not-supported.

All parameters are optional. An unknown or repeated parameter is 400.

ParameterMeaning
categoryRECORD (who did what), ACCESS (refused requests) or IDENTITY (changes to users, groups, roles, permissions, API clients, mapping rules and tenants); all three when absent
subjectExact acting subject, e.g. alice. A person is named as in models: alice for the default identity provider, alice@corp for another one (Who a person is)
principalKinduser, service, client, static or anonymous (see below)
intentRecord intent, e.g. ASSIGNED, COMPLETED, CREATED, CANCELED
actionCommand action carried by the payload, e.g. claim, assign, unassign, complete (user-task records), or the change of an IDENTITY entry, e.g. client.secret.rotate
valueTypeRecord family, e.g. USER_TASK, PROCESS_INSTANCE_CREATION, PROCESS_INSTANCE_MODIFICATION, INCIDENT, DEPLOYMENT
processInstanceKeyPositive process instance key
fromInclusive lower bound, RFC 3339 (2026-09-23T10:00:00Z)
toExclusive upper bound, RFC 3339. from must be earlier than to
pageSize1–200; 0 or absent means 50; above 200 is 400
cursorThe cursor of the previous page

Refusal details are the same in every mode, for example pageSize exceeds the maximum of 200, unknown audit query parameter \tenantId`, invalid cursor`.

{
"items": [
{
"timestamp": 1790158860000000,
"partitionId": 1,
"position": 42,
"subject": "lead",
"principalKind": "user",
"tenantId": "tenant-a",
"recordType": "EVENT",
"valueType": "USER_TASK",
"intent": "ASSIGNED",
"action": "claim",
"key": "2251799813685301",
"processInstanceKey": "2251799813685290",
"correlationId": "…"
}
],
"cursor": "1790158860000000:1:41"
}
  • Entries are newest first, ordered by (timestamp, partitionId, position).
  • timestamp is epoch microseconds. Entity keys are strings.
  • action, correlationId, rejectionType and rejectionReason are present only when the record has them. A rejected command appears with recordType: "COMMAND_REJECTION".
  • cursor is null on the last page.
  • A user-task command produces a pair of records (for example ASSIGNING then ASSIGNED), both with the actor. Filter by intent=ASSIGNED (or COMPLETED, …) for one line per action.
  • For a claim made on someone else’s behalf, subject is who acted. The new assignee is in the task, not in the audit line.
  • In: every record with a principal: deployments, instance creation, cancellation, modification and migration, variable updates, incident resolution, user-task lifecycle, and the engine’s own service principal (for example bpm-runtime creating a user task).

  • Also in, as ACCESS entries: every request refused for wrong, expired or insufficient credentials: 401, 403, and 400 for a missing tenant selection, including refused token requests. A request that sends no credential at all (no token and no session, as when a browser opens the sign-in page) is refused with 401 but not recorded. Bundled and Cluster mode store them, and the IDENTITY entries below, in their PostgreSQL database; Embedded mode keeps the most recent ones in memory.

    • An access entry has "category": "ACCESS", partitionId: -1, status, method, route, and the reason in rejectionReason.
    • principalKind names the kind of credential that was refused: user (a person: a session or a sign-in), client (an API client’s token or its client credentials), static (a static token), service (a service identity from your identity provider), or anonymous when the credential named nobody the engine knows. subject is absent when no credential was recognized.
    • It has no recordType, valueType, intent, action or processInstanceKey, so those filters select records only.

    See Identity and access.

{
"category": "ACCESS",
"timestamp": 1790158861000000,
"partitionId": -1,
"position": 17,
"subject": "billing-worker",
"principalKind": "client",
"tenantId": "tenant-a",
"status": 403,
"method": "POST",
"route": "/v2/process-instances",
"key": "17",
"rejectionReason": "principal is not entitled to this action on this resource"
}

Record entries carry "category": "RECORD". Their principalKind is user or service, as the record stores it; an API client acting in a record is a service.

  • Also in, as IDENTITY entries: every change made through the administration API (/v2/users, /v2/groups, /v2/roles, /v2/mapping-rules, /v2/authorizations, /v2/clients, /v2/tenants), whether it was made or refused: creating, changing and deleting users, groups, roles, permissions, API clients (including a new secret and a revocation) and mapping rules; adding and removing members; and creating, changing (name, limits, retention, administrators), suspending, reactivating, releasing from quarantine, deleting and moving tenants. Searches and reads are not recorded.
    • An identity entry has "category": "IDENTITY", partitionId: -2, who asked (subject, principalKind), action (for example user.create, group.member.add, client.secret.rotate, tenant.suspend), targets (the ids it changed: from the path, the id a create names, a new permission’s authorizationKey, a move’s moveId), result (DONE, or REFUSED with the answer’s status and the reason in rejectionReason), method, route and summary.
    • summary is what the request asked for. Only fields that never carry a secret keep their values (names, descriptions, ids, permission types, claim names and values, expiry, limits, retention and administrators); every other field is listed with the value (not recorded). A client secret is only ever in an answer and is never recorded; neither are passwords or password hashes.
    • An entry is filed under the tenant of the person or client who made the change. The starting tenant’s operators manage the other tenants, so tenant changes are in the starting tenant’s audit log.
    • A request refused before it reaches the administration API (no credential, or no permission for the route) is an ACCESS entry instead.
{
"category": "IDENTITY",
"timestamp": 1790158862000000,
"partitionId": -2,
"position": 5,
"subject": "ada",
"principalKind": "user",
"tenantId": "tenant-a",
"action": "client.create",
"targets": {"clientId": "billing-worker"},
"result": "DONE",
"status": 201,
"method": "POST",
"route": "/v2/clients",
"summary": {"clientId": "billing-worker", "name": "Billing worker"},
"key": "5"
}

The record entries of a finished instance are removed with the instance when the tenant’s history retention has passed (30 days after it ended by default; see How long history is kept). Record entries that belong to no instance (a published message, a signal, a refused request) are removed once they are past the same retention; deployments are not affected. ACCESS and IDENTITY entries are kept for CONDUCTOR_AUTH_AUDIT_RETENTION_DAYS days (30 by default). Repeated identical refusals are recorded five times a minute and then counted in one summary entry (see Refusals and response headers).

Checking that the log was not changed — GET /v1/audit-log/integrity

Section titled “Checking that the log was not changed — GET /v1/audit-log/integrity”

In Bundled and Cluster mode the ACCESS and IDENTITY entries are stored in PostgreSQL in a way that shows later changes. Each tenant’s entries of each kind form a chain: every entry is stored with its number in the chain and a fingerprint (a SHA-256 hash) of its own content together with the fingerprint of the entry before it. Changing an entry, removing one, or removing the newest ones breaks the chain at that point.

GET /v1/audit-log/integrity checks your tenant’s chains and needs the same audit:read permission as the log itself:

{
"durable": true,
"intact": false,
"chains": [
{
"category": "ACCESS",
"entries": 1204,
"firstSequence": 37,
"lastSequence": 1240,
"headSequence": 1240,
"headHash": "5f0c…",
"intact": true,
"broken": null
},
{
"category": "IDENTITY",
"entries": 58,
"firstSequence": 1,
"lastSequence": 58,
"headSequence": 58,
"headHash": "a91e…",
"intact": false,
"broken": {
"sequence": 12,
"problem": "CHANGED",
"detail": "entry 12 was changed after it was stored"
}
}
]
}
  • broken names the first place where the chain no longer holds: CHANGED (the entry’s content was changed), UNLINKED (the entry no longer follows the one before it), MISSING (entries in the middle were removed) or HEAD (the newest entries were removed).
  • Removing old entries is what retention does, so a chain that starts later than entry 1 (firstSequence) is normal. Newest entries are only expected to be missing when they are older than the retention.
  • The check reads every stored entry of your tenant, so call it when you audit, not on every page load.
  • Embedded mode keeps its audit in memory only and answers "durable": false with no chains.

What this protects against: someone who changes or deletes entries directly in the database, for example with the engine’s database login, without going through the engine. What it does not protect against: someone who also rewrites every fingerprint after the changed entry; the fingerprints are not secret, so anyone who can write the tables can do that. To detect that too, keep each chain’s headSequence and headHash outside the installation from time to time (in a ticket or your log store). Later, ask for the fingerprint stored at that position with GET /v1/audit-log/integrity?sequence=1240: each chain then also answers hashAtSequence. If it differs from the headHash you kept, the history up to that point was rewritten. Record entries (RECORD) are part of the engine’s own log and are not in these chains.