Compare commits

...

1 Commits

Author SHA1 Message Date
7ce6467385 #225: read-trail master switch and written purpose limitation
Some checks failed
CI / Lint, typecheck, test (pull_request) Failing after 5s
CI / Build container images (pull_request) Has been skipped
CI / Auth e2e pack (pull_request) Has been skipped
CI / Import/export fidelity gate (pull_request) Has been skipped
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 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01AUtYMxwTCMHG9mVHnwbFg8
2026-07-31 12:33:04 +02:00
11 changed files with 242 additions and 3 deletions

View File

@ -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 } });

View File

@ -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: '<p>x</p>', 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);
}
});
});

View File

@ -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 } });

View File

@ -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<void> {
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();
}
}

View File

@ -84,7 +84,32 @@ 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. Must never fail the boot:
* healthz-only environments come up without a reachable database. */
async announceState(): Promise<void> {
let enabled: boolean;
try {
enabled = await this.settings.get('readTrail.enabled');
} catch {
this.logger.warn('read_trail: switch position unknown at startup (settings unavailable)');
return;
}
if (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<void> {
// 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}`;

View File

@ -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

View File

@ -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

View File

@ -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

View File

@ -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 · 12 AT · #225
- [x] Abschaltbar, Zweckbindung dokumentiert · 12 AT · #225
Vorteil über den Aufwand hinaus: Die Zweckbindung ist sauber begründbar
(„nur eingestufte Inhalte"), was die Personalrats-Diskussion beim Kunden

View File

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

View File

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