OxagenDocs
Security

Audit logging

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

Oxagen writes an immutable audit record for every action that matters: every sign-in, every IAM decision, every capability invocation, every billing change, every org management action. The records are distributed across two independent stores optimized for different query patterns — and neither store can be silently tampered with.

Two independent audit stores

Postgres: security.security_events

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

  • Retention: 7 years, enforced by monthly RANGE partitions. Partitions older than 7 years are dropped automatically.
  • Schema: id, occurred_at, event_type, actor_user_id, org_id, workspace_id, capability, outcome, ip, user_agent, request_id.
  • Query surface: Organization → Security → Audit (filtered UI), or via POST /v1/audit/log/query API.
  • Use cases: Point lookups ("did this user access this capability?"), compliance dashboard queries, access-review exports.

ClickHouse: audit_events

Append-only, ReplacingMergeTree, 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). This creates a verifiable hash chain — any deletion or insertion in the middle of the chain is detectable by recomputing it from any anchor point. Note: concurrent writes from multiple processes may 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 export, tamper verification, trend analysis over audit event volumes.

The two stores are written by independent code paths. For each invoke() call: the Postgres write uses the kernel's emitSecurityEvent() (pluggable emitter); the ClickHouse write uses emitAudit() called inside checkIAM(). They are independent fire-and-forget calls — not all event types reach both stores. Auth and billing events go only to Postgres; ClickHouse receives only IAM capability invocations. Write failures in either path do not cause the capability invocation to fail.

Event taxonomy

40 typed security event kinds are defined in a single source of truth (packages/compliance/src/security-event-types.ts). Both the Postgres CHECK constraint and the ClickHouse schema derive from this file — schema drift fails CI.

Auth lifecycle

EventTriggered by
auth.sign_inSuccessful login (any method)
auth.sign_in_failedFailed login attempt
auth.sign_outExplicit logout or session revocation
auth.token_refreshedSession token refresh
auth.password_changedPassword change (not reset)
auth.email_verifiedEmail verification completed

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_deniedBilling operation attempted without billing role
billing.auto_reload_updatedAuto-reload configuration changed
billing.checkout_initiatedStripe checkout session started
billing.credits_purchasedCredit pack purchase completed
billing.payment_method_addedPayment method added
billing.payment_method_default_changedDefault payment method changed
billing.payment_method_removedPayment method removed
billing.plan_changedSubscription tier changed
billing.seats_changedSeat count changed
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.denylist_addedPlugin added to the org denylist
plugin.denylist_removedPlugin removed from the org denylist

Security policy

EventTriggered by
security.mfa_policy_updatedMFA requirement policy changed
security.session_revokedActive session force-revoked

Access review

EventTriggered by
access.review_completedPeriodic access review completed
access.member_access_confirmedMember access confirmed during review

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 requires a single edit to the event taxonomy source file. The CI test verifies that the Postgres CHECK constraint and ClickHouse schema match this source file — schema drift fails CI.

The completeness guarantee

Every invoke() call — regardless of surface (app, API, MCP) — produces either a capability.invoke_allowed or capability.invoke_denied event. There is no path through the capability boundary that does not emit. This is the SOC 2 completeness argument: every action is audited, with no opt-out and no alternate code path.

Accessing audit logs

In the app

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

  • Event kind (any of the 40 types above)
  • Acting principal
  • Time range
  • Outcome (allowed / denied)
  • Capability name

Workspace-scoped events also appear under Workspace → Activity → Audit (same records, different scope filter).

Via API

POST https://api.oxagen.sh/v1/audit/log/query
Authorization: Bearer oxk_live_…
Content-Type: application/json

{
  "event_type": "capability.invoke_denied",
  "since": "2026-01-01T00:00:00Z"
}

Response is paginated JSONL. Use the next_cursor field to paginate.

SIEM export

SIEM export (Splunk, Datadog, S3, JSONL webhook) is not yet available. Poll POST /v1/audit/log/query on a schedule to pull audit events into an external SIEM in the interim.

Audit event structure

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

Postgres security.security_events

{
  "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": "agent.code.execute",
  "outcome": "deny",
  "ip": "192.0.2.1",
  "user_agent": "Mozilla/5.0 …",
  "request_id": "req_uuid"
}

ClickHouse audit_events

{
  "occurred_at": "2026-06-07T12:34:56.789Z",
  "event_id": "uuid",
  "org_id": "uuid",
  "workspace_id": "uuid",
  "capability": "agent.code.execute",
  "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\"}"
}

The trace_jsonb field is present on every capability.invoke_denied ClickHouse row. It captures the resolver step log — enabling deterministic "why was this denied?" investigations without approximating from logs. Note: auth and billing events are written only to Postgres; they do not produce a ClickHouse audit_events row.

On this page