Compare commits
1 Commits
4af5e6e81f
...
7ce6467385
| Author | SHA1 | Date | |
|---|---|---|---|
| 7ce6467385 |
@ -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 } });
|
||||
|
||||
132
apps/api/src/read-trail/read-trail-switch.e2e.db.test.ts
Normal file
132
apps/api/src/read-trail/read-trail-switch.e2e.db.test.ts
Normal 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);
|
||||
}
|
||||
});
|
||||
});
|
||||
@ -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 } });
|
||||
|
||||
@ -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();
|
||||
}
|
||||
}
|
||||
|
||||
@ -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}`;
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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. |
|
||||
|
||||
@ -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.
|
||||
|
||||
Loading…
Reference in New Issue
Block a user