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. For endpoint shapes, see the audit tag of the API reference.
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 via GET /api/v2/admin/audit-log. The endpoint sits behind the
RequirePlatformAdmin middleware, which only accepts an OIDC JWT —
API keys return 403. Pagination: limit (default 50, max 200),
offset.
There is no per-org listing endpoint today — org-scoped events
surface either through the cross-tenant admin listing above (filtered
on org_id) or through the per-org chain verify endpoint. The
Compliance Pack bundle does not re-export raw
audit events; it ships a chain-attestation.json derived from the
verify endpoint so an auditor can independently confirm the chain's
state at bundle time.
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, domain.removed, user.deleted,
scim.malformed_payload) share a separate chain where org_id IS NULL. org.created is platform-initiated but written with the new
org's id, so it anchors the head of that org's chain rather than
landing in the platform chain. 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 (same linkage break).
What the chain does not prove:
- That every write succeeded. Most 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. Two exceptions commit the audit row inside the primary transaction:api_key.revoked(so an auditor can always answer "who revoked this key") and everyscim.*event (so a failed audit write rolls back the Casdoor dedupe claim and the next retry re-processes cleanly). - 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
The per-org verify endpoint 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 favor of automation; rotate keys when team composition changes if your threat model demands tighter coupling.
Rate-limited at 10 verifications per hour per org.
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".
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) via a separate endpoint. 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 verify response captured at the start of the review window (establishes the chain's state at that moment).
- The latest verify response (proves no retroactive tampering since).
- Any specific rows they want to review, exported via
GET /api/v2/admin/audit-log?...(platform admin). The Compliance Pack itself does not contain raw audit rows — itschain-attestation.jsonis the cryptographic receipt; the rows themselves come from the admin listing.
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 |
scim.malformed_payload | Casdoor SCIM webhook delivered a well-formed envelope with an unparseable nested object — breadcrumb row ensures the delivery is visible in the chain even when the inner payload is unrecoverable |
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 |
GitHub App
| Action | Trigger |
|---|---|
github_installation.created | GitHub App installation linked to the org |
github_installation.deleted | GitHub App installation unlinked from the org (does not uninstall the App on GitHub's side) |
Webhook configurations (GitLab / Atlantis)
| Action | Trigger |
|---|---|
webhook_config.created | Webhook config created |
webhook_config.updated | Webhook config enabled/disabled or otherwise updated |
webhook_config.deleted | Webhook config deleted |
webhook_config.token_updated | API token rotated or cleared |
Compliance Pack (audit export)
| Action | Trigger |
|---|---|
audit_export.requested | Compliance Pack bundle enqueued |
audit_export.downloaded | Compliance Pack artifact streamed to the caller |
audit_export.deleted | Compliance Pack row and artifact 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
The GET /admin/audit-log listing returns the following fields per
entry:
| Field | Description |
|---|---|
id | Unique entry ID |
actor_email | Email of the user who performed the action. Empty for system events |
org_id | Organization scope. Empty string for platform-wide events |
action | Dotted scope.verb string (e.g., api_key.created) |
detail | JSON with action-specific metadata |
created_at | RFC 3339 timestamp (UTC, written from the Go server clock) |
Each row also carries three chain columns (seq, prev_hash, hash)
used by the verify endpoint — see Tamper-evident
chain above. These are not included in the
listing response; they surface via the verify endpoint's head_seq
and head_hash fields, and the Compliance Pack bundles a separate
chain-attestation.json derived from verify.
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.* / scim.group.* | {casdoor_sub, casdoor_org, casdoor_username, email, request_uri} (email is empty for group events) |
scim.malformed_payload | {casdoor_action, record_id, event_id, request_uri, parse_error, created_time} |
cloud_account.created | {account_id, provider, credential_type, external_account_id, display_name} |
policy.updated | {rule_count, version} |
schedule.created | {schedule_id, name} |
schedule.updated / schedule.deleted | {schedule_id} |
schedule.run_triggered | {schedule_id, count} |
scan.bulk_created | {count, scan_ids} |
llm_config.* | {provider} (never key material) |
slack.installed / slack.uninstalled | {team_id, team_name} |
github_installation.created | {id, installation_id, account_login} |
github_installation.deleted | {id} |
webhook_config.created | {config_id, provider, repo_path, provider_base_url, secret_prefix, api_token_prefix, api_token_status, api_token_expires} — the three api_token_* fields are always present but null when no token was supplied |
webhook_config.updated | {config_id, enabled} |
webhook_config.deleted | {config_id} |
webhook_config.token_updated | {config_id, token_cleared, api_token_prefix?, api_token_status?} — never the raw token |
audit_export.requested / audit_export.deleted | {export_id} |
audit_export.downloaded | {export_id, bytes} |
billing.subscription_created | {plan, status, stripe_customer, stripe_event_id, stripe_event_type} |
billing.plan_changed | {plan, status, stripe_event_id, stripe_event_type} |
billing.subscription_cancelled | {plan, stripe_event_id, stripe_event_type} |
billing.payment_failed | {status, stripe_customer, stripe_event_id, stripe_event_type} |
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 — most 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.api_key.revokedandscim.*events are the exception — they commit atomically with the primary mutation viaaudit.WriteChainedso there is no gap window. - 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.
Endpoint reference
Audit log listing and both verify endpoints (per-org + platform) are documented under the audit tag of the API reference. The Compliance Pack CRUD shares the same tag.
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.