Skip to content

Product documentation

SIEM streaming export

Stream your tenant's security-relevant audit events to a SIEM endpoint (Sentinel, Splunk, Elastic) over authenticated HTTPS. Configure a sink, and every matching committed audit row fans out to it at-least-once with retry and a dead-letter queue — grounded in the shipped product-api SIEM surface and the audit-chain fan-out.

SIEM streaming export

SIEM streaming export lets a tenant push its security-relevant audit events to its own SIEM — Microsoft Sentinel, Splunk, Elastic, or any endpoint that accepts an authenticated JSON POST over HTTPS. You configure a sink (an endpoint plus an optional auth header); from then on, every matching audit event the platform commits is fanned out to that sink, delivered at-least-once with automatic retry and a dead-letter queue for events that never land.

This is the push companion to the pull-based events API: the events API lets your systems poll the audit log; a SIEM sink streams the same class of events outward as they happen, so a SOC sees Thoryn sign-ins, token issuance, and admin mutations in the same pane as the rest of its telemetry.

Everything below is on the customer-plane management API (/api/v1/** on product-api, behind the public api-gateway). The tenant is always taken from the tnt claim, never from the path; request and response bodies are camelCase; errors are RFC 9457 problem-details (application/problem+json) with a stable errorCode; lists are cursor-paginated; and cross-tenant (or cross-mode) access returns 404, never 403 — the platform's no-existence-leak invariant.

How an audit event reaches your sink

A SIEM sink is fed off the verifiable audit chain, not bolted onto each writer. When a row is appended to a tenant's chain, it is fanned out to that tenant's active sinks — best-effort and fully decoupled from the audit write:

AuditChainAppendService.append(row)          ← the audit row is committed FIRST
        │ (post-commit, off-thread on a boundedElastic worker)
        ▼
SiemAuditEventBridge.publish(row)             ← fires a Spring application event
        │
        ▼
SiemEventListener.handleAuditEvent(event)     ← filters by action prefix (see below)
        │                                        builds the JSON payload
        ▼
SiemService.publishEvent(...)                 ← one siem_event row (PENDING) per ACTIVE sink
        │                                        in the SAME test/live mode as the source event
        ▼
SiemDeliveryTask  (every 10s)                 ← SiemService.deliverPending()
        │                                        POST payload → sink endpoint (HTTPS)
        ▼
   your SIEM endpoint

Two properties fall out of this shape and are worth internalising up front:

  • The audit write never waits on SIEM. The fan-out is published after the audit row commits and runs on a separate worker thread, off the reactive audit-write path. A slow sink, a SIEM outage, or any fan-out error can never stall or fail an audit append (AuditChainAppendService); the listener catches and WARN-logs a fan-out failure. SIEM is a side-channel, not a system of record.
  • Only security-relevant actions are forwarded. SiemEventListener forwards only audit actions whose name starts with one of a fixed allow-list of prefixes, so high-frequency internal events (retention purges, heartbeats) never flood a tenant's SIEM stream. The forwarded prefixes are: SIGN_IN, SIGN_OUT, TOKEN_ISSUED, ADMIN_, TENANT_, CLIENT_, USER_, FEDERATION_, and COMPLIANCE_.

Prerequisites

  • A tenant-admin access token with tenant:siem.write to configure or deactivate a sink, and tenant:siem.read to list sinks or read delivery health. These scopes were added to the customer-plane clients' grantable set in hub migration V107__add_siem_scopes_to_customer_plane_clients.sql.
  • A SIEM endpoint that accepts an HTTPS JSON POST. Plain-http and any URL resolving to a private, loopback, or link-local address is rejected by the SSRF guard (see Security notes).

Step 1 — Configure a sink

curl -sS -X POST https://api.stg.thoryn.org/api/v1/siem/sinks \
  -H "Authorization: Bearer $ADMIN_TOKEN" \
  -H "Content-Type: application/json" \
  -d '{
        "name": "prod-sentinel",
        "endpointUrl": "https://ingest.example-siem.com/thoryn",
        "format": "JSON_HTTP",
        "authHeader": "Authorization",
        "authValue": "Bearer s3cr3t-ingest-token"
      }'
  • name (required) — a label for the sink, unique per tenant (per mode).
  • endpointUrl (required) — the HTTPS endpoint that receives the POSTs. Validated by the SSRF guard before the sink is persisted.
  • format (optional, default JSON_HTTP) — the only supported wire format today; an unknown value returns 400 invalid_format.
  • authHeader / authValue (optional) — a request header the platform adds to every delivery POST (for example Authorization: Bearer … or X-API-Key: …). The value is envelope-encrypted via Vault Transit before it is stored and decrypted just-in-time before each delivery — it never sits in the database, a log, or an audit row in plaintext.

On success you get 201 with the sink record (id, name, endpointUrl, format, authHeader, isActive, createdAt, createdBy) — note the auth value is never echoed back. A blank name returns 400 invalid_name; a blank endpoint returns 400 invalid_endpoint_url; a URL the SSRF guard rejects returns 400 ssrf_rejected; a transient failure to encrypt the auth value returns 502 vault_unavailable (the sink is not stored in the clear — the create fails loud).

List your sinks (active and inactive), cursor-paginated, newest first:

curl -sS https://api.stg.thoryn.org/api/v1/siem/sinks \
  -H "Authorization: Bearer $ADMIN_TOKEN"

Step 2 — Delivery, retries, and the dead-letter queue

Once a sink is active, every matching audit event produces one delivery row (siem_event, status PENDING). A scheduled sweep (SiemDeliveryTask, every 10 seconds) drains them:

  • PENDINGDELIVERED on an HTTP 2xx from the sink endpoint.
  • PENDING / FAILEDFAILED on a non-2xx or network error, incrementing attemptCount. A FAILED row re-enters the sweep on the next tick.
  • FAILEDDEAD_LETTER once attemptCount reaches the maximum (5 attempts). A dead-lettered event is not retried again.

Each tick processes up to 200 PENDING and 200 FAILED events. Delivery is at-least-once: product-api runs as multiple replicas and the sweep is not leader-elected, so more than one replica may attempt the same delivery, and a retried event is re-POSTed. Every payload carries a stable id (the source audit event's id — see the payload shape) precisely so a sink can deduplicate repeated deliveries of the same logical event.

Step 3 — Check delivery health, and deactivate

Read a sink's delivery statistics — the count of events in each delivery state — to confirm it is receiving traffic and spot a backlog:

curl -sS https://api.stg.thoryn.org/api/v1/siem/sinks/$ID/health \
  -H "Authorization: Bearer $ADMIN_TOKEN"

The response reports pending, delivered, failed, and deadLetter counts for that sink (plus its name and isActive). A rising deadLetter count means the endpoint has been rejecting deliveries past the retry ceiling — check the endpoint URL, its auth header, and its availability.

Deactivate a sink when you no longer want to stream to it. Deactivation is a soft delete — the sink row and its delivery history are retained, but no new events fan out to it, and any already-queued event for it is dropped rather than delivered:

curl -sS -X DELETE https://api.stg.thoryn.org/api/v1/siem/sinks/$ID \
  -H "Authorization: Bearer $ADMIN_TOKEN"

A sink id that is not in your tenant (or mode) returns 404 — never 403.

The payload shape

Each delivery POSTs a single JSON object built with Jackson (never string interpolation, so the body is always valid JSON). It carries the source audit event's id, its action and timing, the actor and target, and the event detail:

{
  "id": "b6f1e2c0-1111-2222-3333-444455556666",
  "tenantId": "acme",
  "action": "CLIENT_SECRET_ROTATED",
  "eventTime": "2026-08-03T09:41:22.170Z",
  "actorSub": "user-42",
  "target": "client:reporting-app",
  "clientIp": "203.0.113.7",
  "eventData": { "clientId": "reporting-app", "rotationId": "7f0c" }
}
  • id — the source audit event's id. The same logical event is fanned out to every configured sink and re-POSTed on retry, all carrying this id — the deduplication key.
  • eventData — embedded as a nested JSON object or array when the audit detail is itself structured JSON, so a SIEM consumer gets real fields rather than an escaped blob. When the detail is a plain string it is embedded as a properly-quoted JSON string, and when it is absent it is null. A parse failure falls back to the raw value as a string — the payload is never invalid JSON.

Endpoint reference

MethodPathScopeDescription
GET/api/v1/siem/sinkstenant:siem.readList the tenant's configured sinks
POST/api/v1/siem/sinkstenant:siem.writeConfigure a new sink
DELETE/api/v1/siem/sinks/{id}tenant:siem.writeDeactivate a sink (soft delete)
GET/api/v1/siem/sinks/{id}/healthtenant:siem.readDelivery statistics for a sink

The generated, always-current contract for these endpoints is in the API reference: Siem.

Configuration

The delivery sweep is on by default and needs no configuration to run. Operators can tune two things:

oauthy:
  siem:
    delivery:
      # The scheduled delivery sweep. Defaults true; set false to disable outbound
      # HTTP delivery entirely (e.g. a dev/test environment). Sinks and siem_event
      # rows are still created — only the POSTs are suppressed.
      enabled: true
  security:
    outbound-url-guard:
      # SSRF policy applied to every sink endpoint URL (HTTPS required; no
      # allow-list entries by default). The platform-wide guard properties.
      # See core/lib/common OutboundUrlGuard.
      # allowed-hosts: []

The per-sink auth header value is envelope-encrypted with the Vault Transit key product-api-token-claims (shared with the hooks / webhook-endpoint / claims-enrichment secrets); oauthy.siem.vault.transit-key overrides the key name and oauthy.siem.vault.base-url the Vault address, both defaulting from the federation Vault configuration.

Troubleshooting

SymptomCauseFix
400 ssrf_rejected on POST /sinksThe endpoint URL is not HTTPS, or resolves to a private/loopback/link-local/metadata addressUse a public HTTPS endpoint; the guard blocks internal ranges by design
400 invalid_format on POST /sinksformat is not JSON_HTTPJSON_HTTP is the only supported format today
502 vault_unavailable on POST /sinksThe auth header value could not be envelope-encrypted (Vault/OpenBao transient failure)Retry — the platform fails loud rather than store the secret in plaintext
Events are PENDING but never DELIVEREDDelivery sweep disabled, or the endpoint is unreachable/slowConfirm oauthy.siem.delivery.enabled is true; check the endpoint is reachable over HTTPS from the cluster
Rising deadLetter count on GET /sinks/{id}/healthThe endpoint returned non-2xx (or errored) for 5 attemptsCheck the endpoint URL, its auth header value, and its availability; re-create the sink to refresh the auth value
An expected event never arrivesIts audit action is not in the forwarded prefix allow-list, or the sink's mode differs from the event'sOnly the listed action prefixes are forwarded; a test-mode sink receives only test-mode events, a live sink only live
404 on delete/health by idThe sink id belongs to another tenant or modeExpected — cross-tenant/cross-mode access is 404 by design, not 403

Security notes

  • SSRF-guarded sinks. Every sink endpoint URL passes through OutboundUrlGuard twice — at configure time (before the sink is stored) and again at delivery time (before each POST). The guard requires HTTPS and rejects URLs that resolve to private, loopback, link-local, broadcast/multicast, or cloud-metadata addresses. The delivery-time re-check is defence in depth against DNS-rebinding a previously-valid host to an internal range.
  • Auth secret custody. The customer-supplied auth header value is envelope-encrypted with Vault Transit before it is persisted and decrypted just-in-time before each delivery POST. Plaintext exists only transiently in JVM memory for one encrypt/decrypt call — never at rest, never in a log, never in an audit row. The auth value is never returned on any read endpoint.
  • Best-effort, never at the audit chain's expense. The fan-out is published after the audit row commits, on a separate worker thread, and every failure is caught and WARN-logged. A sink outage, a slow endpoint, or a fan-out bug can never roll back, stall, or fail an audit write — the verifiable audit chain is the system of record; the SIEM stream is a downstream side-channel.
  • Tenant and mode isolation. A sink is scoped to the tnt claim and stamped with the creating token's test/live mode. The fan-out routes a live audit event only to live sinks and a test event only to test sinks, so test traffic never reaches a live SOC feed and live traffic never reaches a sandbox-configured endpoint.

See also

  • Consume the events API — the pull-based counterpart: poll the audit log instead of (or alongside) streaming it to a SIEM.
  • Receive webhook events — push individual lifecycle events to an application endpoint (a different channel from SIEM audit streaming).