All checks were successful
CI / Lint, typecheck, test (pull_request) Successful in 6m2s
CI / Build container images (pull_request) Successful in 4m1s
CI / Auth e2e pack (pull_request) Successful in 8m26s
CI / Import/export fidelity gate (pull_request) Successful in 58s
CD / Build and push images (push) Successful in 18s
CD / Deploy to Test (push) Successful in 15s
CD / Smoke tests against Test (push) Successful in 1m23s
CD / Promote to Int (push) Successful in 12s
CI / Lint, typecheck, test (push) Successful in 6m6s
CI / Build container images (push) Has been skipped
CI / Auth e2e pack (push) Successful in 8m25s
CI / Import/export fidelity gate (push) Successful in 1m0s
New instance switch readTrail.enabled, default OFF: read logging is employee monitoring in a works council's eyes — an ordinary instance must not surveil reads. Off means no event is written ANYWHERE (no row, no stdout line, verified by test); the api announces the switch position once per boot, so an eventless trail is never ambiguous — a gap reads as "was off", never "was lost". The written purpose limitation ships as section 7 of the VS-NfD security documentation (#228): what is recorded (no content, no titles, no IPs, no fingerprinting), why (evidence for reads of marked content only — variant A is the technical anchor of the promise), who may read it (Site Admin, API-only), for how long (readTrail.retentionDays, audited pruning), and what it may NOT be used for (no performance or behaviour monitoring). The hardening guide's reference configuration turns the trail on (reference value true) and points to that text; the existing trail suites now enable the switch explicitly. Refs #225. 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: proposed
|
||
- 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.
|