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/queryAPI. - 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_hashfield: 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 predecessorchain_hash(a known race documented inemit-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
| Event | Triggered by |
|---|---|
auth.sign_in | Successful login (any method) |
auth.sign_in_failed | Failed login attempt |
auth.sign_out | Explicit logout or session revocation |
auth.token_refreshed | Session token refresh |
auth.password_changed | Password change (not reset) |
auth.email_verified | Email verification completed |
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 | Billing operation attempted without billing role |
billing.auto_reload_updated | Auto-reload configuration changed |
billing.checkout_initiated | Stripe checkout session started |
billing.credits_purchased | Credit pack purchase completed |
billing.payment_method_added | Payment method added |
billing.payment_method_default_changed | Default payment method changed |
billing.payment_method_removed | Payment method removed |
billing.plan_changed | Subscription tier changed |
billing.seats_changed | Seat count changed |
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.denylist_added | Plugin added to the org denylist |
plugin.denylist_removed | Plugin removed from the org denylist |
Security policy
| Event | Triggered by |
|---|---|
security.mfa_policy_updated | MFA requirement policy changed |
security.session_revoked | Active session force-revoked |
Access review
| Event | Triggered by |
|---|---|
access.review_completed | Periodic access review completed |
access.member_access_confirmed | Member access confirmed during review |
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 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.