# Audit Logs & SIEM Integration

Forward Quor's structured audit-event stream to your SIEM for security monitoring and compliance

<Note>
  Audit logging is available in **Quor v4.3 and later**,
  on **self-hosted** deployments where you control the log pipeline.
</Note>

## Overview

Quor emits a normalized, structured **audit-event stream** for security-relevant actions — authentication,
user and access-control changes, admin configuration changes, and credential access.
Every event is a single JSON object, so you can forward the stream to any SIEM — **Splunk, Microsoft Sentinel, Elastic,
Google Chronicle, AWS Security Lake** — with no Quor-side integration to build.
You point your log shipper at Quor's logs, filter the audit stream, and parse the JSON.

Field names and the action taxonomy are shaped toward [**OCSF**](https://schema.ocsf.io)
(the Open Cybersecurity Schema Framework), so events map cleanly onto the event classes most SIEMs already understand.

This maps directly onto common compliance controls — **SOC 2 CC7** and the **NIST 800-53 / FedRAMP AU** family (AU-2
auditable events, AU-3 record content, AU-6 review, AU-12 generation).

## What gets captured

<AccordionGroup>
  <Accordion title="Authentication">
    Login success, login failure, logout, registration, password forgot, password reset, and email verification.
  </Accordion>

  <Accordion title="User & access management">
    User creation (admin invites), deletion, deactivation, reactivation, role changes,
    and user-group membership changes.
  </Accordion>

  <Accordion title="Configuration & resources">
    Create / update / delete of LLM providers, connectors, connector-credential pairs, API keys,
    and credentials — plus credential-access (decrypt) events.
  </Accordion>
</AccordionGroup>

Each event records **who** did **what**, to **which resource**, **when**, from **where**,
and whether it **succeeded** — the core of an audit trail.

## How it works

Audit events are emitted as `INFO` log records on a dedicated **`onyx.audit`** logger.
The message body of each record is a single JSON object — Quor serializes the event itself,
so the audit line is identical regardless of your log format.

Emission is **fail-safe**: it never interrupts a user request or a connector run,
even if context gathering or logging fails. High-volume event classes are de-duplicated within a short (10-minute)
window using Redis. De-duplication is **fail-open** — if Redis is unavailable,
the dedup check is skipped and the event is still emitted,
so an audit event is never silently dropped due to infrastructure trouble (you may just see a duplicate).

The subsystem is **always on** — there is no enable/disable flag, output-format, or destination setting,
and the de-duplication window is fixed. The only related knob is `LOG_FORMAT` (below),
which shapes the *surrounding* log records; the audit JSON body itself is identical either way.

<Tip>
  Run Quor with `LOG_FORMAT=json` so that *all* log records — not just audit events — are machine-parseable,
  and request/tenant context is promoted to top-level fields.
  This makes filtering and parsing in your shipper much simpler.
</Tip>

## Event schema

| Field | Type | Description |
|---|---|---|
| `audit_schema_version` | string | Schema version (currently `"1.0"`). |
| `ts` | number | Event time, epoch seconds. |
| `action` | string | Action taxonomy value, `<domain>.<verb>` (e.g. `llm_provider.update`). Stable, append-only. |
| `ocsf_class` | string | `authentication`, `account_change`, or `api_activity`. |
| `outcome` | string | `success`, `failure`, or `denied`. |
| `tenant_id` | string \| null | Tenant the action occurred in. |
| `actor` | object \| null | `{ user_id, email, api_key_id, auth_type }`. Never contains a secret. |
| `resource_type` | string \| null | Affected resource type (e.g. `llm_provider`, `user`, `api_key`). |
| `resource_id` | string \| null | Affected resource identifier. |
| `request_id` | string \| null | Correlates the event with the rest of that request's logs. |
| `endpoint` | string \| null | Route that produced the event. |
| `source_ip` | string \| null | Client IP (from `X-Forwarded-For`). |
| `extra` | object \| null | Additional non-secret context. |

The `action` values are a stable, append-only contract — safe to filter and build dashboards against.
New actions may be added over time; existing ones do not change meaning.

### Example event

```json
{
  "audit_schema_version": "1.0",
  "ts": 1750000000.123,
  "action": "llm_provider.update",
  "ocsf_class": "api_activity",
  "outcome": "success",
  "tenant_id": "tenant_abc",
  "actor": { "user_id": "u-42", "email": "admin@example.com", "api_key_id": null, "auth_type": "oauth" },
  "resource_type": "llm_provider",
  "resource_id": "7",
  "request_id": "01J...",
  "endpoint": "PUT /admin/llm/provider",
  "source_ip": "203.0.113.5",
  "extra": null
}
```

## Forward to your SIEM

Because audit events are just JSON log lines on a known logger prefix, any log shipper works.

<Steps>
  <Step title="Run Quor with JSON logging">
    Set `LOG_FORMAT=json` so every log record is structured and carries a `logger` and `message` field.
  </Step>

  <Step title="Ship Quor's logs">
    Point Fluent Bit, Vector, the CloudWatch agent, Filebeat,
    or your existing collector at the Quor container stdout (or the mounted log files).
  </Step>

  <Step title="Isolate and parse the audit stream">
    Keep only records whose `logger` starts with `onyx.audit`,
    then parse each record's `message` field as JSON to recover the event fields above.
  </Step>

  <Step title="Route to your SIEM">
    Send the parsed events to your SIEM's ingest endpoint. The OCSF-shaped fields map onto Authentication,
    Account Change, and API Activity event classes.
  </Step>
</Steps>

### Shipper examples

<CodeGroup>
  ```toml Vector
# Keep only audit records, then replace the event with the parsed JSON payload.
[transforms.onyx_audit]
type = "filter"
inputs = ["onyx_logs"]
condition = '''starts_with(string!(.logger), "onyx.audit")'''

[transforms.onyx_audit_parsed]
type = "remap"
inputs = ["onyx_audit"]
source = '. = parse_json!(.message)'
  ```

  ```ini Fluent Bit
# Match the audit stream by logger prefix.
[FILTER]
    Name    grep
    Match   onyx.*
    Regex   logger ^onyx\.audit
  ```
</CodeGroup>

<Info>
  Filter on the `onyx.audit` **prefix** to capture everything.
  The stream is also split into per-class child loggers — `onyx.audit.authentication`, `onyx.audit.account_change`,
  `onyx.audit.api_activity`,
  and `onyx.audit.credential_access` — if you want to route classes to different destinations.
</Info>

## Event reference

Every `action` value, what triggers it, and the fields most useful for building SIEM detection rules. `actor`,
`tenant_id`, `request_id`, `endpoint`, `source_ip`,
and `outcome` are present on every event (see [Event schema](#event-schema)) and are omitted from the tables below.

### Authentication

`ocsf_class: authentication`

| `action` | Triggered when | Notes |
|---|---|---|
| `auth.login` | A user successfully signs in | |
| `auth.login_failure` | A sign-in attempt fails | `outcome` is `failure`, or `denied` for a non-interactive login |
| `auth.logout` | A user signs out | |
| `auth.register` | A new account is registered (self-signup) | |
| `auth.password_forgot` | A password-reset email is requested | |
| `auth.password_reset` | A password reset is completed | |
| `auth.email_verify` | An email-verification email is requested | |

### User & access management

`ocsf_class: account_change` — `resource_type` is `user` (or `user_group`).

| `action` | Triggered when | `resource_id` | Key `extra` fields |
|---|---|---|---|
| `user.create` | An admin invites new users | — | `invited_emails` |
| `user.delete` | A user is deleted | user id | `target_email` |
| `user.deactivate` | A user is deactivated | user id | `target_email` |
| `user.reactivate` | A user is reactivated | user id | `target_email` |
| `user.role_change` | A user's role is changed | user id | `target_email`, `old_role`, `new_role` |
| `user.group_change` | A user group's membership changes | user-group id | `added_user_ids`, `removed_user_ids` |

### Configuration & resources

`ocsf_class: api_activity`

| `action` | Triggered when | `resource_type` | `resource_id` | Key `extra` fields |
|---|---|---|---|---|
| `llm_provider.create` / `.update` / `.delete` | An LLM provider is created, updated, or deleted | `llm_provider` | provider id | |
| `connector.create` / `.update` / `.delete` | A connector is created, updated, or deleted | `connector` | connector id | `source` (on create) |
| `cc_pair.create` / `.update` / `.delete` | A connector is linked to a credential, its status changes, or it is unlinked | `cc_pair` | connector-credential-pair id | `connector_id`, `credential_id`; `status` (on update) |
| `api_key.create` / `.regenerate` / `.delete` | An API key is created, regenerated, or revoked | `api_key` | API-key id | |
| `credential.create` / `.update` / `.delete` | A stored credential is created, updated, or deleted | `credential` | credential id | `source` (on create) |
| `credential.access` | A stored credential is decrypted for use | — | — | Emitted on a **different schema** — see [Credential access](#credential-access) below |

### Credential access

`onyx.audit.credential_access`

Credential-decrypt events predate the generalized schema and are emitted on a **flat,
legacy field set** for backward compatibility.
They share the `onyx.audit` logger tree and the same fail-safe / fail-open behavior,
but do **not** carry the generalized fields — there is no `action`, `ocsf_class`, `outcome`,
`resource_type`/`resource_id`, `extra`, or nested `actor` object, and the client IP is `client_ip` (not `source_ip`).
Parse this stream separately; detection rules written against the generalized schema will not match it.

| Field | Type | Description |
|---|---|---|
| `ts` | number | Event time, epoch seconds. |
| `tenant_id` | string \| null | Tenant the decrypt occurred in. |
| `credential_type` | string | Coarse category, e.g. `llm_provider` or `connector`. |
| `provider` | string \| null | Provider / source name if known (e.g. `openai`, `google_drive`). |
| `row_id` | number \| null | DB row id of the credential / LLM provider. |
| `user_id` | string \| null | Acting user id, if available (may be null for background jobs). |
| `auth_type` | string \| null | How the actor authenticated, if known. |
| `request_id` | string \| null | Correlates with the rest of that request's logs. |
| `endpoint` | string \| null | Route that produced the event. |
| `client_ip` | string \| null | Client IP (from `X-Forwarded-For`). |

<Tip>
  The taxonomy is **append-only**: new `action` values may be added in future releases,
  but existing ones never change meaning — so detection rules and dashboards built against these values keep working
  across upgrades.
</Tip>

## Notes

- **Secrets are never logged.** Actor objects and `extra` context are scrubbed
  of credential values by design.
- **Credential-access events** (`onyx.audit.credential_access`) use a separate,
  flat field set than the generalized schema for backward compatibility — see [Credential access](#credential-access)
  for the full field list. They share the same logger tree and fail-safe behavior.
- For **Quor Cloud** (multi-tenant) deployments, audit-log forwarding is handled
  differently since you don't control the log pipeline — [contact us](/security/contact_us)
  to discuss SIEM delivery options.
