Skip to main content

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's hash (NULL on the genesis row of each chain).
  • hash — a SHA-256 of a canonicalized serialization of all the row's fields plus prev_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 hash would no longer recompute).
  • No row has been retroactively inserted between two existing rows (the seq / prev_hash linkage 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.Error events with the message audit log write failed (and AUDIT GAP: detail not marshalable for 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_at column comes from Go's time.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 owner or admin role 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_update and _no_delete RLS policies are RESTRICTIVE and evaluate to false for the driftwise_app role.
  • A storage-layer bug corrupted the hash or prev_hash columns. 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:

  1. A GET /audit-log/verify response captured at the start of the review window (establishes the chain's state at that moment).
  2. The latest GET /audit-log/verify response (proves no retroactive tampering since).
  3. 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)

ActionTrigger
domain.addedEmail domain added to allowlist
domain.removedEmail domain removed from allowlist
user.deletedUser soft-deleted by a platform admin

Org lifecycle

ActionTrigger
org.createdOrganization created (written with the new org's id)

API keys

ActionTrigger
api_key.createdAPI key generated
api_key.revokedAPI key revoked (OIDC owner/admin only — API keys cannot revoke keys)

Memberships

ActionTrigger
membership.createdUser added to org
membership.removedUser removed from org
membership.role_changedRole updated on an existing membership

Identity / SSO / SCIM

ActionTrigger
sso.updatedSAML identity-provider configuration changed
scim.user.provisionedSCIM user created via IdP → Casdoor → DriftWise
scim.user.updatedSCIM user attributes updated
scim.user.deprovisionedSCIM user deprovisioned
scim.group.provisionedSCIM group created
scim.group.updatedSCIM group membership or attributes updated
scim.group.deprovisionedSCIM 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

ActionTrigger
cloud_account.createdCloud account added
policy.updatedRisk-policy rules changed

Scans and schedules

ActionTrigger
scan.bulk_createdBulk scan initiated
schedule.createdScheduled scan created
schedule.updatedScheduled scan updated
schedule.deletedScheduled scan deleted
schedule.run_triggeredScheduled scan triggered manually

LLM configuration (BYOK)

ActionTrigger
llm_config.createdPersisted BYOK credential added
llm_config.updatedPersisted BYOK credential rotated or replaced
llm_config.deletedPersisted BYOK credential hard-deleted

Slack

ActionTrigger
slack.installedSlack integration installed
slack.uninstalledSlack integration removed

Billing

Billing events are system-attributed — they originate from Stripe webhooks, so the actor_email field is empty.

ActionTrigger
billing.subscription_createdCheckout completed → org upgraded
billing.plan_changedSubscription plan changed (upgrade, downgrade)
billing.subscription_cancelledSubscription cancelled → org downgraded to free
billing.payment_failedSubscription invoice payment failed

Audit entry fields

FieldDescription
idUnique entry ID
actor_emailEmail of the user who performed the action. Empty for system events
org_idOrganization scope. Null for platform-wide events
actionDotted scope.verb string (e.g., api_key.created)
detailJSON with action-specific metadata
created_atISO 8601 timestamp (UTC, written from the Go server clock)
seqPer-chain monotonically increasing integer
prev_hash32-byte SHA-256 of the previous row's hash, or NULL on the genesis row
hash32-byte SHA-256 of the canonical payload for this row

Detail fields by action

ActionDetail 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}
info

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.Error events with the message audit 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_email is empty.
  • Per-org serialization — writers to the same chain serialize via pg_advisory_xact_lock so concurrent mutations can't produce seq collisions. Writers to different chains never contend.
  • Version-tagged canonicalization — the hash input starts with v1\n so 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.json derived from the verify endpoint above, so auditors can independently confirm the bundle's view of the chain matches the live endpoint.