Compare commits
1 Commits
7ce6467385
...
5de398c435
| Author | SHA1 | Date | |
|---|---|---|---|
| 5de398c435 |
@ -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,24 @@ 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. */
|
||||||
|
async announceState(): Promise<void> {
|
||||||
|
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<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