oxagenDocs
Security

Audit logging

How Oxagen records every security event, IAM decision, and capability call across two independent audit stores.

Oxagen writes an audit record for every action that matters: every sign-in, every IAM decision, every capability call, every billing change, and every org management action. Two independent stores hold the records, each built for a different query pattern. ClickHouse chains each row's hash to the one before it, so a removed or inserted row shows when you recompute the chain.

Two independent audit stores

Postgres: security.security_events

Relational, queryable, and indexed for point lookups and compliance reporting.

  • Retention: 7 years, enforced by monthly RANGE partitions. Oxagen drops partitions older than 7 years.
  • Schema: id, occurred_at, event_type, actor_user_id, org_id, workspace_id, capability, outcome, ip, user_agent, request_id.
  • Query surface: Organization → Audit (filtered UI), or the query_audit_log capability over the API.
  • Use cases: point lookups ("did this person call this capability?"), compliance dashboard queries, and access-review exports.

ClickHouse: audit_events

Append-only ReplacingMergeTree, built for high-volume analytics, with chain-hash tamper evidence.

  • Retention: 7-year TTL column.
  • Tamper evidence: each row includes a chain_hash field, SHA-256(previous_chain_hash | event_id | capability). The rows form a hash chain, so recomputing it from any anchor point shows a deletion or insertion in the middle. Concurrent writes from more than one process can read the same predecessor chain_hash (a known race documented in emit-audit.ts), so verification is advisory rather than cryptographically strict.
  • Use cases: high-volume analytics queries, SIEM pulls, tamper checks, and trend analysis over audit event volumes.

Independent code paths write the two stores. For each invoke() call, the Postgres write uses the kernel's emitSecurityEvent() (a pluggable emitter), and the ClickHouse write uses emitAudit(), called inside checkIAM(). Both are independent fire-and-forget calls, and not every event type reaches both stores. Auth and billing events go only to Postgres. ClickHouse receives only IAM capability decisions. A failed write in either path does not fail the capability call.

Event taxonomy

The taxonomy in packages/compliance/src/security-event-types.ts defines the event names. The tables below describe common events and mark reserved names. Consult that file for additions.

Auth lifecycle

EventTriggered by
auth.sign_inSuccessful login (any method)
auth.sign_in_failedFailed login
auth.sign_outExplicit logout or revoked login
auth.token_refreshedReserved, not emitted yet
auth.password_changedA completed password reset. Every existing session for the user ends with it
auth.email_verifiedA verified email address, from a verification link or a confirmed change of address

API key lifecycle

EventTriggered by
api_key.createdNew API key created
api_key.revokedAPI key revoked
api_key.usedSuccessful API key authentication

Billing mutations

EventTriggered by
billing.access_deniedReserved, not emitted yet
billing.auto_reload_updatedReserved, no longer emitted
billing.budget_updatedSpend budget changed
billing.checkout_initiatedStripe checkout started
billing.credits_purchasedCredit pack purchase completed
billing.payment_method_addedReserved, not emitted yet
billing.payment_method_default_changedReserved, not emitted yet
billing.payment_method_removedReserved, not emitted yet
billing.plan_changedSubscription tier changed
billing.seats_changedReserved, not emitted yet
billing.subscription_canceledSubscription canceled
billing.subscription_reactivatedCanceled subscription reactivated

Capability authorization

EventTriggered by
capability.invoke_allowedIAM decision: allowed
capability.invoke_deniedIAM decision: denied
capability.invoke_errorCapability handler threw an error

Org management

EventTriggered by
org.member_invitedInvitation sent
org.member_removedMember removed
org.role_changedRole assignment changed

Org lifecycle

EventTriggered by
organization.createdNew organization provisioned
workspace.createdNew workspace created within an org

Plugin governance

EventTriggered by
plugin.installedPlugin installed from the marketplace
plugin.uninstalledPlugin removed from the workspace
plugin.enabled_changedPlugin enabled or disabled
plugin.credential_setPlugin credential stored
plugin.credential_revokedPlugin credential revoked
plugin.denylist_addedReserved, not emitted yet (there is no plugin denylist)
plugin.denylist_removedReserved, not emitted yet (there is no plugin denylist)

Secrets

EventTriggered by
secret.revealedA secret's plaintext value revealed
secret.exportedSecrets exported in plaintext
secret.value_changedA secret's value set or changed
secret.key_deletedA secret key deleted

Data planes and model credentials

EventTriggered by
data_plane.updatedAn organization's data plane changed (set_data_plane)
model_credential.setAn organization's own model key stored or replaced
model_credential.revokedAn organization's own model key cleared

Agent run integrity

EventTriggered by
agent_run.event_sequence_conflictA run event conflicted with the recorded event sequence
agent_run.forged_decision_referenceReserved, not emitted yet
agent_run.stale_deny_generationReserved, not emitted yet
agent_run.finalization_grant_misuseReserved, not emitted yet

Security policy

EventTriggered by
security.mfa_policy_updatedReserved, not emitted yet
security.session_revokedA signed-in login force-revoked

Access review

EventTriggered by
access.review_completedReserved, not emitted yet
access.member_access_confirmedReserved, not emitted yet

Privacy / GDPR

EventTriggered by
privacy.export_requestedData export requested (Art. 20)
privacy.erasure_requestedPersonal data erasure requested (Art. 17)
privacy.org_erasure_requestedOrg-level erasure requested

Adding a new event kind takes one edit to the taxonomy source file. The CI test checks that the Postgres CHECK constraint and the ClickHouse schema match that file, so schema drift fails CI.

Completeness

Every invoke() call, from any surface (app, API, or MCP), emits either a capability.invoke_allowed or a capability.invoke_denied event. No path through the capability boundary skips the emit, and there is no opt-out. A write can still fail, and a failed write does not fail the call. This is the SOC 2 completeness argument.

Accessing audit logs

In the app

Organization → Audit is a filterable view of security.security_events. Filter by:

  • Event kind
  • Acting principal
  • Time range
  • Outcome (allowed or denied)
  • Capability name

Via API

http
POST https://api.oxagen.sh/v1/{orgSlug}/{workspaceSlug}/audit/log/query
Authorization: Bearer ox_…
Content-Type: application/json

{
  "eventType": "capability.invoke_denied",
  "from": "2026-01-01T00:00:00Z"
}

The response is a JSON object with events (newest first), total, hasMore, limit, and offset. Page through results with limit (up to 200) and offset.

SIEM export

SIEM export (Splunk, Datadog, S3, or a JSONL webhook) is not available. Call query_audit_log on a schedule to pull audit events into an external SIEM.

Audit event structure

The two stores have different schemas. A capability call produces a row in each.

Postgres security.security_events

json
{
  "id": "uuid",
  "occurred_at": "2026-06-07T12:34:56.789Z",
  "event_type": "capability.invoke_denied",
  "actor_user_id": "uuid",
  "org_id": "uuid",
  "workspace_id": "uuid",
  "capability": "change_member_role",
  "outcome": "deny",
  "ip": "192.0.2.1",
  "user_agent": "Mozilla/5.0 …",
  "request_id": "req_uuid"
}

ClickHouse audit_events

json
{
  "occurred_at": "2026-06-07T12:34:56.789Z",
  "event_id": "uuid",
  "org_id": "uuid",
  "workspace_id": "uuid",
  "capability": "change_member_role",
  "scope_kind": "workspace",
  "scope_id": "uuid",
  "acting_principal_id": "uuid",
  "acting_principal_kind": "human",
  "human_principal_id": "uuid",
  "outcome": "deny",
  "decision_reason": "org.enforced_deny",
  "payload_hash": "sha256:…",
  "chain_hash": "sha256:…",
  "ip": "192.0.2.1",
  "ua": "Mozilla/5.0 …",
  "request_id": "uuid",
  "trace_jsonb": "{\"matched_rule\":\"org.enforced_deny\",\"policy_id\":\"uuid\"}"
}

Every capability.invoke_denied ClickHouse row has a trace_jsonb field. It holds the resolver's step log, so you can answer "why was this denied?" from the stored decision rather than reconstructing it from logs. Auth and billing events are written only to Postgres and produce no ClickHouse audit_events row.

On this page