Skip to content

Product documentation

SIEM and DSAR in test mode

Why the SIEM streaming surface is mode-partitioned but the DSAR (GDPR Article 15) surface is platform-live-and-blocked in test mode — the two flip-blocking decisions that close the last customer-plane gaps before trustTestIssuers flips.

SIEM and DSAR in test mode

The per-tenant test mode work (SSO-1887, ADR 2026-07-21) partitions every tenant-created resource and event by a livemode boolean so a sandbox credential reads and writes a dataset that is fully separated from live data. The product-api sweep (stories S2a–S2i, migrations V59V68) covered clients, federation members, organisations, RBAC, FGA, hooks, PIM/access packages, and the audit chain. Two customer-plane surfaces were not in that sweep and are the subject of this page:

  • SIEM streaming/api/v1/siem/**, scopes tenant:siem.read / tenant:siem.write.
  • DSAR (GDPR Article 15 data-subject access) — /api/v1/dsar/**, scopes tenant:dsar.execute / tenant:dsar.read.

This page records, per surface, the mode decision, the reasoning, and the implementation it implies. It is a flip-blocker resolver: both decisions must be implemented before trustTestIssuers flips (SSO-2146), because until they land a mode=test token that becomes acceptable at the flip could reach live SIEM configuration or run a live GDPR operation.

Status (2026-08-02): both decisions are implemented and merged. SIEM is partitioned by livemode (SSO-2151, V71, #2545); DSAR is blocked for mode=test tokens (SSO-2152, #2542). The ADR 2026-07-21 swept-or-excluded inventory has been reconciled to match (SIEM in the swept set; DSAR in the "excluded by design" list, alongside federated_identity). Both remain latent until the flip because trustTestIssuers is still false at all three resource servers.

Background — the three mode outcomes a surface can take

Mode rides the hub-minted mode claim, orthogonal to tnt (which tenant) and scopes (what you may do). com.devnow.productapi.tenant.ModeContext derives livemode = mode != "test", with a fail-safe default: any absent, unknown, or non-test value is live. Only the exact string test selects the sandbox dataset, so no derivation error can ever expose live data to a test credential. Test mode is a privilege drop.

Given that model, a tenant-scoped surface can correctly take exactly one of three shapes. The sweep already has a worked precedent for each:

OutcomeWhat it meansPrecedent
PartitionThe table carries livemode; reads AND livemode = :callerMode, writes stamp the caller's mode, cross-mode get-by-id returns 404. Test and live are independent datasets.federation_member (V59) — a tenant-created integration resource.
Platform-live (serve all)The data is inherently live/system-generated; there is no tenant write path; a read is served the same live data regardless of caller mode.security_incident in the NIS2/DORA report builders (SSO-2111) — platform-written, no tenant-mode write path, so a livemode partition would be semantically wrong, not a gap.
Platform-live and blockedThe data is inherently live, and the operation is side-effectful over live data (or emits live PII), so a strictly-less-privileged test credential must not reach it at all — the surface returns a mode error for mode=test.New with DSAR (below).

The security invariant that gates the whole flip: no mode=test token may read live data, mutate live state, or trigger a live side effect through any surface. A surface that would leak or mutate live data for a test token is a hard blocker, not a nice-to-have.

Today neither SIEM nor DSAR is reachable by a test token: trustTestIssuers is false at all three resource servers, so a mode=test token is rejected 401 with no JWKS fetch. The gaps below are latent — they become live exactly at the flip, which is why they must close first.

SIEM — decision: partition by livemode

What the surface is

SIEM streaming has two tables (servers/product-api/src/main/kotlin/com/devnow/productapi/siem/):

  • siem_sink — a tenant-created row per SIEM endpoint (Splunk / Sentinel / Elastic): the destination URL, wire format, and an auth header. A tenant can configure several. Written through POST /api/v1/siem/sinks, soft-deleted via is_active. Neither this table nor its writes carry livemode today.
  • siem_event — one delivery row per (published event × active sink). These are derived from audit events: SiemEventListener forwards each AuditEvent whose action matches a security-relevant prefix (SIGN_IN, TOKEN_ISSUED, ADMIN_, CLIENT_, USER_, FEDERATION_, …) to SiemService.publishEvent, which fans it out to the tenant's active sinks.

The leak, if left unswept

A mode=test token carrying tenant:siem.write could, after the flip:

  • read the live sink set — endpoint URLs and (today plaintext) auth-header values;
  • create a sink — which then receives the tenant's live sign-in / token / admin event stream, i.e. an exfiltration channel driven from a sandbox credential;
  • deactivate the live sink — silencing the tenant's real SOC feed.

That is live-data read and live-state mutation from the strictly-less-privileged credential — a direct violation of the invariant.

Why partition (and why the security_incident precedent does not apply)

A SIEM sink is a tenant-created resource (collection-valued, with a real tenant write path POST /api/v1/siem/sinks) and siem_event is an event table. That is exactly the class the S2 sweep partitions — the same shape as federation_member. SIEM was simply missed in S2a–S2i.

The security_incident "platform-live" precedent is disanalogous: that table is platform/system-generated with zero tenant write path, which is precisely why partitioning it would be wrong. siem_sink is the opposite — tenant-authored config. So SIEM follows the federation_member precedent (partition), not the security_incident one (serve-live).

Partitioning is also the product-coherent, Stripe-style outcome: a developer integrating in test mode configures a test sink and watches their sandbox event stream flow to it, while live events flow only to live sinks — and a test credential can never see or touch live sink config. The fan-out already has the signal it needs: AuditEvent carries livemode (SSO-2105), so a test-mode audit event routes to test sinks and a live one to live sinks.

Implementation implied

  • Add livemode BOOLEAN NOT NULL DEFAULT true to siem_sink and siem_event (new forward migration, (tenant_id, livemode) composite index), matching the V59 pattern. Rescope per-tenant sink uniqueness to include livemode so test and live sink names are independent.
  • Thread ModeContext through SiemService: sink reads/writes/deletes filter and stamp the caller's mode; cross-mode get-by-id / delete returns 404 (no existence leak).
  • In publishEvent, select target sinks by the source audit event's livemode, not just the tenant — so live events never land on a test sink and vice versa.
  • Pin it with a test: a mode=test caller sees none of a tenant's live sinks, cannot mutate them, and a live audit event is never delivered to a test sink.

Tracked as SSO-2151 (Blocks SSO-2146, Relates SSO-2149) — DONE, merged #2545 (V71, SiemModeSegregationIntegrationTest).

DSAR — decision: platform-live, and blocked in test mode

What the surface is

DSAR implements the GDPR Article 15 right of access (servers/product-api/src/main/kotlin/com/devnow/productapi/dsar/). POST /api/v1/dsar creates a request and dispatches DsarOrchestrator, which fans out across every registered DsarSource — product-api, the authorization hub (oauth2_authorization records for the subject), and identity-service (the user-profile row: email, given/family name, locale, status, external id) — resolves the data subject's sub, aggregates their personal data into a JSON-Lines bundle, signs a deterministic digest with the tenant's key, stores it, and exposes a one-time download at GET /api/v1/dsar/{requestId}/bundle. dsar_request and dsar_audit carry no livemode today.

Why platform-live, and why blocked rather than partitioned

Unlike a SIEM event — which is cleanly derived from a single mode-tagged audit row — a DSAR aggregates a specific human's personal data across services, and a data subject's Article 15 right is not partitioned into "test" and "live" from the subject's or a regulator's perspective. That drives two conclusions:

  1. The data is platform/compliance-live, not partitionable. A "test DSAR" that returned only livemode = false data would be an incomplete answer to a legal right of access. Worse, it is a signed bundle: if a partial sandbox export were ever mistaken for a real Article 15 response, the controller would have under-disclosed and breached GDPR. There is no coherent "sandbox data subject." So DSAR is not a partition candidate.

  2. But it must be blocked for test tokens, not served to them. This is where DSAR differs from the read-only compliance reports (SSO-2111), which serve their live evidence to any caller of the tenant. A DSAR is side-effectful over live PII: execute runs an async fan-out that reads a real person's cross-service profile and emits a downloadable bundle of their PII. Letting the strictly-less-privileged test credential trigger that — or read a live PII bundle back — breaks test mode's core promise that a sandbox key causes no real side effect and touches no live data. So the whole /api/v1/dsar/** surface (execute, status, bundle) returns a mode error for a mode=test token. Test mode inherits the tenant's live DSAR posture; it cannot operate it.

The net: DSAR is documented platform-live (its data is live by nature, never mode-partitioned) and the surface is closed to test-mode callers (because it is a side-effectful live-PII operation). That is the third outcome in the table above — distinct from both federation_member (partition) and security_incident (serve-live-to-all).

Implementation implied

  • Gate the DSAR controller (all three handlers) on ModeContext.livemode(jwt): a mode=test token is rejected with an RFC 9457 problem-detail via ProblemDetailsWriter (403 test_mode_not_supported, sanitised detail, no tenant/subject values). Prefer a small reusable mode gate over three inline checks so a future DSAR handler cannot regress.
  • Add the platform-live rationale as a resolution note in the DSAR service/controller, mirroring the security_incident note in Nis2ReportBuilder / DoraReportBuilder, so a future author does not "finish the sweep" by adding a livemode column that would be semantically wrong here.
  • Pin it with a test: a mode=test token gets the mode error on POST /api/v1/dsar, GET /api/v1/dsar/{id}, and .../bundle; a live token is unaffected.

Tracked as SSO-2152 (Blocks SSO-2146, Relates SSO-2149) — DONE, merged #2542 (DsarController.testModeRejection, DsarControllerTest; DsarRequestEntity carries no livemode by design).

Open question for the product owner (recorded, not assumed): a future DX capability could offer a sandbox DSAR over test users once the identity test-user data plane lands (S5, SSO-2147) — a self-contained Article 15 rehearsal that never touches live subjects. That is a larger product decision (what a sandbox export's legal status is, whether it is signed) and is explicitly out of scope here. The blocking decision above is the conservative default that closes the flip-blocker now; a sandbox-DSAR feature, if wanted, is additive later.

Summary

SurfaceScopesData it touchesDecisionFlip-readiness
SIEM (/api/v1/siem/**)tenant:siem.read / .writeTenant-created sink config + audit-derived delivery eventsPartition by livemode (same class as federation_member; missed in S2 sweep)Resolved → SSO-2151 done (#2545)
DSAR (/api/v1/dsar/**)tenant:dsar.execute / .readCross-service data-subject PII (GDPR Art. 15 bundle)Platform-live, and blocked for mode=test (side-effectful live-PII operation)Resolved → SSO-2152 done (#2542)

Both were hard blockers on the trustTestIssuers flip (SSO-2146): each would let a test token reach live state after the flip. Both are now resolved and merged with their pinning tests (SSO-2151 / SSO-2152), so both may be marked MET on the flip-readiness matrix. They remain latent today only because the flip is still false.

Validation boundary

This is a code-read decision: the SIEM and DSAR sources, ModeContext, AuditEvent's livemode column, the S2 sweep migrations (V59V68), and the security_incident platform-live precedent (SSO-2111) were read directly to ground each call. What is not proven here is the runtime behaviour after the flip — confirming that a live mode=test token is accepted and then correctly denied/partitioned at these surfaces needs the staging round-trip that SSO-2146 already owns as the flip's required gate (mint a mode=test client via POST /api/v1/applications, then exercise /api/v1/siem/** and /api/v1/dsar/**). The implementation tickets carry the pinning tests; this page settles only which outcome each surface must take.