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.