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 and security-relevant lifecycle events. 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, withdrawn, status changed, downloaded
SCIM User created, linked to existing user, updated
SCIM Group created, updated, member added, members replaced

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.

Layer 4: RFC 3161 Trusted Timestamps

Optional integration with an external Time Stamp Authority (TSA) provides independent, cryptographically signed proof of when events occurred — not reliant on the application server's own clock.

When configured, the system obtains RFC 3161 timestamp tokens at two points:

  • Document uploads — the file's SHA-256 content hash is timestamped by the TSA. The signed token is stored alongside the document record.
  • Audit checkpoints — each checkpoint's latest audit hash is timestamped, anchoring the entire audit chain to an externally attested time at regular intervals.

The TSA signs the content hash with its UTC timestamp, producing a token whose structure and hash imprint can be verified. Full trust-chain signature validation requires configuring the TSA's CA certificate (TSA_CERT_PATH) and is planned as a follow-up.

Environment variable Purpose
TSA_URL RFC 3161 TSA endpoint URL (e.g., https://freetsa.org/tsr)
TSA_TIMEOUT_MS TSA request timeout in milliseconds (default: 10000)
TSA_REQUIRED If true, document uploads fail when the TSA is unreachable (default: false)

When TSA_REQUIRED=true in production, TSA_URL must be set — startup fails otherwise.

Free public TSA services include FreeTSA (https://freetsa.org/tsr), DigiCert (http://timestamp.digicert.com), Sectigo (http://timestamp.sectigo.com), and GlobalSign (http://timestamp.globalsign.com/tsa/r6advanced1).

Document evidentiary integrity

Every uploaded document receives a SHA-256 content hash computed during the upload stream. This hash is:

  • Stored on the document row (content_hash column)
  • Included in the document.uploaded audit entry (sealed in the tamper-evident hash chain)
  • Optionally timestamped by an RFC 3161 TSA for independent time attestation

All document lifecycle events are recorded in the audit chain:

Event Audit action Trigger
File uploaded document.uploaded POST /api/documents/upload
File withdrawn document.withdrawn POST /api/documents/:id/withdraw
Approval/rejection document.status_changed Approval decision finalization
Document expired document.status_changed Document expiry reconciliation job
Document purged document.status_changed Cancelled document retention cleanup
File downloaded document.downloaded GET /api/documents/:id/download

Document integrity verification

GET /api/documents/:id/verify-integrity

Requires authentication (uploader or admin). Re-hashes the stored file from disk and compares against the recorded content hash. If a TSA timestamp token is present, its structure and hash match are verified. Note: tokenParseable indicates the token is well-formed RFC 3161, not that the TSA's cryptographic signature has been verified against a trusted certificate chain. Returns:

{
  "documentId": "...",
  "contentHash": {
    "stored": "a1b2c3...",
    "computed": "a1b2c3...",
    "match": true
  },
  "tsaTimestamp": {
    "present": true,
    "timestamp": "2026-03-21T12:00:00.000Z",
    "tsaName": "FreeTSA",
    "tokenParseable": true,
    "hashMatch": true
  },
  "uploadedBy": "user-uuid",
  "uploadedAt": "2026-03-21T12:00:00.000Z"
}

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
request_id varchar(64) Originating Fastify request id (nullable; auto-populated from the active request context for HTTP-driven mutations so the audit row can be joined to its system_log access entry on request_id)

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