- Platform & Tenancy
- Audit Log
Audit Log
The Audit Log shows recorded execution, security, and administrative events in an
environment. Function recording controls workflow run, step, and saga capture.
Security and resource events are captured independently of that flag, subject to
their capture settings. For example, KV audit emission requires
IRONFLOW_AUDIT_KV_EMIT=1. Platform-scoped records are available in Platform Audit.
Browse the dashboard feed
Section titled “Browse the dashboard feed”The dashboard enables Hide successful authorization checks by default to keep
routine reads from crowding out activity. Uncheck it, or use Clear filters, to
see successful checks alongside other events. Selecting
authz.decision.allowed explicitly also turns off the hide control.
Category presets narrow the displayed events:
- Workflow execution: run, step, saga, and debounce events, including operator actions on runs and steps.
- Security: authorization and platform event families, within the endpoint’s existing scope.
- Administration and infrastructure: other event families, including function, deployment, resource, and capacity events.
The category and hide controls filter loaded rows. They do not change capture or remove stored records. Function, run, and event-type filters are sent to the audit query. Active filters remain visible above the table. Counts distinguish shown rows, loaded rows, and the total matching the server query. Use Load More to inspect older pages, including when every loaded row is hidden by display filters. The page does not search all older pages automatically.
Loading, request failures, an empty feed, and no matching results have separate messages. Retry repeats a failed request; a failed refresh keeps previously loaded rows visible. Authorization decision rows show action, actor, API key, resource, and decision. Expand any row to inspect its full payload and metadata.
If the environment has no recorded events, check capture configuration and retention as well as function recording. Enabling function recording does not control security or resource capture.
Key Concepts
Section titled “Key Concepts”| Concept | Description |
|---|---|
| Audit Event | An immutable record of execution, security, or administrative activity. |
| Recording | Per-function toggle for workflow run, step, and saga audit capture. |
| Audit Trail | The ordered sequence of audit events for a run, queryable by type and time range. |
Event Types
Section titled “Event Types”Every audit event has a event_type that identifies what happened. The types are
defined as AuditEventType constants in internal/store/models.go, which is the
single source of truth. The dashboard catalog is generated from those declarations
and their scope/availability metadata. The database does not constrain the column
(see ADR-0052).
The tenant event selector offers available tenant-scoped types, including
deployment.registered and deployment.conflict_rejected. Platform-only types,
including cluster.encryption.verified, belong in Platform Audit and are omitted
from tenant choices. cluster.token.rotated is tenant-scoped despite its prefix.
The backend enforces query scope even if a caller supplies a platform event type.
platform.key.created and platform.key.revoked remain reserved declarations:
there are no current producers for these event names. They are omitted from the
selector, and an older link selecting one is labeled as reserved. This does not
remove access to stored records or introduce new capture behavior.
For contributors: add an explicit AuditEventType string constant with a trailing
// audit: tenant available annotation, using platform or reserved where
appropriate. Check the producer’s storage scope rather than inferring it from the
name. Run make audit-catalog and commit the generated file. make audit-catalog-check
verifies parity without rewriting the file and tests the generator. This gate runs
in make ci and before make web. Availability describes producer support; actual
capture still depends on the applicable recording and operator settings.
Runs, steps, and lifecycle
Section titled “Runs, steps, and lifecycle”| Event Type | When | Key Payload Fields | Globally redactable fields |
|---|---|---|---|
run.created | A new run is created | runId, functionId, input, eventId | input |
run.status_changed | Run status transitions | runId, oldStatus, newStatus, reason | reason |
step.started | A step begins execution | stepId, stepName, stepType | stepName |
step.completed | A step finishes successfully | stepId, output, durationMs | output |
step.failed | A step fails | stepId, error, attempt | error |
step.patched | A step output is hot-patched | stepId, originalOutput, patchedOutput, patchedBy | originalOutput, patchedOutput |
saga.compensation.started | A compensation step begins | stepId, compensationTargetStep | None |
saga.compensation.completed | A compensation step succeeds | stepId, result | result |
saga.compensation.failed | A compensation step fails | stepId, error, attempt | error |
step.injected | Step output injected during scoped injection | stepId, previousOutput, newOutput, reason | previousOutput, newOutput, reason |
run.paused | Reserved — declared but not currently emitted; a pause records run.status_changed | — | None |
run.resumed | Run resumed after pause | runId, actorId, fromStep, newStatus | None |
run.cancelled | A run was cancelled by an operator | runId, actorId, reason, cause | reason |
run.deleted | A terminal run was deleted | resource_id, name, org_id, env_id, actor_id, change_type | None |
entity_stream.deleted | An entity stream was deleted | resource_id, name, org_id, env_id, actor_id, change_type | None |
event.redacted | An event’s data was replaced with a placeholder | resource_id, name, org_id, env_id, actor_id, change_type | None |
step.redacted | A step’s output was replaced with a placeholder | resource_id, name, org_id, env_id, actor_id, change_type | None |
run.redacted | A run’s payloads were replaced with a placeholder | resource_id, name, org_id, env_id, actor_id, change_type | None |
debounce.cancelled | A pending debounce entry was cancelled | environment_id, function_id, debounce_key | debounceKey |
environment.created | Environment created | env_id, env_name, project_id, org_id, actor_id, change_type | None |
environment.deleted | Environment deleted | env_id, env_name, project_id, org_id, actor_id, change_type | None |
environment.updated | Environment renamed or recolored | same as above | None |
Global payload redaction
Section titled “Global payload redaction”Set IRONFLOW_AUDIT_PAYLOAD_REDACT_FIELDS to a comma-separated list of exact
<event_type>.<field> selectors. The setting applies to the whole server
process and is read once at startup. Empty or unset configuration keeps full
payload capture. For example:
IRONFLOW_AUDIT_PAYLOAD_REDACT_FIELDS='run.created.input,step.completed.output' ironflow serveThe table above is the complete selector catalog. Combining each event type
with one field in its last column gives the accepted selectors. Nested paths,
wildcards, duplicates, unknown fields, and selectors for protected event
families are rejected before the server accepts traffic. Authorization,
platform, resource, control-plane, capacity, and scanner families keep their
existing capture rules. The cataloged run.cancelled.reason remains
selectable even though cancellation is an operator action.
Ironflow copies and removes selected top-level fields before it serializes or
writes the audit row. It records the canonical omitted field names as a sorted,
comma-separated audit_redacted_fields metadata value. The marker contains no
omitted values. debounce.cancelled.debounceKey is the public selector. It
removes the existing debounce_key field from the stored JSON and records
debounceKey in the marker.
The policy affects rows written after the configured process starts. It does not rewrite existing audit rows. Audit queries and exports omit the selected fields on affected rows and expose the metadata marker so consumers can tell redaction from a field the producer never supplied.
Redaction changes only the audit copy. Durable run and step inputs, outputs,
and errors remain available through execution-state APIs and continue to drive
retry, resume, and memoization. Time travel still requires
recordingProfile=all, but that profile cannot restore a globally redacted
value. Reconstructed snapshots omit the selected input, output, error, or patch
value, and GetStepOutputAt returns no value for a redacted output.
If runtime sanitization fails, Ironflow skips that audit row, records a delivery failure, and logs the event type. The workflow and its durable state continue. Ironflow never falls back to the unsanitized payload.
Capacity and dispatch
Section titled “Capacity and dispatch”Exception events only — normal queue/reserve/release transitions are metrics and logs, never audit rows.
| Event Type | When | Key Payload Fields |
|---|---|---|
capacity.lease.expired | A worker lease expired and was fenced/reclaimed | environment, executionSeq, laneDigest, leaseTokenHash, reason |
capacity.segment.recovered | An expired segment was re-enqueued under a fresh executionSeq | environment, executionSeq, laneDigest, leaseTokenHash, reason |
capacity.stale_mutation.rejected | A stale worker message was fenced off | environment, executionSeq, laneDigest, leaseTokenHash, reason |
capacity.refresh.rejected | A stale or unknown lease refresh was rejected | environment, executionSeq, laneDigest, leaseTokenHash, reason |
capacity.run.readmitted | A run stranded waiting for capacity with no queued segment was re-admitted by the orphan sweep | environment, executionSeq, laneDigest, leaseTokenHash, reason (laneDigest and leaseTokenHash are empty) |
laneDigest and leaseTokenHash are opaque digests. Raw keys and lease tokens never reach the payload.
Authorization
Section titled “Authorization”| Event Type | When | Key Payload Fields |
|---|---|---|
authz.decision.allowed | An authorization check passed | api_key_id, action, resource, decision, environment, method, path, policy_id, reason |
authz.decision.denied | An authorization check failed | same as above |
authz.decision.would_deny | A monitor-mode policy set would have denied the check | same as above, plus policy_set_version, policy_set_mode |
authz.key.created | An API key was created | api_key_id, key_name, org_id, actor_id |
authz.key.revoked | An API key was revoked | api_key_id, key_name, org_id, actor_id, reason |
authz.role.changed | A role was created, updated, or deleted | role_id, role_name, org_id, change_type, actor_id |
authz.policy.changed | The policy set was written or rolled back | policy_id, policy_name, org_id, change_type, actor_id, old_version, new_version, mode, old_mode (conditional), bypass, bypass_reason (conditional) |
A platform API key uses the same authz.key.created, authz.key.revoked, and authz.role.changed types, with platform scope. Read those rows in Platform Audit. Impersonating a tenant does not put them in that tenant’s feed. A change to the tenant’s own key or role stays in the tenant feed.
Platform
Section titled “Platform”Scoped platform, not tenant. Read them via /api/v1/platform/audit.
| Event Type | When | Key Payload Fields |
|---|---|---|
platform.user.created | A platform user was created | user_id, email, change_type |
platform.user.updated | A platform user was updated | user_id, email, change_type |
platform.user.deleted | A platform user was deleted | user_id, email, change_type |
platform.impersonated | An impersonation session was started | api_key_id, platform_user_id, target_org_id, target_env_id, action, allowed |
platform.role.changed | A delete or update of a built-in role was blocked | role_id, role_name, action, change_type |
platform.key.created | Reserved — declared but not currently emitted | — |
platform.key.revoked | Reserved — declared but not currently emitted | — |
cluster.encryption.verified | The startup encryption pre-flight ran | status, backend, required, data_path, reason, cluster_id, node_id |
cluster.encryption.verified is platform-scoped rather than tenant-scoped because its payload
describes the host, not an application: filesystem paths, cluster ID and node ID. It has no
actor — the engine emits it at startup.
Resources
Section titled “Resources”| Event Type | When | Key Payload Fields |
|---|---|---|
secret.created | A secret was created | name, org_id, env_id, actor_id, change_type |
secret.updated | A secret’s value was replaced | same as above |
secret.deleted | A secret was deleted | same as above |
kv.bucket.created | A KV bucket was created | bucket, key, org_id, env_id, actor_id, operation |
kv.bucket.deleted | A KV bucket was deleted | same as above |
kv.key.put | A KV key was written | same as above |
kv.key.deleted | A KV key was deleted | same as above |
webhook.source.created | A webhook source was provisioned | source_id, name, org_id, env_id, actor_id |
cluster.token.rotated | A cluster cap-token was rotated | cluster_id, token_kind, actor_id, reason |
org.export | A customer-data export completed | org_id, actor_id, started_at, completed_at, include, from (conditional), to (conditional), record_counts, body_sha256, bytes_streamed |
Control-plane resources
Section titled “Control-plane resources”Every row below shares one payload shape: resource_type, resource_id, name, org_id, env_id, actor_id, change_type, plus version and fields where they apply.
| Event Type | When | Extra Payload Fields |
|---|---|---|
function.created | A function was registered for the first time | — |
function.updated | A registered function’s config changed | — |
function.deleted | A function was deleted | — |
function.paused | A function was paused | — |
function.resumed | A paused function was reactivated | — |
function.rolled_back | A function was rolled back to an earlier version | version (the target) |
projection.registered | A projection was registered or re-registered | — |
projection.deleted | A projection was unregistered | — |
projection.paused | A projection stopped consuming events | — |
projection.resumed | A paused projection resumed consuming | — |
projection.rebuild_started | A rebuild job was queued — not that it finished | — |
projection.rebuild_cancelled | A running rebuild job was cancelled | — |
schema.registered | An event schema version was registered | version |
schema.deleted | An event schema version was deleted | version |
consumer_group.created | A consumer group was created | — |
consumer_group.updated | A consumer group’s config changed | fields (changed field names) |
consumer_group.deleted | A consumer group was deleted | — |
deployment.registered | An executable deployment was registered | version (the artifact digest) |
deployment.conflict_rejected | A registration tried to redefine an existing deployment ID and was refused | version (the rejected digest) |
deployment.registered is written only by the call that actually created the
row. A worker fleet re-registers the same build on every boot, and those
idempotent calls are not audited — otherwise the one event worth seeing would be
buried under one row per worker per rollout.
deployment.conflict_rejected is the opposite case, and it records something
that did not happen: an authorized caller tried to point an existing
deployment ID at a different artifact, snapshot, mode or endpoint, and the
server refused. Deployment IDs are what pinned runs resolve through, so
redefining one would move those runs onto code nobody chose for them.
Authorization logs allow/deny decisions and this call is allowed — the handler
is what rejects it — so this is the only record that the attempt was made. Its
version carries the digest that was refused; the one on record is readable
from the deployment itself.
A re-registration writes projection.registered / schema.registered again rather than an .updated variant: both resources are addressed by name (and version, for schemas), so registering over one is a registration that clobbers — and the clobber is the thing worth seeing.
Function code, schema documents and consumer-group configs never appear in these payloads. As with secrets, the event struct has no field to hold them.
Secret values, KV values, and token material are structurally absent from these payloads — omitted at the event-struct level, not filtered at serialization.
The four kv.* events are off by default. Set IRONFLOW_AUDIT_KV_EMIT=1 to record them; they sit on a hot data path and will grow the audit table quickly.
Effective settings and delivery health
Section titled “Effective settings and delivery health”The Settings page shows the effective policy for the current server. It
distinguishes per-function workflow recording from server-wide operator
controls: successful authorization capture, excluded allowed actions, KV
capture, global payload redaction, global retention, and delivery health. The
same read-only data is available at GET /api/v1/audit/settings.
payload_redaction_enabled reports whether the policy is active, and
payload_redaction_fields contains its normalized, sorted selectors.
Server-wide values are read at startup and changes require a restart; the view
does not offer edits.
Audit delivery is synchronous and best effort. A recorder attempts each row at most once and logs an error if that write fails. The request or workflow that emitted the record continues, so telemetry failure does not become execution failure. Automatic retries are disabled to avoid duplicate rows when a store write result is ambiguous.
Workflow and capacity audit rows can also drop before they reach the store,
even on a healthy database, when a load passes the recorder’s limits. Do not
use audit_events as a complete record of a run. See
Audit rows can drop under load
for the limits and how to find the cause.
With IRONFLOW_METRICS_ENABLED=true, alert on
ironflow_audit_write_failures_total{source,event_type}, which counts each row
that fails in the recorder. That page gives the alert expression. /ready also returns an audit warning, but the next
successful audit write clears it, so it can show healthy while rows are lost.
The warning is report-only so an idle replica remains available to recover
after the store comes back.
Audit and agent data boundaries
Section titled “Audit and agent data boundaries”| Data | Purpose and persistence |
|---|---|
| Execution audit | Optional function-scoped lifecycle events for inspection, controlled by recording. |
| Durable step results | Persisted outputs used to resume a run, even with recording: false. |
| Case state | Application business facts, such as a ticket status, stored in the application’s chosen database or entity stream. |
| Projection-backed memory and curated knowledge | Derived state over explicit events. The application chooses what is suitable to retain and reuse as model context. |
The Node agent’s llm.complete wrapper persists the completion returned by the provider closure as a durable step result, including returned metadata. Disabling audit recording does not prevent that persistence. Return only replay-required data from the closure and omit unnecessary raw or sensitive content.
Raw audit logs are not automatically appropriate or authorized model context. Projections derive views; they do not automatically provide curation, redaction, or authorization. Apply those policies before supplying data to a model.
Audit retention and payload redaction govern audit rows, not all execution data. Pruning or redacting audit events does not remove durable step results, case state, or memory. The global audit pruner applies as described below.
Enabling Recording
Section titled “Enabling Recording”Recording is configured per-function. When recording is off, no function-scoped audit events are captured. Existing events are preserved.
Use recordingProfile when a function needs a narrower workflow capture boundary:
| Profile | Captured workflow events |
|---|---|
all | Run lifecycle, step, and saga compensation events |
run_lifecycle | run.created and run.status_changed |
steps | Step events and saga compensation events |
| empty or omitted | Legacy behavior: recording: true means all; false means off |
A non-empty profile enables recording even when the legacy boolean is omitted. The
server returns the effective profile, so legacy enabled functions appear as
all. Operator, authorization, resource, control-plane, and scanner events have
their existing capture rules and are independent of this workflow profile.
Operator actions against the control plane also ignore it: run.cancelled, run.resumed, and every event in the Resources and Control-plane resources tables. They are not function-scoped application telemetry, so a per-function toggle must not be able to hide them.
Three more events ignore this toggle: capacity.lease.expired, capacity.segment.recovered and capacity.run.readmitted. They are emitted by the internal capacity scanner and orphan sweep, not by a function, and record unconditionally — they are platform concerns a tenant’s per-function toggle must not suppress. Everything else in the tables above respects the setting.
import { createFunction } from "@ironflow/node";
const processOrder = createFunction( { id: "process-order", recordingProfile: "steps", triggers: [{ event: "order.placed" }], }, async ({ event, step }) => { const validated = await step.run("validate", async () => { return validateOrder(event.data); }); // Step and saga compensation events are recorded; run lifecycle events are not. return validated; },);var ProcessOrder = ironflow.CreateFunction(ironflow.FunctionConfig{ ID: "process-order", RecordingProfile: ironflow.RecordingProfileSteps, Triggers: []ironflow.Trigger{{Event: "order.placed"}},}, func(ctx ironflow.Context) (any, error) { result, err := ironflow.Run(ctx, "validate", func() (any, error) { var data OrderData if err := ctx.Event.Data(&data); err != nil { return nil, err } return validateOrder(data) }) return result, err})from ironflow.worker import function
@function(id="process-order", recording_profile="steps", triggers=[{"event": "order.placed"}])async def process_order(ctx): validated = await ctx.step.run("validate", lambda: validate_order(ctx.event.data)) # Step and saga compensation events are recorded; run lifecycle events are not. return validatedRemoved function retention
Section titled “Removed function retention”The per-function recordingRetention field (recording_retention in Python
and the wire protocol) was removed. It never controlled deletion. Delete it from
function definitions. The server-wide pruner applies uniformly by age. Set
IRONFLOW_AUDIT_RETENTION_DAYS=0 to disable automatic audit pruning globally.
Global retention TTL
Section titled “Global retention TTL”The server runs a daily pruner that deletes rows older than IRONFLOW_AUDIT_RETENTION_DAYS from the audit_events table. This protects single-VPS deployments from unbounded table growth.
| Env | Default | Notes |
|---|---|---|
IRONFLOW_AUDIT_RETENTION_DAYS | 90 | 0 disables the pruner. Values between 1 and 6, above 36500, or that are not integers are rejected at boot (safety floor is 7). |
The pruner runs once per day at 03:00 UTC and logs rows_deleted and duration on every pass. Multi-node clusters fire at the same wall-clock instant regardless of host TZ.
Querying the Audit Trail
Section titled “Querying the Audit Trail”Dashboard
Section titled “Dashboard”Navigate to Audit Log in the left sidebar to browse audit events across all runs. You can filter by:
- Function — select a specific function from the dropdown
- Event type — filter by
step.completed,run.created, etc. - Run ID — search for events from a specific run
From any run detail page, click the Audit Log button to jump directly to that run’s audit events.
# View audit trail for a runironflow audit trail <run-id>
# Filter by event typeironflow audit trail <run-id> --type step.completed
# Time range filteringironflow audit trail <run-id> --from 2025-01-01T00:00:00Z --to 2025-01-02T00:00:00Z
# Limit resultsironflow audit trail <run-id> --limit 100
# JSON output (for piping to jq, etc.)ironflow audit trail <run-id> --jsonExample output:
TIMESTAMP EVENT TYPE STEP ID PAYLOAD10:23:01.123 run.created {"runId":"run-abc","functionId":"process-order"...}10:23:01.145 step.started step-1 {"stepId":"step-1","stepName":"validate","stepType":"invoke"}10:23:01.312 step.completed step-1 {"stepId":"step-1","output":{"valid":true},"durationMs":167}10:23:01.315 step.started step-2 {"stepId":"step-2","stepName":"charge","stepType":"invoke"}10:23:01.520 step.completed step-2 {"stepId":"step-2","output":{"charged":true},"durationMs":205}10:23:01.522 run.status_changed {"runId":"run-abc","oldStatus":"running","newStatus":"completed"}
Total: 6 eventsREST API
Section titled “REST API”# Get audit events for a specific runcurl -H "Content-Type: application/json" -d '{"runId":"<run-id>"}' \ http://localhost:9123/ironflow.v1.AuditService/GetAuditTrail
# List audit events across all runs (with optional filters)curl "http://localhost:9123/api/v1/audit?function_id=process-order&event_type=step.failed&limit=50"
# Paginate with cursorcurl "http://localhost:9123/api/v1/audit?cursor=<next_cursor>&limit=50"Query parameters:
| Parameter | Description |
|---|---|
function_id | Filter by function ID |
run_id | Filter by run ID |
event_type | Filter by event type |
from | Start timestamp (RFC3339) |
to | End timestamp (RFC3339) |
limit | Maximum events to return (default: 100) |
cursor | Pagination cursor from previous response |
Go SDK
Section titled “Go SDK”client := ironflow.NewClient(ironflow.ClientConfig{ ServerURL: "http://localhost:9123",})
result, err := client.GetAuditTrail(ctx, "run-abc123", ironflow.GetAuditTrailOpts{ EventType: "step.completed", Limit: 50,})if err != nil { log.Fatal(err)}
for _, event := range result.Events { fmt.Printf("%s %s %v\n", event.CreatedAt, event.EventType, event.Payload)}
// Paginateif result.NextCursor != "" { nextPage, _ := client.GetAuditTrail(ctx, "run-abc123", ironflow.GetAuditTrailOpts{ Cursor: result.NextCursor, }) // ...}Python SDK
Section titled “Python SDK”from ironflow import IronflowClient, IronflowRPCfrom ironflow.rpc.v1 import GetAuditTrailRequest
with IronflowRPC(server_url="http://localhost:9123") as rpc: result = rpc.audit.get_trail(GetAuditTrailRequest( run_id="run-abc123", event_type="step.completed", limit=50, )) for event in result.events: payload = event.payload_value.to_python() if event.payload_value is not None else event.payload.to_python() print(event.created_at, event.event_type, payload) if result.next_cursor: next_page = rpc.audit.get_trail(GetAuditTrailRequest( run_id="run-abc123", cursor=result.next_cursor, ))
# The global feed remains REST-backed.client = IronflowClient(server_url="http://localhost:9123")client.audit_list(function_id="process-order", event_type="step.failed", from_="2026-01-01T00:00:00Z", limit=50)Metadata
Section titled “Metadata”The metadata column contains recorder-owned context. When the global payload
policy removes fields, audit_redacted_fields contains their canonical public
names as a sorted, comma-separated string. It never contains the removed
values. There is no user-facing API for attaching custom metadata to an audit
event.
How It Works
Section titled “How It Works”The audit recorder is integrated directly into the Ironflow engine. When a function has recording: true, the engine writes an audit event to the database at each lifecycle point — run creation, status transitions, step starts, completions, failures, hot-patches, and saga compensations. These writes are best effort under load.
Event triggers run │ ▼┌─────────────────────────────────┐│ Engine executes function ││ ││ run.created ──► audit_events ││ step.started ──► audit_events ││ step.completed ──► audit_events││ run.status_changed ──► ... │└─────────────────────────────────┘ │ ▼ Query via API / CLI / DashboardKey characteristics:
- Append-only — events are never modified or deleted, except by the retention policy and by an operator redaction (
run.redacted,step.redacted), which replaces the payloads of the affected rows with a placeholder - Non-blocking — audit write failures are logged but never block workflow execution
- Opaque IDs — treat an audit event ID as a string, and use
created_atfor order. Most IDs are ULIDs, but arun.createdrow has an ID derived from its run - Run creation time — a
run.createdrow’screated_atis when the run was created, not when the row was written, so it reads as the run’s first event even if it lands late. A tool that polls withfromset to the last time it saw can miss such a row, so overlap each poll and drop duplicates by ID - Per-function caching — the engine caches each function’s recording status to avoid repeated database lookups. A change applies at once on the node that took it and within about 30 s on other nodes, with no restart
Auth Audit Trail
Section titled “Auth Audit Trail”In addition to workflow audit events, Ironflow can record an audit trail of authorization decisions and auth-related mutations (see the capture settings below). Use it to debug access issues and for security audits.
Auth Event Types
Section titled “Auth Event Types”| Event Type | When | Key Payload Fields |
|---|---|---|
authz.decision.allowed | Auth middleware grants access | api_key_id, action, resource, environment, method, path, decision, policy_id (conditional), impersonated_org_id (conditional) |
authz.decision.denied | Auth middleware denies access | api_key_id, action, resource, environment, method, path, reason, decision, policy_id (conditional), impersonated_org_id (conditional), webhook_source_id (conditional: the webhook source that was denied) |
authz.decision.would_deny | A policy set in monitor mode would have denied the check. The request was allowed. | api_key_id, action, resource, environment, method, path, reason, decision, policy_set_version, policy_set_mode |
authz.key.created | API key created | api_key_id, key_name, org_id, actor_id |
authz.key.revoked | API key deleted or rotated | api_key_id, key_name, org_id, actor_id, reason (conditional) |
authz.role.changed | Role created, updated, or deleted | role_id, role_name, org_id, change_type, actor_id |
authz.policy.changed | Policy set written or rolled back | policy_id, policy_name, org_id, change_type, actor_id, old_version, new_version, mode, old_mode (conditional), bypass, bypass_reason (conditional) |
Auth events use run_id="" and function_id="" since they are not workflow-scoped. The metadata JSON includes org_id and api_key_id (for decision events) for filtering. actor_id lives in the payload. It is the ID of the API key, tenant user or platform user who made the change. An ID that starts with puser_ means a platform operator changed this organization’s own key or role. A platform key’s create, delete, rotate, and role-replacement rows are platform-scoped, including during impersonation, so they are not in this feed.
New HTTP decision payloads use a bounded route label in path, such as
/api/v1/events/{id}. A refusal before routing uses the route the request was
aimed at. A request that names no route uses unmatched.
Registered catch-all routes retain their patterns, such as /api/v1/.
ConnectRPC HTTP decisions use the service mount, such as
/ironflow.v1.AgentToolsService/, so their path no longer identifies a method.
Internal checks keep their fixed procedure names, such as eventtrigger.Emit. Resource IRNs retain
target identifiers where supplied. Other audit fields can still contain
caller-selected text.
For example, a new denial for GET /api/v1/events/evt_example can contain:
{ "action": "events:subscribe", "resource": "irn:ironflow:org_default:proj_default_default:event:env_default:evt_example", "method": "GET", "path": "/api/v1/events/{id}", "decision": "denied"}Older rows can contain concrete normalized paths such as
/api/v1/events/evt_example. Existing payloads remain unchanged under normal
retention, so a query can return both forms. Bounding the audit path does not
change RBAC checks, resource IRNs, or the actual HTTP request view Rego evaluates.
Refused credentials
Section titled “Refused credentials”A request refused for its credential writes an authz.decision.denied row with action auth:authenticate and reason set to the failure reason of ironflow_auth_failures_total (expired_key, revoked_session, inactive_user, bad_login or env_forbidden; see Authentication failures).
- Only a principal the engine knows gets a row: an expired API key, a dashboard user who typed a wrong password at login or an incorrect current password during a password change, a dashboard or desktop session whose user or source API key was deleted (on a request that checks the session’s current principal: an authenticated API, ConnectRPC or WebSocket request, or the dashboard’s
GET /api/v1/auth/validatesession check), a session of a deactivated platform user, or a valid credential that asked for an organization or environment outside its scope. An unknown key, a forged token, an unknown login email or a platform session whose platform user was deleted writes no row, so guessing cannot fill the table. The counter and the rate-limitedWARNlog still record them. A dashboard page request checks only the session token’s signature and expiry, so a valid token of a deleted user is not refused there and leaves no row, count or log line; its next API call or session check is refused. - Each principal writes at most one row per minute on each node. The next row has
suppressed, the number of refusals since the previous row that got no row. That count is reported only if the principal is refused again within 10 minutes; after 10 minutes without a refusal the node forgets it. - The row is stored in the principal’s own organization and resolved environment, never in an organization or environment outside its scope that the request named. If the refused principal has no resolved environment, as at dashboard login, the row goes to its organization’s oldest environment (the first created;
env_defaultfor the default organization). An organization with no environment gets no row. A platform principal’s row goes toenv_default, like its other decision rows. - A failed platform login writes no row: it is counted and logged only.
Policy-set decisions
Section titled “Policy-set decisions”The Rego policy set of an organization adds fields to decision rows. See Authorization policy sets.
policy_set_versionandpolicy_set_modeappear on every decision row for which the set was consulted. They are absent when Layer 2 was not consulted, for example when RBAC denied the request.- For a deny,
reasonholds the reasons of the set, sorted and joined with;. The text is cut at 1 KiB with a…(+N more)suffix. An evaluation failure readspolicy evaluation error (<class>). - Denied rows are always recorded. The capture settings for allowed decisions do not apply to
authz.decision.would_denyrows. Ironflow samples them, as the next bullet says. - Decision rows are stored in the environment of the request. A row for a request in
env_proddoes not appear in a view ofenv_default. - Sampling. A broad monitor-mode rule could write one row for every request. Ironflow writes at most one
would_denyrow for each combination of organization, action and first reason, every 10 seconds on each node. The counterironflow_policy_would_deny_totalcounts every would-deny decision. Checks that a handler makes by itself, without an HTTP request, writewould_denyrows too, with the same sampling. - A reason that includes request data, such as a user ID or a resource ID, is different for each request. Sampling treats each text as a new combination, so it cannot limit the rows. Use a fixed reason text in broad rules.
- A write to the policy set is an
authz.policy.changedrow. A write that used the self-lockout bypass hasbypass: trueand abypass_reason. The platform break-glassironflow policy force-monitoralso setsbypass: trueand records its reason. When a write changes the mode, for example fromenforcetomonitor, the row also hasold_modewith the previous mode. A change to the platform organization’s set is recorded in the platform scope, so read it via/api/v1/platform/audit. Aforce-monitoron a tenant set also writes a platform-scope row that names the operator.
To list would-deny rows, filter by the event type:
curl -s -H "Authorization: Bearer $IRONFLOW_API_KEY" \ "http://localhost:9123/api/v1/audit?event_type=authz.decision.would_deny"Querying Auth Audit Trail
Section titled “Querying Auth Audit Trail”# View auth audit trail for an organizationironflow audit auth-trail --org <org_id>
# Filter by API keyironflow audit auth-trail --org <org_id> --key <api_key_id>
# Filter by actionironflow audit auth-trail --org <org_id> --action "functions:invoke"
# Time rangeironflow audit auth-trail --org <org_id> --from 2026-01-01T00:00:00Z --to 2026-01-02T00:00:00Z
# Limit results and JSON outputironflow audit auth-trail --org <org_id> --limit 50 --jsonExample output:
TIMESTAMP EVENT TYPE DECISION PAYLOAD10:23:01.000 authz.decision.allowed allowed {"api_key_id":"key-1","action":"functions:invoke"...}10:23:02.000 authz.decision.denied denied {"api_key_id":"key-2","action":"admin:manage","reason":"..."}10:24:00.000 authz.role.changed {"role_id":"role-1","change_type":"created"...}
Total: 3 eventsConnectRPC API
Section titled “ConnectRPC API”The GetAuthAuditTrail RPC is available on the AuditService:
# Using buf curl (ConnectRPC)buf curl --data '{"org_id": "org-123", "limit": 50}' \ http://localhost:9123/ironflow.v1.AuditService/GetAuthAuditTrailRequest fields:
| Field | Description |
|---|---|
org_id | Required. Organization ID to query |
api_key_id | Filter by API key |
action | Filter by action (e.g., functions:invoke) |
from_timestamp | Start timestamp (RFC3339) |
to_timestamp | End timestamp (RFC3339) |
limit | Maximum events to return (default: 100) |
cursor | Pagination cursor from previous response |
Configuration
Section titled “Configuration”Auth audit logging writes captured decisions synchronously to the audit_events table. Write failures are logged but do not fail the request.
By default, all decisions are captured. Operators can suppress successful reads or specific allowed actions through the authorization capture policy. Denied decisions remain captured.
What’s Next?
Section titled “What’s Next?”- Time-Travel Debugging — audit events power time-travel debugging, letting you inspect run state at any historical point in time
- Workflows — learn about functions, steps, and execution modes
- Sagas — compensation patterns that generate
saga.compensation.*audit events - Debugging — use the TUI debugger alongside the audit trail
Investigation filters and shared links
Section titled “Investigation filters and shared links”The tenant Audit Log supports inclusive From time and To time bounds,
entered in UTC, an exact Authorization action such as functions:list, and
an exact API key ID. Enter the key identifier, never its secret. These filters
combine with function, run, and event types and apply to every page. Tenant
queries always exclude platform records.
Filters are stored in the URL. Reloading, sharing the URL, or using browser back/forward restores the selected query and display filters. Clear filters shows the complete feed, including successful checks. Display categories and successful-check hiding still apply only to loaded pages.
GET /api/v1/audit accepts from, to, action, and api_key_id alongside
function_id, run_id, event_type, limit, and cursor. Time bounds accept
RFC3339 timestamps or YYYY-MM-DD. A date alone means midnight UTC, including
the to bound; use a timestamp to include the rest of that day. Invalid bounds
return HTTP 400. Pagination preserves the filtered total count.
The response includes retention_days, the effective policy loaded at server
startup. The dashboard shows it with the results. Zero means global pruning is
disabled; a positive value makes older records eligible for the daily prune.
This policy applies to all audit records of every function.
Authorization capture policy
Section titled “Authorization capture policy”Operators can configure capture through process environment variables. These settings apply across the server’s environments, with both YAML and flag-based startup, and require restarting every server process after a change.
| Variable | Default | Behavior |
|---|---|---|
IRONFLOW_AUDIT_AUTHZ_CAPTURE_SUCCESSFUL_READS | true | Set to false to omit allowed decisions for actions ending in :read or :list. |
IRONFLOW_AUDIT_AUTHZ_EXCLUDE_ACTIONS | empty | Comma-separated exact action names whose allowed decisions are omitted. Whitespace is trimmed; wildcards are not expanded. |
For example, start with successful-read capture disabled and an explicit action exclusion:
IRONFLOW_AUDIT_AUTHZ_CAPTURE_SUCCESSFUL_READS=false IRONFLOW_AUDIT_AUTHZ_EXCLUDE_ACTIONS='functions:list,runs:read' ironflow serveDenied decisions are always recorded, including for actions covered by either suppression setting. Key lifecycle, resource changes, and workflow recording are unaffected. Invalid boolean values fail startup. The defaults preserve previous capture volume; the dashboard’s Hide successful authorization checks control only changes display and never removes stored records.
Export the filtered Audit Log
Section titled “Export the filtered Audit Log”Choose Export JSONL in Audit Log to download every matching page, including pages you have not loaded. The export preserves the selected environment, function, run, event types, inclusive time bounds, authorization action, API key ID, category, and Hide successful authorization checks setting. Changing filters while an export runs affects the page and future exports. It does not change the export in progress. Cancel export stops the download.
The server fixes the upper time bound at export start, or at your selected To time if earlier. Records timestamped after that bound are excluded. This is a timestamp-bounded scan, not a database snapshot: an earlier-timestamped record committed during export may be included if visible before its page is read. Retention pruning may remove records before the export reads them. The file’s manifest records the effective filters, environment, and cutoff.
The .jsonl file contains a manifest line, one record envelope per audit event
with the complete stored event under data, optional heartbeat lines, and a
completion footer. Payloads and metadata retain their stored structure and
redaction. The footer includes counts.audit_events and body_sha256, a SHA-256
of all bytes before the footer. The dashboard checks the footer and record count
before saving; it does not independently verify the checksum. An interrupted or
failed request shows an error and offers no partial file. Retry starts a new
export with the current filters and a new cutoff.
The dashboard holds the download in browser memory until it completes. Use a narrower time range for large exports, or consume the HTTP stream directly:
curl --fail-with-body \ -H "Authorization: Bearer $IRONFLOW_API_KEY" \ -H 'X-Ironflow-Environment-ID: env_default' \ 'http://localhost:9123/api/v1/audit/export?category=workflow&hide_successful=true&from=2026-09-01T00:00:00Z&to=2026-09-10T23:59:59Z' \ -o audit-export.jsonlGET /api/v1/audit/export requires the same runs:read permission as Audit Log.
It exports tenant records from the request environment; query parameters cannot
select another environment or platform scope. It accepts the list endpoint’s
function_id, run_id, comma-separated event_type, from, to, action, and
api_key_id filters, plus category=all|workflow|security|administration and
hide_successful=true|false. Direct API requests default to all categories with
successful checks included. from and to accept RFC3339 timestamps or
YYYY-MM-DD for midnight UTC. cursor and limit are ignored so every matching
page is exported.
HTTP errors before streaming use an error status. A failure after streaming
starts uses a JSONL error envelope instead of the completion footer, so HTTP
200 alone does not prove completion. Direct API consumers must require a footer
whose count matches the event records; verify its checksum when checking file
integrity. See Exporting data for the shared
JSONL envelope format.
Temporary log-level requests
Section titled “Temporary log-level requests”cluster.log_level.requested is a platform-scope event for POST /api/v1/cluster/log-level. Its payload records the command ID, requested level, issue and expiry times, actor ID, organization, and outcome: "authorized_attempt". The store must accept this row before the node changes its threshold or broadcasts the command. It does not assert cluster-wide success. The HTTP response separately lists authenticated applied node IDs.