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:
- The job reads the latest audit entry's hash and ID, plus the total entry count.
- It HMAC-SHA-256 signs this payload with a dedicated checkpoint key (
AUDIT_CHECKPOINT_KEY). - The checkpoint is stored in the
audit_checkpointdatabase 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:
- Copy the current
AUDIT_CHECKPOINT_KEYvalue toAUDIT_CHECKPOINT_KEY_PREVIOUS. - Generate a new 64-character hex key and set it as
AUDIT_CHECKPOINT_KEY. - Restart the application (rolling restart in K8s).
- Old checkpoints remain verifiable because the verification logic accepts both the current and previous keys.
- 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¶
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¶
Query parameters: entity, entityId, action, actor, actorId, from, to, page, pageSize, sortBy, sortOrder.
Requires the audit:read permission.
Get a single entry¶
Returns full details including previous/new state, hash, and previous_hash. Requires audit:read.
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 |