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. 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'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, 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 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 (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.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. Two exceptions commit the audit row inside the primary transaction: api_key.revoked (so an auditor can always answer "who revoked this key") and every scim.* 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_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

The per-org verify endpoint 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 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_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) 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:

  1. A verify response captured at the start of the review window (establishes the chain's state at that moment).
  2. The latest 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). The Compliance Pack itself does not contain raw audit rows — its chain-attestation.json is 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)

ActionTrigger
domain.addedEmail domain added to allowlist
domain.removedEmail domain removed from allowlist
user.deletedUser soft-deleted by a platform admin
scim.malformed_payloadCasdoor 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

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

GitHub App

ActionTrigger
github_installation.createdGitHub App installation linked to the org
github_installation.deletedGitHub App installation unlinked from the org (does not uninstall the App on GitHub's side)

Webhook configurations (GitLab / Atlantis)

ActionTrigger
webhook_config.createdWebhook config created
webhook_config.updatedWebhook config enabled/disabled or otherwise updated
webhook_config.deletedWebhook config deleted
webhook_config.token_updatedAPI token rotated or cleared

Compliance Pack (audit export)

ActionTrigger
audit_export.requestedCompliance Pack bundle enqueued
audit_export.downloadedCompliance Pack artifact streamed to the caller
audit_export.deletedCompliance Pack row and artifact 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

The GET /admin/audit-log listing returns the following fields per entry:

FieldDescription
idUnique entry ID
actor_emailEmail of the user who performed the action. Empty for system events
org_idOrganization scope. Empty string for platform-wide events
actionDotted scope.verb string (e.g., api_key.created)
detailJSON with action-specific metadata
created_atRFC 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

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.* / 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}
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 — 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.Error events with the message audit log write failed; wire log-based alerting on that message to detect drops. api_key.revoked and scim.* events are the exception — they commit atomically with the primary mutation via audit.WriteChained so there is no gap window.
  • 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.

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.json derived from the verify endpoint above, so auditors can independently confirm the bundle's view of the chain matches the live endpoint.