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.