dorfteich/docs/architecture/adr/0023-read-access-audit-trail.md
Claude Fable 5 2bdb0ec2cf
Some checks failed
CI / Lint, typecheck, test (pull_request) Successful in 6m30s
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) Failing after 2s
#224: read-trail storage — partitioning, retention, admin query path
Convert read_events to monthly RANGE partitions on occurred_at, with a
DEFAULT partition as safety net: a lagging maintenance job must never
turn the trail's hard-failure semantics into an outage for classified
reads. The dedup unique pair (#223) moves to per-partition indexes
(PostgreSQL cannot carry it on the parent); a bucket spanning a month
boundary may record one duplicate — over-recording is acceptable, gaps
are not.

New daily job read-trail-maintenance (job-count fence 9 -> 10) creates
months ahead — each with its dedup index — and applies the trail's own
retention readTrail.retentionDays (default 365, deliberately independent
of audit.retentionDays): whole expired months are DROPped without
scanning, remainders deleted by range, every run audited as
read_trail.pruned (catalogue v1.2; the fence regex now admits an
underscore namespace).

Site-Admin query path GET /admin/system/read-events answers "who read
page X" and "what did user Y read" within a period — API-only by
design, documented. Growth measured and documented in data-model.md:
~1 MB per 1000 events including indexes.

Tests: retention pruning + audited deletion + admin queries on the
shared database; the partitioned shape, per-partition P2002 dedup,
months-ahead creation and DROP-based pruning against a fresh database
built by the real migration chain.

Refs #224.

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

129 lines
6.7 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

# 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.
## 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.
## 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.