dorfteich/docs/architecture/adr/0023-read-access-audit-trail.md
Claude Fable 5 fd4fd60c99
Some checks failed
CI / Lint, typecheck, test (pull_request) Successful in 6m29s
CI / Auth e2e pack (pull_request) Failing after 5s
CI / Import/export fidelity gate (pull_request) Has been skipped
CI / Build container images (pull_request) Successful in 2m55s
#223: dedup window for the read trail
One event per (session, page, channel) within an aligned window of
readTrail.dedupWindowMinutes (default 5): buckets are
floor(epoch / windowSeconds), and a unique (dedup_key, window_bucket)
pair collapses concurrent duplicates race-free at insert time — the
first access in a window is always recorded, a later duplicate lands on
the unique violation and is skipped quietly (a skipped duplicate is not
a gap; only real write failures still abort the read). Each row carries
windowSeconds, so the evidence states it represents a window, never a
request count.

Reconnects within a window stay one event; a new session records again
even for the same user; channels never collapse into each other; the
page-less attachment key uses the documented `-` placeholder. Load
evidence: 30 collab-token renewals inside one window produce exactly one
event (test), bounding a live editing session at ~12 events/hour/page.

Window semantics documented in ADR 0023, the VS-NfD security
documentation (#228) and as a hardening-guide line for the new setting
(care rule: same PR).

Refs #223.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01AUtYMxwTCMHG9mVHnwbFg8
2026-07-31 12:13:21 +02:00

5.7 KiB
Raw Blame History

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 (1820 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 (810 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.

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.