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:
- 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.
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
documentrow (content_hashcolumn) - Included in the
document.uploadedaudit 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¶
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¶
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 |
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 |