From 5de398c4351b35ad469aa55251f9ce4e743246a8 Mon Sep 17 00:00:00 2001 From: Claude Fable 5 Date: Fri, 31 Jul 2026 12:24:57 +0200 Subject: [PATCH] #225: read-trail master switch and written purpose limitation MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 Claude-Session: https://claude.ai/code/session_01AUtYMxwTCMHG9mVHnwbFg8 --- .../read-trail-dedup.e2e.db.test.ts | 4 + .../read-trail-switch.e2e.db.test.ts | 132 ++++++++++++++++++ .../src/read-trail/read-trail.e2e.db.test.ts | 7 +- apps/api/src/read-trail/read-trail.module.ts | 6 +- apps/api/src/read-trail/read-trail.service.ts | 17 +++ .../src/settings/instance-settings.service.ts | 8 ++ .../adr/0023-read-access-audit-trail.md | 16 +++ docs/architecture/security.md | 5 + docs/vs-nfd/20-massnahmenplan.md | 2 +- docs/vs-nfd/50-haertungsleitfaden.md | 1 + docs/vs-nfd/60-sicherheitsdokumentation.md | 39 ++++++ 11 files changed, 234 insertions(+), 3 deletions(-) create mode 100644 apps/api/src/read-trail/read-trail-switch.e2e.db.test.ts diff --git a/apps/api/src/read-trail/read-trail-dedup.e2e.db.test.ts b/apps/api/src/read-trail/read-trail-dedup.e2e.db.test.ts index 8b05f79..68e9b56 100644 --- a/apps/api/src/read-trail/read-trail-dedup.e2e.db.test.ts +++ b/apps/api/src/read-trail/read-trail-dedup.e2e.db.test.ts @@ -4,6 +4,7 @@ import request from 'supertest'; import { afterAll, afterEach, beforeAll, describe, expect, it, vi } from 'vitest'; import { ClockService } from '../common/clock.service'; +import { InstanceSettingsService } from '../settings/instance-settings.service'; import { createTestApp, sessionCookieOf } from '../testing/test-app'; import { createTestPrisma, grantOwnerAdmin, hasTestDb, uniqueSuffix } from '../testing/test-db'; import { UsersService } from '../users/users.service'; @@ -54,6 +55,8 @@ describe.skipIf(!hasTestDb)('read-trail dedup window (e2e, issue #223)', () => { }); await users.markEmailVerified(owner.id); ownerId = owner.id; + // The trail ships OFF by default (#225) — this suite needs it on. + await app.get(InstanceSettingsService).set('readTrail.enabled', true, ownerId); ownerCookie = await login(); const pond = await prisma.pond.create({ @@ -85,6 +88,7 @@ describe.skipIf(!hasTestDb)('read-trail dedup window (e2e, issue #223)', () => { }); afterAll(async () => { + await prisma.instanceSetting.deleteMany({ where: { key: 'readTrail.enabled' } }); await prisma.readEvent.deleteMany({ where: { pondId } }); await prisma.attachment.deleteMany({ where: { pondId } }); await prisma.roleGrant.deleteMany({ where: { pondId } }); diff --git a/apps/api/src/read-trail/read-trail-switch.e2e.db.test.ts b/apps/api/src/read-trail/read-trail-switch.e2e.db.test.ts new file mode 100644 index 0000000..3987608 --- /dev/null +++ b/apps/api/src/read-trail/read-trail-switch.e2e.db.test.ts @@ -0,0 +1,132 @@ +import { INestApplication } from '@nestjs/common'; +import { PrismaClient } from '@prisma/client'; +import { PinoLogger } from 'nestjs-pino'; +import request from 'supertest'; +import { afterAll, beforeAll, describe, expect, it, vi } from 'vitest'; + +import { InstanceSettingsService } from '../settings/instance-settings.service'; +import { createTestApp, sessionCookieOf } from '../testing/test-app'; +import { createTestPrisma, grantOwnerAdmin, hasTestDb, uniqueSuffix } from '../testing/test-db'; +import { UsersService } from '../users/users.service'; + +import { ReadTrailService } from './read-trail.service'; + +/** + * The read-trail master switch (issue #225, ADR 0023): OFF is the default + * and means no event is written ANYWHERE — no row, no stdout line; ON + * restores the full #222 semantics. The switch position is announced so a + * silent trail is never ambiguous. + */ +describe.skipIf(!hasTestDb)('read-trail switch (e2e, issue #225)', () => { + let app: INestApplication; + let prisma: PrismaClient; + const suffix = uniqueSuffix(); + const password = 'schalter zeugen 123'; + + let ownerId: string; + let ownerCookie: string; + let pondId: string; + let classifiedId: string; + + const api = () => request(app.getHttpServer()); + + beforeAll(async () => { + prisma = createTestPrisma(); + await prisma.rateLimit.deleteMany({}); + // No leftover switch row: this suite tests the DEFAULT (off). + await prisma.instanceSetting.deleteMany({ where: { key: 'readTrail.enabled' } }); + app = await createTestApp(); + const users = app.get(UsersService); + + const owner = await users.createUser({ + username: `switch-owner-${suffix}`, + email: `switch-owner-${suffix}@example.test`, + displayName: 'Switch Owner', + password, + locale: 'en', + }); + await users.markEmailVerified(owner.id); + ownerId = owner.id; + ownerCookie = sessionCookieOf( + await api() + .post('/api/v1/auth/login') + .send({ usernameOrEmail: `switch-owner-${suffix}`, password }) + .expect(200), + ); + + const pond = await prisma.pond.create({ + data: { slug: `switch-pond-${suffix}`, name: 'Switch Pond', type: 'SHARED', ownerId }, + }); + pondId = pond.id; + await grantOwnerAdmin(prisma, pondId, ownerId); + const page = await prisma.page.create({ + data: { + pondId, + slug: `classified-${suffix}`, + title: 'Classified', + classification: 'VS_NFD', + createdBy: ownerId, + sortKey: 'a0', + ydocState: new Uint8Array(), + contentCache: { + create: { plainText: 'x', markdown: 'x', html: '

x

', outline: [] }, + }, + }, + }); + classifiedId = page.id; + }); + + afterAll(async () => { + await prisma.instanceSetting.deleteMany({ where: { key: 'readTrail.enabled' } }); + await prisma.readEvent.deleteMany({ where: { pondId } }); + await prisma.roleGrant.deleteMany({ where: { pondId } }); + await prisma.pageContentCache.deleteMany({ where: { page: { pondId } } }); + await prisma.page.deleteMany({ where: { pondId } }); + await prisma.pond.deleteMany({ where: { id: pondId } }); + await prisma.user.deleteMany({ where: { id: ownerId } }); + await prisma.$disconnect(); + await app.close(); + }); + + it('is OFF by default: a classified read writes nothing — no row, no stdout line', async () => { + const trail = app.get(ReadTrailService); + const logger = (trail as unknown as { logger: PinoLogger }).logger; + const infoSpy = vi.spyOn(logger, 'info'); + try { + await api().get(`/api/v1/pages/${classifiedId}`).set('Cookie', ownerCookie).expect(200); + } finally { + infoSpy.mockRestore(); + } + expect(await prisma.readEvent.count({ where: { pondId } })).toBe(0); + expect(infoSpy).not.toHaveBeenCalled(); + }); + + it('records again the moment the switch turns on', async () => { + await app.get(InstanceSettingsService).set('readTrail.enabled', true, ownerId); + try { + await api().get(`/api/v1/pages/${classifiedId}`).set('Cookie', ownerCookie).expect(200); + expect(await prisma.readEvent.count({ where: { pondId } })).toBe(1); + } finally { + await app.get(InstanceSettingsService).set('readTrail.enabled', false, ownerId); + } + }); + + it('announces the switch position so silence is never ambiguous', async () => { + const trail = app.get(ReadTrailService); + const logger = (trail as unknown as { logger: PinoLogger }).logger; + const warnSpy = vi.spyOn(logger, 'warn'); + const infoSpy = vi.spyOn(logger, 'info'); + try { + await trail.announceState(); // switch is off after the previous test + expect(warnSpy).toHaveBeenCalledWith(expect.stringContaining('NOT evidenced')); + + await app.get(InstanceSettingsService).set('readTrail.enabled', true, ownerId); + await trail.announceState(); + expect(infoSpy).toHaveBeenCalledWith(expect.stringContaining('enabled')); + } finally { + warnSpy.mockRestore(); + infoSpy.mockRestore(); + await app.get(InstanceSettingsService).set('readTrail.enabled', false, ownerId); + } + }); +}); diff --git a/apps/api/src/read-trail/read-trail.e2e.db.test.ts b/apps/api/src/read-trail/read-trail.e2e.db.test.ts index b9def60..f33dc0b 100644 --- a/apps/api/src/read-trail/read-trail.e2e.db.test.ts +++ b/apps/api/src/read-trail/read-trail.e2e.db.test.ts @@ -74,6 +74,9 @@ describe.skipIf(!hasTestDb)('read-access trail (e2e, issue #222)', () => { }); await users.markEmailVerified(owner.id); ownerId = owner.id; + // The trail ships OFF by default (#225) — these suites test the + // instrumented channels, so they run with the switch on. + await app.get(InstanceSettingsService).set('readTrail.enabled', true, ownerId); ownerCookie = sessionCookieOf( await api() .post('/api/v1/auth/login') @@ -131,7 +134,9 @@ describe.skipIf(!hasTestDb)('read-access trail (e2e, issue #222)', () => { }); afterAll(async () => { - await prisma.instanceSetting.deleteMany({ where: { key: 'api.enabled' } }); + await prisma.instanceSetting.deleteMany({ + where: { key: { in: ['api.enabled', 'readTrail.enabled'] } }, + }); await prisma.readEvent.deleteMany({ where: { pondId } }); await prisma.conversionJob.deleteMany({ where: { ownerId } }); await prisma.apiToken.deleteMany({ where: { userId: ownerId } }); diff --git a/apps/api/src/read-trail/read-trail.module.ts b/apps/api/src/read-trail/read-trail.module.ts index 3f812de..577d3a2 100644 --- a/apps/api/src/read-trail/read-trail.module.ts +++ b/apps/api/src/read-trail/read-trail.module.ts @@ -26,9 +26,10 @@ export class ReadTrailModule implements OnModuleInit { constructor( private readonly scheduler: SchedulerService, private readonly maintenance: ReadTrailMaintenanceService, + private readonly trail: ReadTrailService, ) {} - onModuleInit(): void { + async onModuleInit(): Promise { this.scheduler.register({ name: 'read-trail-maintenance', cadenceSeconds: READ_TRAIL_MAINTENANCE_CADENCE_SECONDS, @@ -36,5 +37,8 @@ export class ReadTrailModule implements OnModuleInit { await this.maintenance.run(); }, }); + // One line per boot stating the switch position (issue #225) — a silent + // trail must never be ambiguous. + await this.trail.announceState(); } } diff --git a/apps/api/src/read-trail/read-trail.service.ts b/apps/api/src/read-trail/read-trail.service.ts index de0845f..42e9e73 100644 --- a/apps/api/src/read-trail/read-trail.service.ts +++ b/apps/api/src/read-trail/read-trail.service.ts @@ -84,7 +84,24 @@ export class ReadTrailService { this.logger.setContext(ReadTrailService.name); } + /** States the switch position once at startup (issue #225): a trail + * without events must be distinguishable from a disabled trail — the log + * line makes the gap explainable either way. */ + async announceState(): Promise { + if (await this.settings.get('readTrail.enabled')) { + this.logger.info('read_trail: enabled — reads of classified pages are recorded'); + } else { + this.logger.warn( + 'read_trail: disabled — reads of classified pages are NOT evidenced (readTrail.enabled)', + ); + } + } + async record(event: ReadEventInput): Promise { + // The master switch (issue #225): off means nothing is written anywhere + // — no row, no stdout line. The startup announcement above is what keeps + // the resulting silence unambiguous. + if (!(await this.settings.get('readTrail.enabled'))) return; const { actorId, sessionKey, pageId, pondId, channel, details } = event; const windowSeconds = (await this.settings.get('readTrail.dedupWindowMinutes')) * 60; const dedupKey = `${sessionKey}:${pageId ?? '-'}:${channel}`; diff --git a/apps/api/src/settings/instance-settings.service.ts b/apps/api/src/settings/instance-settings.service.ts index 11a84ec..aebcaee 100644 --- a/apps/api/src/settings/instance-settings.service.ts +++ b/apps/api/src/settings/instance-settings.service.ts @@ -51,6 +51,14 @@ export const INSTANCE_SETTINGS = { // bounded. PENDING rows — including failed-but-retryable ones — are // never touched; the retry loop owns them. 'mail.outboxRetentionDays': z.number().int().min(1).default(30), + // Read-trail master switch (issue #225, ADR 0023). Default OFF: read + // logging is employee monitoring in a works council's eyes — an ordinary + // instance must not surveil reads. The VS-NfD reference configuration + // (#227) turns it on together with the written purpose limitation + // (60-sicherheitsdokumentation.md §7). Off means NO event is written + // anywhere, including stdout; the api states the switch position once at + // startup, so a gap in the evidence is never ambiguous. + 'readTrail.enabled': z.boolean().default(false), // Read-trail dedup window (issue #223, ADR 0023): one event per // (session, page, channel) within an aligned window of this many minutes. // 5 minutes keeps a live Yjs session (collab tokens every 60 s) at a diff --git a/docs/architecture/adr/0023-read-access-audit-trail.md b/docs/architecture/adr/0023-read-access-audit-trail.md index 38979c5..da3947b 100644 --- a/docs/architecture/adr/0023-read-access-audit-trail.md +++ b/docs/architecture/adr/0023-read-access-audit-trail.md @@ -106,6 +106,22 @@ traffic per open document. 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 diff --git a/docs/architecture/security.md b/docs/architecture/security.md index 882a16b..5d0a780 100644 --- a/docs/architecture/security.md +++ b/docs/architecture/security.md @@ -214,6 +214,11 @@ scan docker-archive:/image.tar -o cyclonedx-json` respectively - **Read-access trail** (issue #222, ADR 0023): reads of pages with `classification = vs_nfd` land as `read_events` rows — only classified pages, which is what keeps the purpose limitation defensible (variant A). + Master switch `readTrail.enabled`, **default off** (#225): off means no + event is written anywhere, including stdout, and the api announces the + switch position once per boot so an eventless trail is never ambiguous. + The written purpose limitation lives in + `docs/vs-nfd/60-sicherheitsdokumentation.md` §7. The instrumented channels, and the emission point of each: - `page_view` — authenticated SPA state fetch (`GET /pages/:id`, the by-slug variant), the rendered read view (`/read/...`), the public JSON diff --git a/docs/vs-nfd/20-massnahmenplan.md b/docs/vs-nfd/20-massnahmenplan.md index f252195..bfe503f 100644 --- a/docs/vs-nfd/20-massnahmenplan.md +++ b/docs/vs-nfd/20-massnahmenplan.md @@ -137,7 +137,7 @@ Protokolliert werden Lesezugriffe **ausschließlich** auf Seiten mit - [x] Dedup-Fenster (eine Sitzung + eine Seite innerhalb N Minuten = ein Ereignis), sonst erzeugt Yjs-Sync eine Ereignisflut · 2 AT · #223 - [x] Getrennte Tabelle mit eigener Retention und Partitionierung · 2 AT · #224 -- [ ] Abschaltbar, Zweckbindung dokumentiert · 1–2 AT · #225 +- [x] Abschaltbar, Zweckbindung dokumentiert · 1–2 AT · #225 Vorteil über den Aufwand hinaus: Die Zweckbindung ist sauber begründbar („nur eingestufte Inhalte"), was die Personalrats-Diskussion beim Kunden diff --git a/docs/vs-nfd/50-haertungsleitfaden.md b/docs/vs-nfd/50-haertungsleitfaden.md index 6ee05a7..445e9e3 100644 --- a/docs/vs-nfd/50-haertungsleitfaden.md +++ b/docs/vs-nfd/50-haertungsleitfaden.md @@ -39,6 +39,7 @@ Settings-Cache ist in-process (operations.md). | `upload.allowedExtensions` | nur das dienstlich Nötige (z. B. `pdf`) | Standardliste | jede zusätzliche Endung vergrößert die Menge nicht prüfbarer Binärformate im Bestand. Bilder sind davon unabhängig immer erlaubt (Magic-Byte-geprüft). | | `backup.nextcloud.enabled` | `false` | `false` | „Backup nur lokal": kein Anwendungs-Upload von Restore-Sets zu Drittdiensten. Fernspiegel regelt ausschließlich die Deploy-Allowlist (1.2). | | `trash.retentionDays`, `audit.retentionDays`, `conversion.payloadRetentionDays`, `mail.outboxRetentionDays` | Defaults (30/365/30/30) | ebd. | Aufbewahrung bewusst begrenzt; Verkürzung nach Betreiber-Löschkonzept zulässig (Betriebshandbuch §5). | +| `readTrail.enabled` | `true` | `false` | **explizit setzen** — der Lesetrail (#222–#225) evidenziert Lesezugriffe auf eingestufte Seiten; Default aus, weil Lesebeobachtung mitbestimmungsrelevant ist. Einschalten NUR zusammen mit der Zweckbindung (Sicherheitsdokumentation §7); die api meldet die Schalterstellung beim Start. | | `readTrail.dedupWindowMinutes` | Default (5) | `5` | Dedup-Fenster des Lesetrails (#223): je (Sitzung, Seite, Kanal) ein Ereignis pro Fenster — begrenzt die Ereignisflut einer Live-Sitzung auf ~12/h. Kleiner = feineres Protokoll und mehr Zeilen; Änderung mit dem Zweckbindungs-Dokument (#225) abstimmen. | | `readTrail.retentionDays` | Default (365) | `365` | eigene Aufbewahrung des Lesetrails (#224), bewusst getrennt von `audit.retentionDays`; Löschläufe sind selbst auditiert (`read_trail.pruned`). Dauer mit der Zweckbindung (#225) und dem Betreiber-Löschkonzept abstimmen. | | `legal.imprint`, `legal.privacyPolicy` | befüllt | leer | Betreiberpflicht; leere Seiten zeigen einen Warnbanner. | diff --git a/docs/vs-nfd/60-sicherheitsdokumentation.md b/docs/vs-nfd/60-sicherheitsdokumentation.md index bb686f5..e17766b 100644 --- a/docs/vs-nfd/60-sicherheitsdokumentation.md +++ b/docs/vs-nfd/60-sicherheitsdokumentation.md @@ -282,3 +282,42 @@ HKDF-Zweckableitung (#188), SHA-256-Integritätshashes für Attachments (#199, fail-closed beim Download). Keine Inhalts- oder Backup-Verschlüsselung, kein eigenes Schlüsselmanagement — bewusst (§52 VSA). + +## 7 Lesetrail: Zweckbindung (Issue #225, ADR 0023) + +Der Lesetrail ist abschaltbar (`readTrail.enabled`, **Default: aus**); +die Referenzkonfiguration (Härtungsleitfaden §1.1) schaltet ihn ein. +Aus heißt: es wird **nirgends** ein Ereignis geschrieben — keine +Tabellenzeile, keine Logzeile; die api meldet die Schalterstellung +einmal beim Start, damit ein ereignisloser Trail nie zweideutig ist. +Diese Zweckbindung ist Teil der Betriebsdokumentation und gegenüber +der Personalvertretung offenzulegen. + +**Was aufgezeichnet wird:** je (Sitzung, Seite, Kanal) und Dedup-Fenster +(§3.5) ein Ereignis mit Zeitstempel, Konto-Id (oder dem Marker `anon`), +Sitzungsschlüssel, Seiten- und Teich-Id, Kanal und der Einstufung zum +Lesezeitpunkt. **Kein** Seiteninhalt, **keine** Titel, **keine** +IP-Adressen, **kein** Fingerprinting anonymer Leser. + +**Warum:** ausschließlich Beweissicherung für Lesezugriffe auf **als +VS-NfD gekennzeichnete** Inhalte (§52-konforme Nachvollziehbarkeit: +„welche eingestufte Seite wurde wann über welchen Kanal gelesen") — +Plattform-Logs kennen URLs, nicht Einstufungen. Zugriffe auf nicht +eingestufte Inhalte werden **nie** erfasst (Variante A, ADR 0023). + +**Wer lesen darf:** ausschließlich Site-Admins über +`GET /admin/system/read-events` (kein UI-Panel — Prüfwerkzeug, kein +Alltagsbildschirm). Organisatorische Beschränkung auf den +Sicherheitsbeauftragten: Betreibersache (Betriebshandbuch §6 +Rollentrennung). + +**Wie lange:** `readTrail.retentionDays` (Default 365 Tage); Löschläufe +sind selbst auditiert (`read_trail.pruned`), sodass jede Lücke erklärbar +ist. + +**Wofür NICHT:** keine Leistungs- oder Verhaltenskontrolle der +Beschäftigten, keine Auswertung von Arbeitsmustern, keine +Anwesenheitskontrolle, keine Weitergabe außerhalb des +Sicherheitsvorfalls- bzw. Prüfkontexts. Die Beschränkung auf +eingestufte Inhalte ist die technische Absicherung dieser Zusage: was +nicht gekennzeichnet ist, erzeugt keine Spur.