All checks were successful
CI / Auth e2e pack (pull_request) Successful in 8m34s
CI / Import/export fidelity gate (pull_request) Successful in 57s
CI / Lint, typecheck, test (pull_request) Successful in 6m19s
CI / Build container images (pull_request) Successful in 1m14s
CD / Build and push images (push) Successful in 17s
CD / Deploy to Test (push) Successful in 15s
CD / Smoke tests against Test (push) Successful in 1m16s
CD / Promote to Int (push) Successful in 12s
CI / Lint, typecheck, test (push) Successful in 6m25s
CI / Build container images (push) Has been skipped
CI / Auth e2e pack (push) Successful in 8m24s
CI / Import/export fidelity gate (push) Successful in 59s
Stefan reviewed and accepted all nine VS-NfD ADRs one by one. Two adjustments from the review: ADR 0021 decision 3 now states the #216 refinement in the decision itself (PAT/feed-token issuance stays available to IdP-authenticated sessions — API authorization under its own switches, not interactive sign-in) instead of contradicting the later Decisions section; and the ADR 0020 dual-verify window will be removed early (issue #296) rather than waiting for its stated expiry. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01AUtYMxwTCMHG9mVHnwbFg8
145 lines
7.5 KiB
Markdown
145 lines
7.5 KiB
Markdown
# ADR 0023: Read-access audit trail limited to classified content
|
||
|
||
- Status: accepted (2026-07-31)
|
||
- Date: 2026-07-29
|
||
|
||
## Context
|
||
|
||
The existing audit trail (`audit_log`, issue #86) is deliberately scoped to
|
||
"who changed access or configuration", and its own service comment states
|
||
that content activity stays log-only. There is no record of _reads_.
|
||
|
||
For "operable in an approved environment", read logging is not a mandatory
|
||
product feature — evidence collection can be a platform function. In
|
||
practice platform logging cannot answer the question that matters: a proxy
|
||
log knows URLs, not classifications, so it cannot say which _classified_
|
||
page was read. Leistungsbeschreibungen tend to list this as a must.
|
||
|
||
Two variants were considered. Variant B logs all reads (18–20 AT) and
|
||
brings volume, latency and retention problems, plus the requirement that no
|
||
event may be lost. Variant A logs reads of classified pages only (8–10 AT).
|
||
|
||
A live editing session is the volume hazard: Yjs sync means continuous
|
||
traffic per open document.
|
||
|
||
## Decision
|
||
|
||
**Variant A: read events are recorded only for pages with
|
||
`classification = VS_NFD`.** Requires ADR 0022.
|
||
|
||
1. **All read channels are instrumented**, or the feature is worthless:
|
||
SPA page fetch, public API GET, attachment download, export, no-JS
|
||
shell, collab WS join.
|
||
2. **A dedup window** (session + page + channel within N minutes = one
|
||
event) keeps Yjs sync from flooding the trail. The recorded event states
|
||
that it represents a window, so the evidence is not overread.
|
||
3. **Its own table**, with time partitioning and its own retention period —
|
||
independent of `audit_log`, because volume, purpose and legal basis all
|
||
differ.
|
||
4. **Failure is not silent.** `AuditService` swallows write failures by
|
||
design; for classified reads a lost event is a gap in evidence, so the
|
||
behaviour is either hard failure or an explicitly documented
|
||
degradation. Which one is decided in #222 and stated in the security
|
||
documentation.
|
||
5. **Switchable, with a written purpose limitation.** Off means nothing is
|
||
written anywhere; a startup log line states the trail is off so a gap is
|
||
never ambiguous.
|
||
6. **Variant B is rejected**, and the rejection is recorded rather than
|
||
left open: unbounded volume, the no-loss requirement, and a purpose
|
||
limitation that is much harder to defend.
|
||
|
||
## Decisions taken in #222
|
||
|
||
- **Failure mode: hard failure.** A failed `read_events` write aborts the
|
||
read with the ordinary 500. The alternative (documented degradation) was
|
||
rejected: the reader retrying is cheap, a gap in evidence is not. This is
|
||
the deliberate contrast to `AuditService`, which swallows failures.
|
||
- **Collab WS join: the api emits at token issuance.** The collab server
|
||
keeps zero permission/classification context; tokens live 60 s, so a live
|
||
session re-requests one per minute — per-minute granularity for free,
|
||
which the dedup window (#223) collapses. The trail therefore proves
|
||
"held a live connection during this window", not individual sync frames.
|
||
- **Scope: full-content channels.** Content _fragments_ (search snippets,
|
||
task-overview rows, backlink titles) and the Atom feeds (off in the
|
||
reference configuration) are deliberately not instrumented — recorded in
|
||
`security.md` §Logging as a residual.
|
||
- **Session key vocabulary:** `session:<id>` (cookie), `token:<id>` (PAT —
|
||
also the MCP `read_page` path), `job:<id>` (background builds such as the
|
||
account data export), `anon` (anonymous reader on a public grant).
|
||
|
||
## Decisions taken in #223
|
||
|
||
- **Aligned dedup windows.** One event per (session, page, channel) within
|
||
an aligned window of `readTrail.dedupWindowMinutes` (default 5):
|
||
buckets are `floor(epoch / windowSeconds)`, and a unique
|
||
(dedupKey, windowBucket) pair collapses concurrent duplicates race-free
|
||
at insert time. Sliding windows (measured from the first event) were
|
||
rejected — they need a read-before-write and lose the race-safety of the
|
||
plain unique insert. Consequence: two reads just across a bucket
|
||
boundary yield two events; over-recording is acceptable, gaps are not.
|
||
- **A skipped duplicate is not a gap** — the unique-violation path returns
|
||
quietly (debug log), only real write failures abort the read.
|
||
- **Each row states its window** (`windowSeconds`), so the evidence reads
|
||
as "accessed at least once in these N minutes", never as a request
|
||
count. What the trail can prove about a live session: the per-minute
|
||
collab-token renewals collapse to one `collab_join` event per window —
|
||
presence during the window, not activity within it.
|
||
- **Anonymous readers share one `anon` key**: all anonymous reads of a page
|
||
through one channel inside a window are one event. Deliberate — telling
|
||
anonymous browsers apart would require fingerprinting (IP/UA), which the
|
||
purpose limitation (#225) rules out.
|
||
|
||
## Decisions taken in #224
|
||
|
||
- **Monthly RANGE partitions** on `occurred_at`, plus a DEFAULT partition
|
||
as a safety net: a lagging maintenance job must never make classified
|
||
reads fail (the trail write is hard-failing — an ops miss must not
|
||
become an outage). Retention DROPs whole expired months without
|
||
scanning; the pruning run is audited (`read_trail.pruned`) under the
|
||
trail's own `readTrail.retentionDays` (default 365).
|
||
- **The dedup unique pair lives per partition** (PostgreSQL cannot carry
|
||
it on the parent without the partition key). A dedup bucket spanning a
|
||
month boundary can therefore record one duplicate — over-recording is
|
||
acceptable, gaps are not.
|
||
- **Query path is API-only** (`GET /admin/system/read-events`, Site
|
||
Admin): the trail is an examiner's tool, not a daily screen. Evidence
|
||
nobody can read is not evidence — the path exists; a panel does not.
|
||
- **Growth measured**: ~1 MB per 1000 events including indexes.
|
||
|
||
## Decisions taken in #225
|
||
|
||
- **Default OFF** (`readTrail.enabled`): read logging is employee
|
||
monitoring in a works council's eyes — an ordinary instance must not
|
||
surveil reads. The VS-NfD reference configuration turns it on together
|
||
with the written purpose limitation
|
||
(`docs/vs-nfd/60-sicherheitsdokumentation.md` §7, referenced from the
|
||
hardening guide).
|
||
- **Off means silence everywhere** — no row, no stdout line; the api
|
||
announces the switch position once per boot, so an eventless trail is
|
||
never ambiguous (a gap reads as "was off", not "was lost").
|
||
- The purpose limitation names what is recorded, why, who may read it
|
||
(Site Admin, API-only), for how long, and what it may NOT be used for
|
||
(no performance or behaviour monitoring). Its technical anchor is the
|
||
variant-A scope: unmarked content produces no trace.
|
||
|
||
## Consequences
|
||
|
||
- The scope limit is the feature's strongest argument in the works-council
|
||
discussion at the customer: only classified content is observed.
|
||
- Reads of unclassified content are not evidenced. Deliberate, and it goes
|
||
into the residual-risk list.
|
||
- The collab WS join is the awkward channel: authorization there is
|
||
token-only (signature plus `pageId` match) and the collab server has no
|
||
permission context. Either the event carries what the token asserts, or
|
||
the api emits it at token issuance. #222 decides and documents; the
|
||
choice affects what the trail can prove about live sessions.
|
||
- Retention and partition maintenance are operational obligations that
|
||
must ship with the feature, not after it.
|
||
- Classification at read time is stored with the event: a later
|
||
reclassification must not rewrite history.
|
||
|
||
## Implementing issues
|
||
|
||
#222 (instrumentation), #223 (dedup window), #224 (table, retention,
|
||
partitioning), #225 (switch + purpose limitation). Depends on #204/#205.
|