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_logcapability 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_hashfield, 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 predecessorchain_hash(a known race documented inemit-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
| Event | Triggered by |
|---|---|
auth.sign_in | Successful login (any method) |
auth.sign_in_failed | Failed login |
auth.sign_out | Explicit logout or revoked login |
auth.token_refreshed | Reserved, not emitted yet |
auth.password_changed | A completed password reset. Every existing session for the user ends with it |
auth.email_verified | A verified email address, from a verification link or a confirmed change of address |
API key lifecycle
| Event | Triggered by |
|---|---|
api_key.created | New API key created |
api_key.revoked | API key revoked |
api_key.used | Successful API key authentication |
Billing mutations
| Event | Triggered by |
|---|---|
billing.access_denied | Reserved, not emitted yet |
billing.auto_reload_updated | Reserved, no longer emitted |
billing.budget_updated | Spend budget changed |
billing.checkout_initiated | Stripe checkout started |
billing.credits_purchased | Credit pack purchase completed |
billing.payment_method_added | Reserved, not emitted yet |
billing.payment_method_default_changed | Reserved, not emitted yet |
billing.payment_method_removed | Reserved, not emitted yet |
billing.plan_changed | Subscription tier changed |
billing.seats_changed | Reserved, not emitted yet |
billing.subscription_canceled | Subscription canceled |
billing.subscription_reactivated | Canceled subscription reactivated |
Capability authorization
| Event | Triggered by |
|---|---|
capability.invoke_allowed | IAM decision: allowed |
capability.invoke_denied | IAM decision: denied |
capability.invoke_error | Capability handler threw an error |
Org management
| Event | Triggered by |
|---|---|
org.member_invited | Invitation sent |
org.member_removed | Member removed |
org.role_changed | Role assignment changed |
Org lifecycle
| Event | Triggered by |
|---|---|
organization.created | New organization provisioned |
workspace.created | New workspace created within an org |
Plugin governance
| Event | Triggered by |
|---|---|
plugin.installed | Plugin installed from the marketplace |
plugin.uninstalled | Plugin removed from the workspace |
plugin.enabled_changed | Plugin enabled or disabled |
plugin.credential_set | Plugin credential stored |
plugin.credential_revoked | Plugin credential revoked |
plugin.denylist_added | Reserved, not emitted yet (there is no plugin denylist) |
plugin.denylist_removed | Reserved, not emitted yet (there is no plugin denylist) |
Secrets
| Event | Triggered by |
|---|---|
secret.revealed | A secret's plaintext value revealed |
secret.exported | Secrets exported in plaintext |
secret.value_changed | A secret's value set or changed |
secret.key_deleted | A secret key deleted |
Data planes and model credentials
| Event | Triggered by |
|---|---|
data_plane.updated | An organization's data plane changed (set_data_plane) |
model_credential.set | An organization's own model key stored or replaced |
model_credential.revoked | An organization's own model key cleared |
Agent run integrity
| Event | Triggered by |
|---|---|
agent_run.event_sequence_conflict | A run event conflicted with the recorded event sequence |
agent_run.forged_decision_reference | Reserved, not emitted yet |
agent_run.stale_deny_generation | Reserved, not emitted yet |
agent_run.finalization_grant_misuse | Reserved, not emitted yet |
Security policy
| Event | Triggered by |
|---|---|
security.mfa_policy_updated | Reserved, not emitted yet |
security.session_revoked | A signed-in login force-revoked |
Access review
| Event | Triggered by |
|---|---|
access.review_completed | Reserved, not emitted yet |
access.member_access_confirmed | Reserved, not emitted yet |
Privacy / GDPR
| Event | Triggered by |
|---|---|
privacy.export_requested | Data export requested (Art. 20) |
privacy.erasure_requested | Personal data erasure requested (Art. 17) |
privacy.org_erasure_requested | Org-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
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
{
"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
{
"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.