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 V59–V68) 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/**, scopestenant:siem.read/tenant:siem.write. - DSAR (GDPR Article 15 data-subject access) —
/api/v1/dsar/**, scopestenant: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:
| Outcome | What it means | Precedent |
|---|---|---|
| Partition | The 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 blocked | The 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 throughPOST /api/v1/siem/sinks, soft-deleted viais_active. Neither this table nor its writes carrylivemodetoday.siem_event— one delivery row per (published event × active sink). These are derived from audit events:SiemEventListenerforwards eachAuditEventwhose action matches a security-relevant prefix (SIGN_IN,TOKEN_ISSUED,ADMIN_,CLIENT_,USER_,FEDERATION_, …) toSiemService.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 truetosiem_sinkandsiem_event(new forward migration,(tenant_id, livemode)composite index), matching theV59pattern. Rescope per-tenant sink uniqueness to includelivemodeso test and live sink names are independent. - Thread
ModeContextthroughSiemService: sink reads/writes/deletes filter and stamp the caller's mode; cross-mode get-by-id / delete returns404(no existence leak). - In
publishEvent, select target sinks by the source audit event'slivemode, not just the tenant — so live events never land on a test sink and vice versa. - Pin it with a test: a
mode=testcaller 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:
-
The data is platform/compliance-live, not partitionable. A "test DSAR" that returned only
livemode = falsedata 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. -
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:
executeruns 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 amode=testtoken. 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): amode=testtoken is rejected with an RFC 9457 problem-detail viaProblemDetailsWriter(403 test_mode_not_supported, sanitiseddetail, 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_incidentnote inNis2ReportBuilder/DoraReportBuilder, so a future author does not "finish the sweep" by adding alivemodecolumn that would be semantically wrong here. - Pin it with a test: a
mode=testtoken gets the mode error onPOST /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
| Surface | Scopes | Data it touches | Decision | Flip-readiness |
|---|---|---|---|---|
SIEM (/api/v1/siem/**) | tenant:siem.read / .write | Tenant-created sink config + audit-derived delivery events | Partition by livemode (same class as federation_member; missed in S2 sweep) | Resolved → SSO-2151 done (#2545) |
DSAR (/api/v1/dsar/**) | tenant:dsar.execute / .read | Cross-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 (V59–V68), 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.