#225: read-trail master switch and written purpose limitation
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
This commit is contained in:
parent
2bdb0ec2cf
commit
7ce6467385
@ -4,6 +4,7 @@ import request from 'supertest';
|
|||||||
import { afterAll, afterEach, beforeAll, describe, expect, it, vi } from 'vitest';
|
import { afterAll, afterEach, beforeAll, describe, expect, it, vi } from 'vitest';
|
||||||
|
|
||||||
import { ClockService } from '../common/clock.service';
|
import { ClockService } from '../common/clock.service';
|
||||||
|
import { InstanceSettingsService } from '../settings/instance-settings.service';
|
||||||
import { createTestApp, sessionCookieOf } from '../testing/test-app';
|
import { createTestApp, sessionCookieOf } from '../testing/test-app';
|
||||||
import { createTestPrisma, grantOwnerAdmin, hasTestDb, uniqueSuffix } from '../testing/test-db';
|
import { createTestPrisma, grantOwnerAdmin, hasTestDb, uniqueSuffix } from '../testing/test-db';
|
||||||
import { UsersService } from '../users/users.service';
|
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);
|
await users.markEmailVerified(owner.id);
|
||||||
ownerId = 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();
|
ownerCookie = await login();
|
||||||
|
|
||||||
const pond = await prisma.pond.create({
|
const pond = await prisma.pond.create({
|
||||||
@ -85,6 +88,7 @@ describe.skipIf(!hasTestDb)('read-trail dedup window (e2e, issue #223)', () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
afterAll(async () => {
|
afterAll(async () => {
|
||||||
|
await prisma.instanceSetting.deleteMany({ where: { key: 'readTrail.enabled' } });
|
||||||
await prisma.readEvent.deleteMany({ where: { pondId } });
|
await prisma.readEvent.deleteMany({ where: { pondId } });
|
||||||
await prisma.attachment.deleteMany({ where: { pondId } });
|
await prisma.attachment.deleteMany({ where: { pondId } });
|
||||||
await prisma.roleGrant.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);
|
await users.markEmailVerified(owner.id);
|
||||||
ownerId = 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(
|
ownerCookie = sessionCookieOf(
|
||||||
await api()
|
await api()
|
||||||
.post('/api/v1/auth/login')
|
.post('/api/v1/auth/login')
|
||||||
@ -131,7 +134,9 @@ describe.skipIf(!hasTestDb)('read-access trail (e2e, issue #222)', () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
afterAll(async () => {
|
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.readEvent.deleteMany({ where: { pondId } });
|
||||||
await prisma.conversionJob.deleteMany({ where: { ownerId } });
|
await prisma.conversionJob.deleteMany({ where: { ownerId } });
|
||||||
await prisma.apiToken.deleteMany({ where: { userId: ownerId } });
|
await prisma.apiToken.deleteMany({ where: { userId: ownerId } });
|
||||||
|
|||||||
@ -26,9 +26,10 @@ export class ReadTrailModule implements OnModuleInit {
|
|||||||
constructor(
|
constructor(
|
||||||
private readonly scheduler: SchedulerService,
|
private readonly scheduler: SchedulerService,
|
||||||
private readonly maintenance: ReadTrailMaintenanceService,
|
private readonly maintenance: ReadTrailMaintenanceService,
|
||||||
|
private readonly trail: ReadTrailService,
|
||||||
) {}
|
) {}
|
||||||
|
|
||||||
onModuleInit(): void {
|
async onModuleInit(): Promise<void> {
|
||||||
this.scheduler.register({
|
this.scheduler.register({
|
||||||
name: 'read-trail-maintenance',
|
name: 'read-trail-maintenance',
|
||||||
cadenceSeconds: READ_TRAIL_MAINTENANCE_CADENCE_SECONDS,
|
cadenceSeconds: READ_TRAIL_MAINTENANCE_CADENCE_SECONDS,
|
||||||
@ -36,5 +37,8 @@ export class ReadTrailModule implements OnModuleInit {
|
|||||||
await this.maintenance.run();
|
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);
|
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> {
|
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 { actorId, sessionKey, pageId, pondId, channel, details } = event;
|
||||||
const windowSeconds = (await this.settings.get('readTrail.dedupWindowMinutes')) * 60;
|
const windowSeconds = (await this.settings.get('readTrail.dedupWindowMinutes')) * 60;
|
||||||
const dedupKey = `${sessionKey}:${pageId ?? '-'}:${channel}`;
|
const dedupKey = `${sessionKey}:${pageId ?? '-'}:${channel}`;
|
||||||
|
|||||||
@ -51,6 +51,14 @@ export const INSTANCE_SETTINGS = {
|
|||||||
// bounded. PENDING rows — including failed-but-retryable ones — are
|
// bounded. PENDING rows — including failed-but-retryable ones — are
|
||||||
// never touched; the retry loop owns them.
|
// never touched; the retry loop owns them.
|
||||||
'mail.outboxRetentionDays': z.number().int().min(1).default(30),
|
'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
|
// Read-trail dedup window (issue #223, ADR 0023): one event per
|
||||||
// (session, page, channel) within an aligned window of this many minutes.
|
// (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
|
// 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.
|
nobody can read is not evidence — the path exists; a panel does not.
|
||||||
- **Growth measured**: ~1 MB per 1000 events including indexes.
|
- **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
|
## Consequences
|
||||||
|
|
||||||
- The scope limit is the feature's strongest argument in the works-council
|
- 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
|
- **Read-access trail** (issue #222, ADR 0023): reads of pages with
|
||||||
`classification = vs_nfd` land as `read_events` rows — only classified
|
`classification = vs_nfd` land as `read_events` rows — only classified
|
||||||
pages, which is what keeps the purpose limitation defensible (variant A).
|
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:
|
The instrumented channels, and the emission point of each:
|
||||||
- `page_view` — authenticated SPA state fetch (`GET /pages/:id`, the
|
- `page_view` — authenticated SPA state fetch (`GET /pages/:id`, the
|
||||||
by-slug variant), the rendered read view (`/read/...`), the public JSON
|
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
|
- [x] Dedup-Fenster (eine Sitzung + eine Seite innerhalb N Minuten = ein
|
||||||
Ereignis), sonst erzeugt Yjs-Sync eine Ereignisflut · 2 AT · #223
|
Ereignis), sonst erzeugt Yjs-Sync eine Ereignisflut · 2 AT · #223
|
||||||
- [x] Getrennte Tabelle mit eigener Retention und Partitionierung · 2 AT · #224
|
- [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
|
Vorteil über den Aufwand hinaus: Die Zweckbindung ist sauber begründbar
|
||||||
(„nur eingestufte Inhalte"), was die Personalrats-Diskussion beim Kunden
|
(„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). |
|
| `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). |
|
| `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). |
|
| `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.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. |
|
| `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. |
|
| `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
|
(#199, fail-closed beim Download). Keine Inhalts- oder
|
||||||
Backup-Verschlüsselung, kein eigenes Schlüsselmanagement — bewusst
|
Backup-Verschlüsselung, kein eigenes Schlüsselmanagement — bewusst
|
||||||
(§52 VSA).
|
(§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