Skip to content

Audit Logs

Audit logs track all configuration changes made to the system. They provide a tamper-evident record of who changed what and when, supporting compliance and troubleshooting.

What is logged

Audit logs capture configuration changes only. Workflow runtime events (runs, steps, escalations) are not included — those are visible in the Workflow Runs screen.

Entity Actions tracked
Workflow created, updated, published, deprecated, deleted, restored, permanently deleted, moved, reverted to draft, debug toggled, version created
Project created, updated, deleted, restored, permanently deleted
Workflow Set created, updated, deleted, restored, permanently deleted
Connector registered, updated, deleted, restored, permanently deleted
Schedule created, updated, deleted
User login, logout, role changed, created, soft-deleted, restored, permanently deleted, permission override set, permission override cleared
Group created, updated, deleted, members added, member removed
Organization created, updated, soft-deleted, restored, permanently deleted, member added, member removed
Role Definition created, updated, soft-deleted, restored
Entitlement Definition created, updated, deleted
Role Assignment granted, revoked, expired
Entitlement Instance provisioned, deprovisioned, failed, orphaned, reprovisioned
Reconciliation completed
Configuration Transfer exported, imported
Email Template created, updated, soft-deleted, restored, permanently deleted
Invitation accepted, rejected
Approval approved, rejected
Document uploaded, status changed

Anatomy of an audit entry

Each entry stores:

  • entity / entity_id — the type and ID of the affected resource
  • action — what happened (e.g. workflow.updated)
  • actor / actor_id — who made the change
  • timestamp — when it occurred
  • previous_state — snapshot of the resource before the change (nullable)
  • new_state — snapshot after the change (nullable)
  • ip_address — client IP address (when available)
  • metadata — additional context (nullable)
  • hash — SHA-256 hash of this entry (includes all fields above plus the previous entry's hash)
  • previous_hash — the hash of the preceding audit log entry, forming a cryptographic chain

Tamper hardening

The audit log uses a three-layer defense against tampering:

Layer 1: Cryptographic hash chain

Every entry stores a SHA-256 hash computed over its data fields and the previous entry's hash. This creates a chain: modifying or deleting any row breaks the chain from that point forward.

The hash is computed as:

SHA-256(entity | entityId | action | actor | actorId | timestamp | previousState | newState | ipAddress | previousHash)

The first entry uses a well-known genesis value (64 zeros). Hash chain writes are serialized using a PostgreSQL advisory lock to prevent race conditions across concurrent requests.

Layer 2: Database-level immutability

A PostgreSQL trigger (trg_audit_immutable) prevents UPDATE and DELETE operations on the audit_log table at the database layer, regardless of application behavior or direct database access.

Layer 3: Periodic signed checkpoints

A scheduled job (default: every 6 hours) creates signed checkpoints that anchor the chain state externally:

  1. The job reads the latest audit entry's hash and ID, plus the total entry count.
  2. It HMAC-SHA-256 signs this payload with a dedicated checkpoint key (AUDIT_CHECKPOINT_KEY).
  3. The checkpoint is stored in the audit_checkpoint database table and exported to a configured external store (default: signed JSON files on disk).

If an attacker were to disable the immutability trigger and rewrite the hash chain, the external checkpoint signatures would no longer match — making the tampering detectable.

Checkpoint store adapters

The checkpoint system uses a CheckpointStore interface with pluggable backends:

Store Status Description
SignedFileStore Default Writes checkpoint JSON files to disk (./audit-checkpoints/)
S3CheckpointStore Stub Placeholder for S3 with Object Lock / WORM policy
SiemCheckpointStore Stub Placeholder for Splunk / Datadog / ELK export

To implement a custom store, create a class that implements the CheckpointStore interface from packages/server/src/modules/audit/checkpoint-store.ts.

Checkpoint key management

The checkpoint signing key is separate from all other application keys.

Environment variable Purpose
AUDIT_CHECKPOINT_KEY 64-character hex string for HMAC-SHA-256 signing
AUDIT_CHECKPOINT_KEY_PREVIOUS Previous key for verifying historical checkpoints after rotation
AUDIT_CHECKPOINT_SCHEDULE Cron expression for checkpoint frequency (default: 0 */6 * * * = every 6 hours)
AUDIT_CHECKPOINT_STORE Store type: file, s3, or siem (default: file)
AUDIT_CHECKPOINT_PATH File store directory (default: ./audit-checkpoints)

Key rotation procedure:

  1. Copy the current AUDIT_CHECKPOINT_KEY value to AUDIT_CHECKPOINT_KEY_PREVIOUS.
  2. Generate a new 64-character hex key and set it as AUDIT_CHECKPOINT_KEY.
  3. Restart the application (rolling restart in K8s).
  4. Old checkpoints remain verifiable because the verification logic accepts both the current and previous keys.
  5. Each checkpoint records a key_id (SHA-256 of the signing key) so you can identify which key signed each checkpoint.

K8s deployment: The key is loaded from an environment variable, which can be backed by a Kubernetes Secret. All pods in the cluster read the same value. No cross-pod coordination is needed — rotation is simply a Secret update followed by a rolling restart.

If no key is set, a dev key (all zeros) is used with a startup warning. This is acceptable for development but must not be used in production.

Integrity verification

API endpoint

GET /api/audit-logs/verify-integrity

Requires audit:read permission. Returns:

{
  "chain": {
    "valid": true,
    "checkedCount": 12345,
    "firstBrokenId": null,
    "firstBrokenAt": null
  },
  "checkpoints": {
    "total": 48,
    "verified": 48,
    "failed": 0,
    "lastCheckpointAt": "2025-06-15T12:00:00.000Z"
  }
}

The endpoint streams through all audit log entries in chronological order, recomputing each hash and verifying chain linkage. It also verifies all checkpoint signatures against the current and previous signing keys.

API

List audit logs

GET /api/audit-logs

Query parameters: entity, entityId, action, actor, actorId, from, to, page, pageSize, sortBy, sortOrder.

Requires the audit:read permission.

Get a single entry

GET /api/audit-logs/:id

Returns full details including previous/new state, hash, and previous_hash. Requires audit:read.

Verify integrity

GET /api/audit-logs/verify-integrity

Verifies hash chain integrity and checkpoint signatures. Requires audit:read.

UI

The Audit Log screen (sidebar > Audit Log) shows a paginated, sortable, filterable table of configuration changes. Each row includes a View details button that opens a dialog with:

  • Entity metadata (type, ID, actor, timestamp, IP)
  • A field-by-field diff for updates showing which fields were added, modified, or removed
  • Raw previous/new state for creates and deletes
  • Any additional metadata

System-initiated events

Some events are triggered by the system rather than a user action:

  • Role expiry — when a role assignment expires, it is logged with actor: system / actorId: system
  • Reconciliation — orphaned entitlements and reconciliation completion are logged with the system actor
  • Entitlement provisioning/deprovisioning — connector-driven changes are logged with the system actor

Adding audit logging to new features

Use AuditService.log() in your route handler whenever a configuration change occurs:

import { AuditService } from '../audit/service.js';

const audit = new AuditService(app.db);

await audit.log({
  entity: 'my_entity',
  entityId: id,
  action: 'my_entity.updated',
  actor: request.user!.displayName,
  actorId: request.user!.id,
  previousState: existing,
  newState: updated,
  ipAddress: request.ip,
});

For services without request context (background jobs, cron tasks), use the system actor:

import { SYSTEM_ACTOR } from '@floh/shared';

await audit.log({
  entity: 'my_entity',
  entityId: id,
  action: 'my_entity.updated',
  ...SYSTEM_ACTOR,
  newState: result,
});

Add the new action to the AuditAction type in packages/shared/src/audit.types.ts and add a human-readable label in the ACTION_LABELS map in packages/web/src/app/features/reports/audit-log.component.ts.

Database schema

audit_log table

Column Type Description
id varchar(36), PK UUID
entity varchar(100) Entity type
entity_id varchar(36) Entity UUID
action varchar(100) Action identifier
actor varchar(255) Human-readable actor name
actor_id varchar(36) Actor UUID (or system)
timestamp timestamp When the event occurred
previous_state text (JSON) Serialized previous state
new_state text (JSON) Serialized new state
ip_address varchar(45) Client IP
metadata text (JSON) Additional context
hash varchar(64) SHA-256 hash of this entry
previous_hash varchar(64) Hash of the preceding entry

The table is protected by the trg_audit_immutable trigger which prevents UPDATE and DELETE operations.

audit_checkpoint table

Column Type Description
id varchar(36), PK UUID
sequence_number serial, unique Auto-incrementing sequence
latest_audit_id varchar(36) ID of the latest audit entry at checkpoint time
latest_hash varchar(64) Hash of the latest audit entry
entry_count integer Total number of audit entries
signature text HMAC-SHA-256 signature
key_id varchar(64) SHA-256 of the signing key used
exported boolean Whether the checkpoint was written to the external store
created_at timestamp When the checkpoint was created