Audit Logs
DriftWise logs security-relevant mutations to an audit trail. Every action that creates, deletes, or modifies credentials, memberships, or configuration is recorded with the actor, org, and timestamp.
The log is tamper-evident: each row references the SHA-256 of its predecessor inside a per-org hash chain, and the database row-level-security policy forbids UPDATE or DELETE from the application role. Owners, admins, and their API keys can independently verify the chain on demand — see Verify your chain below.
Viewing audit logs
Platform admins can query the cross-tenant log. The endpoint sits behind the RequirePlatformAdmin middleware, which only accepts an OIDC JWT — API keys return 403:
curl "https://app.driftwise.ai/api/v2/admin/audit-log?limit=50&offset=0" \
-H "Authorization: Bearer $OIDC_TOKEN"
[
{
"id": "entry-uuid",
"actor_email": "[email protected]",
"org_id": "org-uuid",
"action": "api_key.created",
"detail": { "key_id": "uuid", "name": "CI Key" },
"created_at": "2026-04-10T15:30:00Z"
}
]
Pagination: limit (default 50, max 200), offset.
Tamper-evident chain
Every audit row carries three extra columns beyond its payload:
seq— a per-chain monotonically increasing integer starting at 1.prev_hash— the SHA-256 of the immediately preceding row'shash(NULL on the genesis row of each chain).hash— a SHA-256 of a canonicalized serialization of all the row's fields plusprev_hash.
Rows with org_id IS NOT NULL share one chain per org. Platform-wide rows (domain.added, user.deleted, org.created) share a separate chain where org_id IS NULL. A unique index on (org_id, seq) enforces no gaps or duplicates within a chain.
The canonical form is version-tagged (v1\n…) so future changes to the hashing rules can be rolled out without invalidating existing chains — rows written under v1 stay verifiable under the original rule forever.
What the chain proves:
- A row's contents have not been mutated since it was written (the stored
hashwould no longer recompute). - No row has been retroactively inserted between two existing rows (the
seq/prev_hashlinkage would break). - No row has been deleted from the middle of the chain (the same linkage break).
What the chain does not prove:
- That every write succeeded. Audit writes are best-effort (fire-and-forget with a 10-second timeout) — a DB outage during a mutation can leave the primary operation committed but no audit row written. The chain stays internally consistent; it just has no entry for that event. Failed writes surface as
slog.Errorevents with the messageaudit log write failed(andAUDIT GAP: detail not marshalablefor the marshal-failure variant) — wire log-based alerting on those messages to detect drops. - That the clock on the application server is correct. The
created_atcolumn comes from Go'stime.Now().UTC()at write time; if the host's clock drifts, so does the stamp.
Verify your chain
GET /api/v2/orgs/:id/audit-log/verify walks the chain, recomputes every hash, and reports ok or broken.
Authorization:
- OIDC callers must have
owneroradminrole on the org. - API keys are accepted regardless of the issuing user's current role — keys are scoped at creation time, so a CI integrity check keeps working even if the user who minted the key is later demoted. This is a deliberate trade-off in favour of automation; rotate keys when team composition changes if your threat model demands tighter coupling.
# Rate-limited to 10 per hour per org.
curl "https://app.driftwise.ai/api/v2/orgs/$ORG_ID/audit-log/verify" \
-H "X-API-Key: $API_KEY"
Anchor against tail truncation. Pass ?expected_min_seq=N to assert the chain head is at least seq N. The hash chain alone catches in-place mutation and middle-of-chain deletion, but a privileged delete of the last few rows leaves the survivors internally consistent — they verify clean. The watermark closes that gap: keep the last observed head_seq in your auditor's external system, and pass it back on every check. A regression returns 409 with reason="head_seq … below expected anchor … — chain may have been truncated".
curl "https://app.driftwise.ai/api/v2/orgs/$ORG_ID/audit-log/verify?expected_min_seq=1243" \
-H "X-API-Key: $API_KEY"
Intact chain:
{
"status": "ok",
"checked": 1243,
"head_seq": 1243,
"head_hash": "7b1a1a7a0000000000000000000000009c2b2b8b0000000000000000000000ff"
}
Broken chain (HTTP 409 Conflict):
{
"status": "broken",
"checked": 917,
"head_seq": 918,
"first_broken_seq": 918,
"reason": "stored hash does not match recomputation"
}
Investigate immediately. A broken chain means either:
- Someone (or some process) with DB superuser credentials mutated a row directly. The application role cannot do this — the
admin_audit_log_no_updateand_no_deleteRLS policies are RESTRICTIVE and evaluate to false for thedriftwise_approle. - A storage-layer bug corrupted the
hashorprev_hashcolumns. Unlikely but worth ruling out via a filesystem / backup cross-check.
Platform admins can verify the cross-tenant platform chain (org_id IS NULL) with:
curl "https://app.driftwise.ai/api/v2/admin/audit-log/verify" \
-H "Authorization: Bearer $OIDC_TOKEN"
Not rate-limited — platform chain verification is a low-volume incident-response path.
Sharing a chain with an auditor
For a SOC 2 or similar audit walkthrough, hand your auditor:
- A
GET /audit-log/verifyresponse captured at the start of the review window (establishes the chain's state at that moment). - The latest
GET /audit-log/verifyresponse (proves no retroactive tampering since). - Any specific rows they want to review, exported via
GET /api/v2/admin/audit-log?...(platform admin) or the Compliance Pack bundle when it ships.
A matching status=ok across both verify snapshots demonstrates that every event between those two heads is consistent with the cryptographic chain.
Logged actions
Platform-wide events (org_id IS NULL)
| Action | Trigger |
|---|---|
domain.added | Email domain added to allowlist |
domain.removed | Email domain removed from allowlist |
user.deleted | User soft-deleted by a platform admin |
Org lifecycle
| Action | Trigger |
|---|---|
org.created | Organization created (written with the new org's id) |
API keys
| Action | Trigger |
|---|---|
api_key.created | API key generated |
api_key.revoked | API key revoked (OIDC owner/admin only — API keys cannot revoke keys) |
Memberships
| Action | Trigger |
|---|---|
membership.created | User added to org |
membership.removed | User removed from org |
membership.role_changed | Role updated on an existing membership |
Identity / SSO / SCIM
| Action | Trigger |
|---|---|
sso.updated | SAML identity-provider configuration changed |
scim.user.provisioned | SCIM user created via IdP → Casdoor → DriftWise |
scim.user.updated | SCIM user attributes updated |
scim.user.deprovisioned | SCIM user deprovisioned |
scim.group.provisioned | SCIM group created |
scim.group.updated | SCIM group membership or attributes updated |
scim.group.deprovisioned | SCIM group deleted |
SCIM events are delivered by the Casdoor webhook subscription at POST /webhooks/casdoor. Events outside the SCIM request path (UI-initiated user edits, self-signup) are filtered out so only true SCIM lifecycle activity surfaces in the audit trail.
Cloud accounts and policy
| Action | Trigger |
|---|---|
cloud_account.created | Cloud account added |
policy.updated | Risk-policy rules changed |
Scans and schedules
| Action | Trigger |
|---|---|
scan.bulk_created | Bulk scan initiated |
schedule.created | Scheduled scan created |
schedule.updated | Scheduled scan updated |
schedule.deleted | Scheduled scan deleted |
schedule.run_triggered | Scheduled scan triggered manually |
LLM configuration (BYOK)
| Action | Trigger |
|---|---|
llm_config.created | Persisted BYOK credential added |
llm_config.updated | Persisted BYOK credential rotated or replaced |
llm_config.deleted | Persisted BYOK credential hard-deleted |
Slack
| Action | Trigger |
|---|---|
slack.installed | Slack integration installed |
slack.uninstalled | Slack integration removed |
Billing
Billing events are system-attributed — they originate from Stripe webhooks, so the actor_email field is empty.
| Action | Trigger |
|---|---|
billing.subscription_created | Checkout completed → org upgraded |
billing.plan_changed | Subscription plan changed (upgrade, downgrade) |
billing.subscription_cancelled | Subscription cancelled → org downgraded to free |
billing.payment_failed | Subscription invoice payment failed |
Audit entry fields
| Field | Description |
|---|---|
id | Unique entry ID |
actor_email | Email of the user who performed the action. Empty for system events |
org_id | Organization scope. Null for platform-wide events |
action | Dotted scope.verb string (e.g., api_key.created) |
detail | JSON with action-specific metadata |
created_at | ISO 8601 timestamp (UTC, written from the Go server clock) |
seq | Per-chain monotonically increasing integer |
prev_hash | 32-byte SHA-256 of the previous row's hash, or NULL on the genesis row |
hash | 32-byte SHA-256 of the canonical payload for this row |
Detail fields by action
| Action | Detail fields |
|---|---|
domain.added / domain.removed | {domain} |
user.deleted | {user_id, email} |
org.created | {slug} |
api_key.created | {key_id, name, key_prefix, scopes} |
api_key.revoked | {key_id, key_prefix} — never the raw key or its hash |
membership.created | {user_id, role} |
membership.removed | {membership_id, user_id} |
membership.role_changed | {membership_id, user_id, old_role, new_role} |
sso.updated | {idp_entity_id, provider_type} |
scim.user.* | {casdoor_sub, casdoor_org, casdoor_username, email, request_uri} |
scim.group.* | {casdoor_sub, casdoor_org, casdoor_username, request_uri} |
cloud_account.created | {account_id, provider, ...} |
policy.updated | {rule_count} |
schedule.* | {schedule_id} (created also includes name) |
scan.bulk_created | {count, scan_ids} |
llm_config.* | {provider} (never key material) |
slack.installed / slack.uninstalled | {team_id, team_name} |
billing.* | {plan, ...Stripe-event-specific fields} |
Audit details never contain secrets (raw keys, credentials, tokens, passwords). Only IDs, names, prefixes, and enums are logged. The api_key.created and api_key.revoked details include key_prefix (the human-readable dw2_xxxxxxxx portion) for correlation but never the raw key value.
Design principles
- Best-effort writes — audit logging never blocks or rolls back the primary operation. If the log write fails, it's surfaced via structured logging but the action still succeeds.
- Nil-safe — calling the audit logger on a nil instance is a no-op (safe for tests and bootstrap).
- Placement — audit entries are written after the primary transaction commits. The write runs asynchronously (fire-and-forget goroutine with a 10-second timeout) and may land after the HTTP response has been sent. In crash scenarios between the primary commit and the audit write, the entry can be lost — the chain stays internally consistent, it just has no row for that event. Failed writes surface as
slog.Errorevents with the messageaudit log write failed; wire log-based alerting on that message to detect drops. - System events — when there's no human actor (background jobs, Stripe webhooks, Casdoor webhooks),
actor_emailis empty. - Per-org serialization — writers to the same chain serialize via
pg_advisory_xact_lockso concurrent mutations can't produce seq collisions. Writers to different chains never contend. - Version-tagged canonicalization — the hash input starts with
v1\nso the serialization rules can evolve without invalidating existing chains.
See also
- Compliance Pack — the downloadable audit-evidence bundle. Every pack ships with a
chain-attestation.jsonderived from the verify endpoint above, so auditors can independently confirm the bundle's view of the chain matches the live endpoint.