diff --git a/apps/api/prisma/migrations/20260731150000_read_events/migration.sql b/apps/api/prisma/migrations/20260731150000_read_events/migration.sql new file mode 100644 index 0000000..92867a4 --- /dev/null +++ b/apps/api/prisma/migrations/20260731150000_read_events/migration.sql @@ -0,0 +1,23 @@ +-- #222 (ADR 0023): read-access trail for classified pages. Its own table — +-- volume, purpose and legal basis differ from audit_log. No foreign keys: +-- evidence must survive page purges and hard user deletions unchanged. +-- Partitioning and retention follow in #224. +CREATE TABLE "read_events" ( + "id" TEXT NOT NULL, + "occurred_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + "actor_id" TEXT, + "session_key" TEXT NOT NULL, + "page_id" TEXT, + "pond_id" TEXT NOT NULL, + "channel" TEXT NOT NULL, + "classification" TEXT NOT NULL, + "details" JSONB, + + CONSTRAINT "read_events_pkey" PRIMARY KEY ("id") +); + +CREATE INDEX "read_events_page_id_occurred_at_idx" ON "read_events"("page_id", "occurred_at"); + +CREATE INDEX "read_events_actor_id_occurred_at_idx" ON "read_events"("actor_id", "occurred_at"); + +CREATE INDEX "read_events_occurred_at_idx" ON "read_events"("occurred_at"); diff --git a/apps/api/prisma/schema.prisma b/apps/api/prisma/schema.prisma index da2d37c..2ad741a 100644 --- a/apps/api/prisma/schema.prisma +++ b/apps/api/prisma/schema.prisma @@ -91,6 +91,37 @@ model AuditEntry { @@map("audit_log") } +/// Read-access trail for classified pages (issue #222, ADR 0023): one row per +/// read of a `VS_NFD` page, per channel. Separate from `audit_log` because +/// volume, purpose and legal basis all differ. Deliberately WITHOUT foreign +/// keys: evidence must survive a page purge and a hard user deletion — the +/// ids stay as recorded (pseudonymous uuids), history is never rewritten. +model ReadEvent { + id String @id @default(uuid()) + occurredAt DateTime @default(now()) @map("occurred_at") + /// Null = anonymous reader (public grant); `sessionKey` still names the + /// browsing session, so the anonymous marker is explicit, not an accident. + actorId String? @map("actor_id") + /// `session:` for cookie sessions, `token:` for PATs, `job:` + /// for background builds (account data export), `anon` for anonymous + /// visitors — the dedup-window key basis (#223). + sessionKey String @map("session_key") + pageId String? @map("page_id") + pondId String @map("pond_id") + /// Which read surface fired: `page_view` | `no_js_shell` | `public_api` | + /// `attachment` | `export` | `collab_join` (READ_CHANNELS union in code). + channel String + /// Classification at read time — a later reclassification must not + /// rewrite history (ADR 0023). + classification String + details Json? + + @@index([pageId, occurredAt]) + @@index([actorId, occurredAt]) + @@index([occurredAt]) + @@map("read_events") +} + /// Threaded page comments (issue #91, data-model.md §Comments). Threads are /// one level deep: roots carry the optional document anchor and the resolve /// state, replies reference the root via `parentId`. Purging a page cascades diff --git a/apps/api/src/app.module.ts b/apps/api/src/app.module.ts index d040d53..5072a4d 100644 --- a/apps/api/src/app.module.ts +++ b/apps/api/src/app.module.ts @@ -32,6 +32,7 @@ import { PrismaModule } from './prisma/prisma.module'; import { PublicApiModule } from './public-api/public-api.module'; import { PublicModule } from './public/public.module'; import { RateLimitModule } from './rate-limit/rate-limit.module'; +import { ReadTrailModule } from './read-trail/read-trail.module'; import { SearchModule } from './search/search.module'; import { SettingsModule } from './settings/settings.module'; import { SetupModule } from './setup/setup.module'; @@ -47,6 +48,7 @@ import { VersionsModule } from './versions/versions.module'; ConfigModule, PrismaModule, AuditModule, + ReadTrailModule, RateLimitModule, MailModule, SettingsModule, diff --git a/apps/api/src/files/attachment-integrity.e2e.db.test.ts b/apps/api/src/files/attachment-integrity.e2e.db.test.ts index 0f2fcc2..aa8972a 100644 --- a/apps/api/src/files/attachment-integrity.e2e.db.test.ts +++ b/apps/api/src/files/attachment-integrity.e2e.db.test.ts @@ -90,14 +90,16 @@ describe.skipIf(!hasTestDb)('attachment integrity (e2e, issue #199)', () => { const { id, bytes } = await uploadPng('will-be-tampered'); // Intact: the download succeeds and streams the exact bytes. - const intact = await files.download(null, id); + const intact = await files.download(null, id, { actorId: null, sessionKey: 'anon' }); const chunks: Buffer[] = []; for await (const chunk of intact.stream) chunks.push(chunk as Buffer); expect(Buffer.concat(chunks).equals(bytes)).toBe(true); // Tampered on disk (row untouched): fail closed with the dedicated code. await storage.save(pondId, id, pngBuffer('evil-replacement')); - const failure = await files.download(null, id).catch((error: unknown) => error); + const failure = await files + .download(null, id, { actorId: null, sessionKey: 'anon' }) + .catch((error: unknown) => error); expect(failure).toBeInstanceOf(InternalServerErrorException); expect((failure as InternalServerErrorException).getResponse()).toMatchObject({ code: 'attachment_integrity_failure', @@ -124,7 +126,10 @@ describe.skipIf(!hasTestDb)('attachment integrity (e2e, issue #199)', () => { await storage.delete(pondId, unreadable.id); // A null-hash row is served unverified (pre-#199 status quo). - const unverified = await files.download(null, readable.id); + const unverified = await files.download(null, readable.id, { + actorId: null, + sessionKey: 'anon', + }); expect(unverified.attachment.sha256).toBeNull(); const first = await files.backfillHashes(); diff --git a/apps/api/src/files/files.controller.ts b/apps/api/src/files/files.controller.ts index d9d6683..4ab9f3f 100644 --- a/apps/api/src/files/files.controller.ts +++ b/apps/api/src/files/files.controller.ts @@ -27,6 +27,7 @@ import { RequiresPagePermission, RequiresPondRole, } from '../permissions/permission.decorators'; +import { readActorOf } from '../read-trail/read-actor'; import { FilesService } from './files.service'; @@ -93,6 +94,7 @@ export class FilesController { const { attachment, stream, inline, downloadName } = await this.files.download( request.user ?? null, fileId, + readActorOf(request), ); response.set('X-Content-Type-Options', 'nosniff'); // Attachments are immutable — a new upload always gets a new id. diff --git a/apps/api/src/files/files.service.ts b/apps/api/src/files/files.service.ts index 2be721e..a052aa8 100644 --- a/apps/api/src/files/files.service.ts +++ b/apps/api/src/files/files.service.ts @@ -26,6 +26,7 @@ import { PinoLogger } from 'nestjs-pino'; import { AuditService } from '../audit/audit.service'; import { PrismaService } from '../prisma/prisma.service'; import { QuotaService } from '../quotas/quota.service'; +import { ReadTrailService, type ReadActor } from '../read-trail/read-trail.service'; import { InstanceSettingsService } from '../settings/instance-settings.service'; import { FileStorageService } from './file-storage.service'; @@ -62,6 +63,7 @@ export class FilesService { private readonly storage: FileStorageService, private readonly settings: InstanceSettingsService, private readonly audit: AuditService, + private readonly readTrail: ReadTrailService, private readonly logger: PinoLogger, ) { this.logger.setContext(FilesService.name); @@ -230,7 +232,7 @@ export class FilesService { * the nightly backfill reaches them) are served unverified — that is the * pre-#199 status quo, not a downgrade. */ - async download(_user: User | null, id: string): Promise { + async download(_user: User | null, id: string, read: ReadActor): Promise { const attachment = await this.prisma.attachment.findFirst({ where: { id } }); if (!attachment) throw new NotFoundException(); const buffer = await this.storage.read(attachment.pondId, attachment.id).catch(() => null); @@ -248,6 +250,19 @@ export class FilesService { } } const classification = await this.effectiveClassification(attachment); + // Read trail (issue #222): a download whose effective classification is + // vs_nfd (#212 semantics — page level, pond max when page-less) is a read + // of classified content. `pageId` may be null for pond-level files; the + // attachment id in `details` keeps the object identifiable. + if (classification === 'vs_nfd') { + await this.readTrail.record({ + ...read, + pageId: attachment.pageId, + pondId: attachment.pondId, + channel: 'attachment', + details: { attachmentId: attachment.id }, + }); + } return { attachment, stream: Readable.from(buffer), diff --git a/apps/api/src/import-export/data-export.service.ts b/apps/api/src/import-export/data-export.service.ts index b863cf9..758a531 100644 --- a/apps/api/src/import-export/data-export.service.ts +++ b/apps/api/src/import-export/data-export.service.ts @@ -95,7 +95,14 @@ export class DataExportService implements DataExportProcessor { orderBy: { slug: 'asc' }, }); for (const pond of ponds) { - await this.exports.appendPondMarkdown(archive, user, pond, `ponds/${pond.slug}/`); + // The build runs in the conversion worker, outside any request — the + // read-trail session key (#222) is the job itself: `job:` names the + // one download this build feeds, so the dedup window (#223) has a + // stable, honest key. + await this.exports.appendPondMarkdown(archive, user, pond, `ponds/${pond.slug}/`, { + actorId: user.id, + sessionKey: `job:${job.id}`, + }); } await archive.finalize(); diff --git a/apps/api/src/import-export/export.controller.ts b/apps/api/src/import-export/export.controller.ts index c5d4047..a0c4450 100644 --- a/apps/api/src/import-export/export.controller.ts +++ b/apps/api/src/import-export/export.controller.ts @@ -5,6 +5,7 @@ import type { Response } from 'express'; import { AuthedRequest } from '../auth/auth.guard'; import { ZodValidationPipe } from '../common/zod-validation.pipe'; import { RequiresPagePermission, RequiresPondRole } from '../permissions/permission.decorators'; +import { readActorOf } from '../read-trail/read-actor'; import { ExportService } from './export.service'; @@ -27,7 +28,7 @@ export class ExportController { @Req() request: AuthedRequest, @Res() response: Response, ): Promise { - await this.exports.streamPondMarkdownZip(request.user!, pondId, response); + await this.exports.streamPondMarkdownZip(request.user!, pondId, response, readActorOf(request)); } /** Enqueue a `.docx`/`.odt` export of one page; poll `GET /jobs/:id` and @@ -39,6 +40,11 @@ export class ExportController { @Body(new ZodValidationPipe(pageExportInputSchema)) input: PageExportInput, @Req() request: AuthedRequest, ): Promise { - return this.exports.enqueuePageExport(request.user!, pageId, input.format); + return this.exports.enqueuePageExport( + request.user!, + pageId, + input.format, + readActorOf(request), + ); } } diff --git a/apps/api/src/import-export/export.service.ts b/apps/api/src/import-export/export.service.ts index 31053b7..2cd0ca5 100644 --- a/apps/api/src/import-export/export.service.ts +++ b/apps/api/src/import-export/export.service.ts @@ -24,6 +24,7 @@ import { PermissionService } from '../permissions/permission.service'; import { PluginFallbackRenderer } from '../plugins/plugin-fallback-renderer'; import { PluginsService } from '../plugins/plugins.service'; import { PrismaService } from '../prisma/prisma.service'; +import { ReadTrailService, type ReadActor } from '../read-trail/read-trail.service'; import { markClassifiedMarkdown } from './classified-markdown'; import { ConversionJobService } from './conversion-job.service'; @@ -52,6 +53,7 @@ export class ExportService { private readonly plugins: PluginsService, private readonly fallbacks: PluginFallbackRenderer, private readonly config: AppConfig, + private readonly readTrail: ReadTrailService, private readonly logger: PinoLogger, ) { this.logger.setContext(ExportService.name); @@ -64,7 +66,12 @@ export class ExportService { * already checked the requester may see the pond; here we filter to the pages * they may actually read. */ - async streamPondMarkdownZip(user: User, pondId: string, res: Response): Promise { + async streamPondMarkdownZip( + user: User, + pondId: string, + res: Response, + read: ReadActor, + ): Promise { const pond = await this.prisma.pond.findFirst({ where: { id: pondId, deletedAt: null } }); if (!pond) throw new NotFoundException(); @@ -77,7 +84,7 @@ export class ExportService { res.destroy(error); }); archive.pipe(res); - await this.appendPondMarkdown(archive, user, pond); + await this.appendPondMarkdown(archive, user, pond, '', read); await archive.finalize(); } @@ -93,7 +100,8 @@ export class ExportService { archive: archiver.Archiver, user: User, pond: { id: string; slug: string }, - prefix = '', + prefix: string, + read: ReadActor, ): Promise { const pondId = pond.id; const pages = await this.prisma.page.findMany({ @@ -113,6 +121,21 @@ export class ExportService { const readablePages = pages.filter((page) => readableIds.has(page.id)); const readableSlugs = new Set(readablePages.map((page) => page.slug)); + // Read trail (issue #222): the ZIP is a bulk-egress channel — one event + // per classified page it will contain, recorded BEFORE any classified + // bytes enter the stream, so a failed write aborts the download while the + // evidence is still complete (ADR 0023). + for (const page of readablePages) { + if (page.classification !== 'VS_NFD') continue; + await this.readTrail.record({ + ...read, + pageId: page.id, + pondId, + channel: 'export', + details: { format: 'markdown_zip' }, + }); + } + // Every image referenced by a readable page — resolved to attachments that // still exist in this pond, so the media directory matches the rewrites. const referenced = new Set(); @@ -222,14 +245,18 @@ export class ExportService { user: User, pageId: string, format: ExportFormat, + read: ReadActor, ): Promise { - if (format === 'pdf') return this.enqueuePdfExport(user, pageId); + if (format === 'pdf') return this.enqueuePdfExport(user, pageId, read); const page = await this.prisma.page.findFirst({ where: { id: pageId, deletedAt: null }, include: { contentCache: { select: { markdown: true } } }, }); if (!page) throw new NotFoundException(); + // Read trail (issue #222): recorded at enqueue — the user's action; the + // worker's later conversion is machinery, not a second read. + await this.recordClassifiedExport(page, read, format); // Plugin blocks degrade to their fallback text and sections to quoted // blocks first (#79) — GFM knows neither construct, and pandoc would @@ -265,7 +292,27 @@ export class ExportService { * the job input; the worker sends it to Gotenberg (`html → pdf`). Building it * up front keeps the job a plain byte→byte render the worker can retry. */ - private async enqueuePdfExport(user: User, pageId: string): Promise { + /** One `export` event for a classified page leaving as a document (#222). */ + private async recordClassifiedExport( + page: { id: string; pondId: string; classification: string }, + read: ReadActor, + format: ExportFormat, + ): Promise { + if (page.classification !== 'VS_NFD') return; + await this.readTrail.record({ + ...read, + pageId: page.id, + pondId: page.pondId, + channel: 'export', + details: { format }, + }); + } + + private async enqueuePdfExport( + user: User, + pageId: string, + read: ReadActor, + ): Promise { const page = await this.prisma.page.findFirst({ where: { id: pageId, deletedAt: null }, include: { @@ -274,6 +321,7 @@ export class ExportService { }, }); if (!page) throw new NotFoundException(); + await this.recordClassifiedExport(page, read, 'pdf'); const fonts = pondSettingsSchema.parse(page.pond.settings ?? {}).fonts; // Plugin blocks first become their best static form (#79: stored SVG diff --git a/apps/api/src/mcp/mcp.service.ts b/apps/api/src/mcp/mcp.service.ts index d1bb66b..9a332d6 100644 --- a/apps/api/src/mcp/mcp.service.ts +++ b/apps/api/src/mcp/mcp.service.ts @@ -112,7 +112,9 @@ export class McpService { case 'read_page': await this.assertPondExposed(input.pond!, token); await this.requirePage(user, input.pond!, input.page!, 'read'); - return asJson(await this.publicApi.getPage(user, input.pond!, input.page!)); + // The shared getPage also records the read-trail event (#222) — + // MCP reads of classified pages are covered by the same emission. + return asJson(await this.publicApi.getPage(user, token, input.pond!, input.page!)); case 'search': { if (input.pond) await this.assertPondExposed(input.pond, token); diff --git a/apps/api/src/pages/pages.controller.ts b/apps/api/src/pages/pages.controller.ts index c3d2196..e475256 100644 --- a/apps/api/src/pages/pages.controller.ts +++ b/apps/api/src/pages/pages.controller.ts @@ -39,6 +39,7 @@ import { RequiresPagePermission, RequiresPondRole, } from '../permissions/permission.decorators'; +import { readActorOf } from '../read-trail/read-actor'; import { PagesService } from './pages.service'; /** Page CRUD and Yjs state persistence (issue #23; permission guard since #52). */ @@ -75,7 +76,7 @@ export class PagesController { @Get('pages/:id') @RequiresPagePermission('read', { idParam: 'id' }) async getState(@Param('id') id: string, @Req() request: AuthedRequest): Promise { - return this.pages.getState(request.user!, id); + return this.pages.getState(request.user!, id, readActorOf(request)); } /** Toggles one task-list checkbox (issue #153). Applied asynchronously @@ -106,7 +107,7 @@ export class PagesController { @Param('id') id: string, @Req() request: AuthedRequest, ): Promise { - return this.pages.issueCollabToken(request.user ?? null, id); + return this.pages.issueCollabToken(request.user ?? null, id, readActorOf(request)); } /** Markdown export (issue #30) — downloads `.md`. */ @@ -117,7 +118,11 @@ export class PagesController { @Req() request: AuthedRequest, @Res({ passthrough: true }) response: Response, ): Promise { - const { slug, markdown } = await this.pages.exportMarkdown(request.user!, id); + const { slug, markdown } = await this.pages.exportMarkdown( + request.user!, + id, + readActorOf(request), + ); response.set('Content-Type', 'text/markdown; charset=utf-8'); response.set('Content-Disposition', `attachment; filename="${slug}.md"`); return markdown; @@ -133,7 +138,7 @@ export class PagesController { @Param('slug') slug: string, @Req() request: AuthedRequest, ): Promise { - return this.pages.getStateBySlug(request.user!, pondId, slug); + return this.pages.getStateBySlug(request.user!, pondId, slug, readActorOf(request)); } /** diff --git a/apps/api/src/pages/pages.service.ts b/apps/api/src/pages/pages.service.ts index 2e77f6e..adc42d9 100644 --- a/apps/api/src/pages/pages.service.ts +++ b/apps/api/src/pages/pages.service.ts @@ -38,6 +38,11 @@ import { AuditService } from '../audit/audit.service'; import { AppConfig } from '../config/app-config.service'; import { PermissionService } from '../permissions/permission.service'; import { PrismaService } from '../prisma/prisma.service'; +import { + ReadTrailService, + type ReadActor, + type ReadChannel, +} from '../read-trail/read-trail.service'; import { InstanceSettingsService } from '../settings/instance-settings.service'; import { WatchesService } from '../watches/watches.service'; import { SearchProvider } from '../search/search.provider'; @@ -80,10 +85,29 @@ export class PagesService { private readonly watches: WatchesService, private readonly settings: InstanceSettingsService, private readonly audit: AuditService, + private readonly readTrail: ReadTrailService, ) { this.logger.setContext(PagesService.name); } + /** Records the read-trail event for a classified page (issue #222) — a + * no-op for unclassified pages, a hard failure when recording fails. */ + private async recordRead( + page: Pick, + read: ReadActor, + channel: ReadChannel, + details?: Record, + ): Promise { + if (page.classification !== 'VS_NFD') return; + await this.readTrail.record({ + ...read, + pageId: page.id, + pondId: page.pondId, + channel, + details, + }); + } + viewOf(page: Page): PageView { return { id: page.id, @@ -400,8 +424,9 @@ export class PagesService { AND from_page_id IN (SELECT id FROM pages WHERE pond_id = ${pondId})`; } - async getState(_user: User, id: string): Promise { + async getState(_user: User, id: string, read: ReadActor): Promise { const page = await this.findLivePage(id); + await this.recordRead(page, read, 'page_view'); return this.stateViewOf(page); } @@ -415,12 +440,21 @@ export class PagesService { * guard has already granted read access via a `public` grant, so they receive * an `ro` token with a `null` subject. */ - async issueCollabToken(user: User | null, id: string): Promise { + async issueCollabToken( + user: User | null, + id: string, + read: ReadActor, + ): Promise { const page = await this.findLivePage(id); const canWrite = await this.permissions.canAccessPage(user, page, 'write'); const mode = canWrite ? 'rw' : 'ro'; const userId = user?.id ?? null; + // Token issuance is the api-side proxy for the collab WS join (ADR 0023): + // the collab server has no permission context, and the 60 s token TTL + // makes a live session re-request one per minute — per-minute granularity + // the dedup window (#223) then collapses. + await this.recordRead(page, read, 'collab_join', { mode }); const token = await signCollabToken( { userId, pageId: page.id, mode }, this.config.env.COLLAB_TOKEN_SECRET, @@ -432,9 +466,15 @@ export class PagesService { } /** The trashed-page hint for editors (issue #31) moved into the guard. */ - async getStateBySlug(_user: User, pondId: string, slug: string): Promise { + async getStateBySlug( + _user: User, + pondId: string, + slug: string, + read: ReadActor, + ): Promise { const page = await this.prisma.page.findFirst({ where: { pondId, slug, deletedAt: null } }); if (!page) throw new NotFoundException(); + await this.recordRead(page, read, 'page_view'); return this.stateViewOf(page); } @@ -648,8 +688,16 @@ export class PagesService { * `page_content_cache.markdown` (refreshed on every state save, #23) * rather than re-decoding the Yjs state, so export always matches what * the app itself considers the page's current Markdown representation. */ - async exportMarkdown(_user: User, id: string): Promise<{ slug: string; markdown: string }> { + async exportMarkdown( + _user: User, + id: string, + read: ReadActor, + // The plugin content route reuses this markdown path but is a page view + // in the trail's terms (issue #222), not a download. + channel: ReadChannel = 'export', + ): Promise<{ slug: string; markdown: string }> { const page = await this.findLivePage(id); + await this.recordRead(page, read, channel); const cache = await this.prisma.pageContentCache.findUnique({ where: { pageId: page.id } }); return { slug: page.slug, markdown: cache?.markdown ?? '' }; } diff --git a/apps/api/src/pages/plugin-api.controller.ts b/apps/api/src/pages/plugin-api.controller.ts index 7a8a476..dedb355 100644 --- a/apps/api/src/pages/plugin-api.controller.ts +++ b/apps/api/src/pages/plugin-api.controller.ts @@ -4,6 +4,7 @@ import type { PluginPageContent, PluginPageMeta, PluginPageSummary } from '@dorf import { AuthedRequest } from '../auth/auth.guard'; import { RequiresPagePermission, RequiresPondRole } from '../permissions/permission.decorators'; +import { readActorOf } from '../read-trail/read-actor'; import { PagesService } from './pages.service'; @@ -45,7 +46,12 @@ export class PluginApiController { @Param('pageId') pageId: string, @Req() request: AuthedRequest, ): Promise { - const { markdown } = await this.pages.exportMarkdown(request.user!, pageId); + const { markdown } = await this.pages.exportMarkdown( + request.user!, + pageId, + readActorOf(request), + 'page_view', + ); return { markdown }; } diff --git a/apps/api/src/public-api/public-api.controller.ts b/apps/api/src/public-api/public-api.controller.ts index 200be74..73fdeb2 100644 --- a/apps/api/src/public-api/public-api.controller.ts +++ b/apps/api/src/public-api/public-api.controller.ts @@ -119,7 +119,7 @@ export class PublicApiController { @Param('pageSlug') pageSlug: string, @Req() request: PublicApiRequest, ): Promise { - return this.publicApi.getPage(request.user!, pondSlug, pageSlug); + return this.publicApi.getPage(request.user!, request.apiToken!, pondSlug, pageSlug); } @Patch('ponds/:pondSlug/pages/:pageSlug') @@ -169,7 +169,10 @@ export class PublicApiController { @Res() res: Response, ): Promise { const pond = await this.publicApi.requirePond(pondSlug); - await this.exports.streamPondMarkdownZip(request.user!, pond.id, res); + await this.exports.streamPondMarkdownZip(request.user!, pond.id, res, { + actorId: request.user!.id, + sessionKey: `token:${request.apiToken!.id}`, + }); } @Get('ponds/:pondSlug/labels') diff --git a/apps/api/src/public-api/public-api.service.ts b/apps/api/src/public-api/public-api.service.ts index 03dff78..a64c993 100644 --- a/apps/api/src/public-api/public-api.service.ts +++ b/apps/api/src/public-api/public-api.service.ts @@ -38,6 +38,7 @@ import { PagesService } from '../pages/pages.service'; import { PermissionService } from '../permissions/permission.service'; import { PondsService } from '../ponds/ponds.service'; import { PrismaService } from '../prisma/prisma.service'; +import { ReadTrailService } from '../read-trail/read-trail.service'; import { SearchProvider } from '../search/search.provider'; import { VersionsService } from '../versions/versions.service'; import { ApiTokensService } from './api-tokens.service'; @@ -63,6 +64,7 @@ export class PublicApiService { private readonly tokens: ApiTokensService, private readonly audit: AuditService, private readonly permissions: PermissionService, + private readonly readTrail: ReadTrailService, ) {} async me(user: User, token: ApiToken): Promise { @@ -119,8 +121,24 @@ export class PublicApiService { })); } - async getPage(user: User, pondSlug: string, pageSlug: string): Promise { + async getPage( + user: User, + token: ApiToken, + pondSlug: string, + pageSlug: string, + ): Promise { const page = await this.requirePage(pondSlug, pageSlug); + // Read trail (issue #222): the PAT is the session in this channel's + // terms — `token:` keys the dedup window (#223). + if (page.classification === 'VS_NFD') { + await this.readTrail.record({ + actorId: user.id, + sessionKey: `token:${token.id}`, + pageId: page.id, + pondId: page.pondId, + channel: 'public_api', + }); + } const [cache, pageLabels, labelNames, parent] = await Promise.all([ this.prisma.pageContentCache.findUnique({ where: { pageId: page.id } }), this.prisma.pageLabel.findMany({ where: { pageId: page.id }, select: { labelId: true } }), @@ -154,7 +172,8 @@ export class PublicApiService { const state = this.stateFromMarkdown(input.markdown); const page = await this.pages.createWithState(user, pond.id, input.title, state, parentId); await this.auditWrite(user, token, 'page_created', page.id); - return this.getPage(user, pondSlug, page.slug); + // The write echo returns the full page — getPage records the read (#222). + return this.getPage(user, token, pondSlug, page.slug); } /** @@ -186,7 +205,7 @@ export class PublicApiService { await this.pages.moveToEnd(user, page.id, parentId); } await this.auditWrite(user, token, 'page_updated', page.id); - return this.getPage(user, pondSlug, page.slug); + return this.getPage(user, token, pondSlug, page.slug); } async deletePage(user: User, token: ApiToken, pondSlug: string, pageSlug: string): Promise { diff --git a/apps/api/src/public/public.controller.ts b/apps/api/src/public/public.controller.ts index b722b33..d88bc30 100644 --- a/apps/api/src/public/public.controller.ts +++ b/apps/api/src/public/public.controller.ts @@ -3,6 +3,7 @@ import type { PageCommentsView } from '@dorfteich/shared'; import type { Response } from 'express'; import { AuthedRequest, Public } from '../auth/auth.guard'; +import { readActorOf } from '../read-trail/read-actor'; import { InstanceSettingsService } from '../settings/instance-settings.service'; import { FeedService } from './feed.service'; import { PublicPageContent, PublicService } from './public.service'; @@ -66,7 +67,10 @@ export class PublicController { @Param('pageSlug') pageSlug: string, @Req() request: AuthedRequest, ): Promise { - return this.publicPages.content(request.user ?? null, pondSlug, pageSlug); + return this.publicPages.content(request.user ?? null, pondSlug, pageSlug, { + actor: readActorOf(request), + channel: 'page_view', + }); } @Get(':pondSlug/:pageSlug/comments') @@ -88,7 +92,13 @@ export class PublicController { @Res({ passthrough: true }) response: Response, ): Promise { const canonical = `${request.protocol}://${request.get('host') ?? ''}${request.originalUrl}`; - const html = await this.publicPages.html(request.user ?? null, pondSlug, pageSlug, canonical); + const html = await this.publicPages.html( + request.user ?? null, + pondSlug, + pageSlug, + canonical, + readActorOf(request), + ); response.set('Content-Type', 'text/html; charset=utf-8'); return html; } diff --git a/apps/api/src/public/public.service.ts b/apps/api/src/public/public.service.ts index ca1d35c..93f3396 100644 --- a/apps/api/src/public/public.service.ts +++ b/apps/api/src/public/public.service.ts @@ -8,9 +8,18 @@ import { TasksService } from '../pages/tasks.service'; import { PermissionService } from '../permissions/permission.service'; import { PluginFallbackRenderer } from '../plugins/plugin-fallback-renderer'; import { PrismaService } from '../prisma/prisma.service'; +import { ReadTrailService, type ReadActor } from '../read-trail/read-trail.service'; import { InstanceSettingsService } from '../settings/instance-settings.service'; import { escapeHtml, htmlDocument } from './html-shell'; +/** Who is reading and through which surface (issue #222, ADR 0023) — the + * controllers resolve this once; `content` and the embed expansion record + * classified pages under it. */ +export interface ReadContext { + actor: ReadActor; + channel: 'page_view' | 'no_js_shell'; +} + /** The JSON the SPA renders for an anonymous (or any) reader of a public page. */ export interface PublicPageContent { pondName: string; @@ -53,6 +62,7 @@ export class PublicService { private readonly settings: InstanceSettingsService, private readonly commentsService: CommentsService, private readonly tasks: TasksService, + private readonly readTrail: ReadTrailService, ) {} private async resolve( @@ -74,8 +84,24 @@ export class PublicService { } /** The page content for the read view (public and authenticated, issue #56). */ - async content(user: User | null, pondSlug: string, pageSlug: string): Promise { + async content( + user: User | null, + pondSlug: string, + pageSlug: string, + read: ReadContext, + ): Promise { const { pond, page } = await this.resolve(user, pondSlug, pageSlug); + // Read trail (issue #222): a classified page leaving through this surface + // is recorded before any content is assembled — a failed write aborts + // the read (ADR 0023, deliberate contrast to AuditService). + if (page.classification === 'VS_NFD') { + await this.readTrail.record({ + ...read.actor, + pageId: page.id, + pondId: pond.id, + channel: read.channel, + }); + } const cache = await this.prisma.pageContentCache.findUnique({ where: { pageId: page.id } }); // The pond's active section-style CSS travels inline — the read view loads // no plugin runtime, and the CSS passed the install gate's scoping rules. @@ -83,7 +109,7 @@ export class PublicService { // Plugin blocks render their static form (#79) and page embeds expand to the // target's rendered HTML (#135), then media is resolved once over the whole // tree. `visited` seeds with this page so an embed of self is not expanded. - const body = await this.renderBody(user, pond.id, page, 0, new Set([page.slug])); + const body = await this.renderBody(user, pond.id, page, 0, new Set([page.slug]), read); return { pondName: pond.name, pondSlug: pond.slug, @@ -109,11 +135,12 @@ export class PublicService { page: { id: string }, depth: number, visited: Set, + read: ReadContext, ): Promise { const cache = await this.prisma.pageContentCache.findUnique({ where: { pageId: page.id } }); const withFallbacks = await this.fallbacks.applyToHtml(cache?.html ?? ''); const withTasks = await this.expandTaskOverviews(withFallbacks, user, pondId, page.id); - return this.expandEmbeds(withTasks, user, pondId, depth, visited); + return this.expandEmbeds(withTasks, user, pondId, depth, visited, read); } /** Replaces each task-overview placeholder (issue #154) with the static, @@ -143,6 +170,7 @@ export class PublicService { pondId: string, depth: number, visited: Set, + read: ReadContext, ): Promise { const placeholder = /
[^<]*<\/div>/g; @@ -151,18 +179,31 @@ export class PublicService { const bare = Boolean(bareAttr); const target = await this.prisma.page.findFirst({ where: { pondId, slug, deletedAt: null }, - select: { id: true, pondId: true, slug: true, title: true }, + select: { id: true, pondId: true, slug: true, title: true, classification: true }, }); const readable = target && (await this.permissions.canAccessPage(user, target, 'read')); if (!target || !readable || depth >= PublicService.MAX_EMBED_DEPTH || visited.has(slug)) { return embedLink(slug, target?.title ?? slug); } + // An expanded embed shows the target's FULL content, so a classified + // target is a read of that page too (issue #222) — recorded under the + // host's channel. The degraded link above shows no content: no event. + if (target.classification === 'VS_NFD') { + await this.readTrail.record({ + ...read.actor, + pageId: target.id, + pondId, + channel: read.channel, + details: { embedded: true }, + }); + } const inner = await this.renderBody( user, pondId, target, depth + 1, new Set(visited).add(slug), + read, ); // A bare embed (`$[[…]]`, #146) reads as part of the host page: no // frame, no title — just the expanded content. @@ -193,8 +234,13 @@ export class PublicService { pondSlug: string, pageSlug: string, canonical: string, + actor: ReadActor, ): Promise { - const content = await this.content(user, pondSlug, pageSlug); + // `content` records the read-trail event (#222) under the shell's channel. + const content = await this.content(user, pondSlug, pageSlug, { + actor, + channel: 'no_js_shell', + }); // The VS-NfD marking renders in the same places as the SPA — above and // below the content (issue #211, ADR 0022). The no-JS shell is its own // render path, so it carries its own banner markup; unclassified pages diff --git a/apps/api/src/public/read-content.controller.ts b/apps/api/src/public/read-content.controller.ts index 227645a..f80ab36 100644 --- a/apps/api/src/public/read-content.controller.ts +++ b/apps/api/src/public/read-content.controller.ts @@ -4,6 +4,7 @@ import type { TaskOverviewPage } from '@dorfteich/shared'; import { AuthedRequest } from '../auth/auth.guard'; import { TasksService } from '../pages/tasks.service'; import { AuthenticatedOnly } from '../permissions/permission.decorators'; +import { readActorOf } from '../read-trail/read-actor'; import { PublicPageContent, PublicService } from './public.service'; /** @@ -43,6 +44,9 @@ export class ReadContentController { @Param('pageSlug') pageSlug: string, @Req() request: AuthedRequest, ): Promise { - return this.publicPages.content(request.user ?? null, pondSlug, pageSlug); + return this.publicPages.content(request.user ?? null, pondSlug, pageSlug, { + actor: readActorOf(request), + channel: 'page_view', + }); } } diff --git a/apps/api/src/read-trail/read-actor.ts b/apps/api/src/read-trail/read-actor.ts new file mode 100644 index 0000000..b832ca9 --- /dev/null +++ b/apps/api/src/read-trail/read-actor.ts @@ -0,0 +1,17 @@ +import type { ReadActor } from './read-trail.service'; + +/** + * Resolves the {@link ReadActor} of a cookie-session request (issue #222). + * Structural parameter instead of `AuthedRequest` so the read-trail module + * never imports the auth guard. PAT requests build their key directly + * (`token:`, public-api controller). + */ +export function readActorOf(request: { + user?: { id: string } | null; + sessionId?: string; +}): ReadActor { + return { + actorId: request.user?.id ?? null, + sessionKey: request.sessionId ? `session:${request.sessionId}` : 'anon', + }; +} 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 new file mode 100644 index 0000000..b9def60 --- /dev/null +++ b/apps/api/src/read-trail/read-trail.e2e.db.test.ts @@ -0,0 +1,330 @@ +import { INestApplication } from '@nestjs/common'; +import { PrismaClient } from '@prisma/client'; +import request from 'supertest'; +import { afterAll, afterEach, beforeAll, describe, expect, it, vi } from 'vitest'; + +import { PrismaService } from '../prisma/prisma.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'; + +const PNG_SIGNATURE = Buffer.from([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a]); + +/** + * The read-access trail (issue #222, ADR 0023): every read channel emits one + * `read_events` row for a `VS_NFD` page and none for an unclassified one — + * per channel, both directions. Plus the deliberate failure semantics: a + * failed trail write aborts the read (hard failure, the documented contrast + * to AuditService's swallow-and-log). + */ +describe.skipIf(!hasTestDb)('read-access trail (e2e, issue #222)', () => { + let app: INestApplication; + let prisma: PrismaClient; + const suffix = uniqueSuffix(); + const password = 'lesetrail zeugen 123'; + + let ownerId: string; + let ownerCookie: string; + let pondId: string; + let pondSlug: string; + let classifiedId: string; + let classifiedSlug: string; + let openId: string; + let openSlug: string; + let hostSlug: string; + + const api = () => request(app.getHttpServer()); + + const eventsFor = (pageId: string) => + prisma.readEvent.findMany({ where: { pageId }, orderBy: { occurredAt: 'asc' } }); + + async function makePage( + slug: string, + title: string, + html: string, + classification: 'UNCLASSIFIED' | 'VS_NFD', + ) { + return prisma.page.create({ + data: { + pondId, + slug, + title, + classification, + createdBy: ownerId, + sortKey: 'a0', + ydocState: new Uint8Array(), + contentCache: { create: { plainText: title, markdown: `# ${title}`, html, outline: [] } }, + }, + }); + } + + beforeAll(async () => { + prisma = createTestPrisma(); + await prisma.rateLimit.deleteMany({}); + app = await createTestApp(); + const users = app.get(UsersService); + + const owner = await users.createUser({ + username: `trail-owner-${suffix}`, + email: `trail-owner-${suffix}@example.test`, + displayName: 'Trail Owner', + password, + locale: 'en', + }); + await users.markEmailVerified(owner.id); + ownerId = owner.id; + ownerCookie = sessionCookieOf( + await api() + .post('/api/v1/auth/login') + .send({ usernameOrEmail: `trail-owner-${suffix}`, password }) + .expect(200), + ); + + pondSlug = `trail-pond-${suffix}`; + const pond = await prisma.pond.create({ + data: { slug: pondSlug, name: 'Trail Pond', type: 'SHARED', ownerId }, + }); + pondId = pond.id; + // Grants land as rows BEFORE any permission query touches this pond, so + // the per-pond cache first fills with them present. + await grantOwnerAdmin(prisma, pondId, ownerId); + await prisma.roleGrant.create({ + data: { + pondId, + subjectType: 'PUBLIC', + subjectId: null, + role: 'READER', + scopeType: 'POND', + scopeId: null, + effect: 'ALLOW', + createdBy: ownerId, + }, + }); + + classifiedSlug = `classified-${suffix}`; + const classified = await makePage( + classifiedSlug, + 'Classified Note', + '

Restricted content.

', + 'VS_NFD', + ); + classifiedId = classified.id; + + openSlug = `open-${suffix}`; + const open = await makePage(openSlug, 'Open Note', '

Open content.

', 'UNCLASSIFIED'); + openId = open.id; + + // An unclassified host page that transcludes the classified page — the + // expanded embed shows the target's full content (#222). + hostSlug = `host-${suffix}`; + await makePage( + hostSlug, + 'Host Page', + `

Intro.

${classifiedSlug}
`, + 'UNCLASSIFIED', + ); + }); + + afterEach(async () => { + await prisma.readEvent.deleteMany({ where: { pondId } }); + }); + + afterAll(async () => { + await prisma.instanceSetting.deleteMany({ where: { key: 'api.enabled' } }); + await prisma.readEvent.deleteMany({ where: { pondId } }); + await prisma.conversionJob.deleteMany({ where: { ownerId } }); + await prisma.apiToken.deleteMany({ where: { userId: ownerId } }); + await prisma.attachment.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('records the SPA state fetch (page_view) with actor and session key — and nothing for unclassified', async () => { + await api().get(`/api/v1/pages/${classifiedId}`).set('Cookie', ownerCookie).expect(200); + const events = await eventsFor(classifiedId); + expect(events).toHaveLength(1); + expect(events[0]!).toMatchObject({ + channel: 'page_view', + actorId: ownerId, + pondId, + classification: 'vs_nfd', + }); + expect(events[0]!.sessionKey).toMatch(/^session:/); + + await api().get(`/api/v1/pages/${openId}`).set('Cookie', ownerCookie).expect(200); + expect(await eventsFor(openId)).toHaveLength(0); + }); + + it('records the authenticated read rendering (/read) as page_view', async () => { + await api() + .get(`/api/v1/read/${pondSlug}/${classifiedSlug}`) + .set('Cookie', ownerCookie) + .expect(200); + const events = await eventsFor(classifiedId); + expect(events).toHaveLength(1); + expect(events[0]!.channel).toBe('page_view'); + + await api().get(`/api/v1/read/${pondSlug}/${openSlug}`).set('Cookie', ownerCookie).expect(200); + expect(await eventsFor(openId)).toHaveLength(0); + }); + + it('records the anonymous public JSON read with the documented anon marker', async () => { + await api().get(`/api/v1/public/${pondSlug}/${classifiedSlug}/content`).expect(200); + const events = await eventsFor(classifiedId); + expect(events).toHaveLength(1); + expect(events[0]!).toMatchObject({ channel: 'page_view', actorId: null, sessionKey: 'anon' }); + + await api().get(`/api/v1/public/${pondSlug}/${openSlug}/content`).expect(200); + expect(await eventsFor(openId)).toHaveLength(0); + }); + + it('records the no-JS shell under its own channel', async () => { + await api().get(`/api/v1/public/${pondSlug}/${classifiedSlug}`).expect(200); + const events = await eventsFor(classifiedId); + expect(events).toHaveLength(1); + expect(events[0]!.channel).toBe('no_js_shell'); + + await api().get(`/api/v1/public/${pondSlug}/${openSlug}`).expect(200); + expect(await eventsFor(openId)).toHaveLength(0); + }); + + it('records an expanded embed of a classified page inside an unclassified host', async () => { + const res = await api() + .get(`/api/v1/read/${pondSlug}/${hostSlug}`) + .set('Cookie', ownerCookie) + .expect(200); + expect(res.body.html).toContain('Restricted content.'); + const events = await eventsFor(classifiedId); + expect(events).toHaveLength(1); + expect(events[0]!).toMatchObject({ channel: 'page_view', details: { embedded: true } }); + }); + + it('records collab-token issuance (collab_join) with the granted mode', async () => { + await api() + .get(`/api/v1/pages/${classifiedId}/collab-token`) + .set('Cookie', ownerCookie) + .expect(200); + const events = await eventsFor(classifiedId); + expect(events).toHaveLength(1); + expect(events[0]!).toMatchObject({ channel: 'collab_join', details: { mode: 'rw' } }); + + await api().get(`/api/v1/pages/${openId}/collab-token`).set('Cookie', ownerCookie).expect(200); + expect(await eventsFor(openId)).toHaveLength(0); + }); + + it('records the single-page markdown download and the queued document export', async () => { + await api() + .get(`/api/v1/pages/${classifiedId}/export/markdown`) + .set('Cookie', ownerCookie) + .expect(200); + let events = await eventsFor(classifiedId); + expect(events).toHaveLength(1); + expect(events[0]!.channel).toBe('export'); + + await prisma.readEvent.deleteMany({ where: { pondId } }); + await api() + .post(`/api/v1/pages/${classifiedId}/export`) + .set('Cookie', ownerCookie) + .send({ format: 'docx' }) + .expect(201); + events = await eventsFor(classifiedId); + expect(events).toHaveLength(1); + expect(events[0]!).toMatchObject({ channel: 'export', details: { format: 'docx' } }); + + await api() + .get(`/api/v1/pages/${openId}/export/markdown`) + .set('Cookie', ownerCookie) + .expect(200); + await api() + .post(`/api/v1/pages/${openId}/export`) + .set('Cookie', ownerCookie) + .send({ format: 'docx' }) + .expect(201); + expect(await eventsFor(openId)).toHaveLength(0); + }); + + it('records one event per classified page in a pond ZIP export — none for the unclassified ones', async () => { + await api() + .get(`/api/v1/ponds/${pondId}/export/markdown`) + .set('Cookie', ownerCookie) + .expect(200); + const events = await eventsFor(classifiedId); + expect(events).toHaveLength(1); + expect(events[0]!).toMatchObject({ channel: 'export', details: { format: 'markdown_zip' } }); + expect(await eventsFor(openId)).toHaveLength(0); + }); + + it('records a download of an attachment whose effective classification is vs_nfd', async () => { + const classifiedUpload = await api() + .post(`/api/v1/pages/${classifiedId}/files`) + .set('Cookie', ownerCookie) + .attach('file', Buffer.concat([PNG_SIGNATURE, Buffer.from('classified bytes')]), 'c.png') + .expect(201); + await prisma.readEvent.deleteMany({ where: { pondId } }); + + await api() + .get(`/api/v1/media/${classifiedUpload.body.id}`) + .set('Cookie', ownerCookie) + .expect(200); + const events = await prisma.readEvent.findMany({ where: { pondId, channel: 'attachment' } }); + expect(events).toHaveLength(1); + expect(events[0]!).toMatchObject({ + pageId: classifiedId, + details: { attachmentId: classifiedUpload.body.id }, + }); + }); + + it('records a public-api page read under the token session key', async () => { + await app.get(InstanceSettingsService).set('api.enabled', true, ownerId); + try { + await api() + .patch(`/api/v1/ponds/${pondId}`) + .set('Cookie', ownerCookie) + .send({ apiEnabled: true }) + .expect(200); + const minted = await api() + .post('/api/v1/users/me/api-tokens') + .set('Cookie', ownerCookie) + .send({ name: `trail-${suffix}`, scope: 'read' }) + .expect(201); + + await api() + .get(`/api/public/v1/ponds/${pondSlug}/pages/${classifiedSlug}`) + .set('Authorization', `Bearer ${minted.body.token}`) + .expect(200); + const events = await eventsFor(classifiedId); + expect(events).toHaveLength(1); + expect(events[0]!.channel).toBe('public_api'); + expect(events[0]!.sessionKey).toMatch(/^token:/); + + await api() + .get(`/api/public/v1/ponds/${pondSlug}/pages/${openSlug}`) + .set('Authorization', `Bearer ${minted.body.token}`) + .expect(200); + expect(await eventsFor(openId)).toHaveLength(0); + } finally { + await prisma.instanceSetting.deleteMany({ where: { key: 'api.enabled' } }); + } + }); + + it('fails the read hard when the trail cannot be written (ADR 0023 — no silent gap)', async () => { + const appPrisma = app.get(PrismaService); + const create = vi + .spyOn(appPrisma.readEvent, 'create') + .mockRejectedValueOnce(new Error('trail unavailable')); + try { + await api().get(`/api/v1/pages/${classifiedId}`).set('Cookie', ownerCookie).expect(500); + // The unclassified read never touches the trail and stays unaffected. + await api().get(`/api/v1/pages/${openId}`).set('Cookie', ownerCookie).expect(200); + } finally { + create.mockRestore(); + } + expect(await eventsFor(classifiedId)).toHaveLength(0); + }); +}); diff --git a/apps/api/src/read-trail/read-trail.module.ts b/apps/api/src/read-trail/read-trail.module.ts new file mode 100644 index 0000000..85520d3 --- /dev/null +++ b/apps/api/src/read-trail/read-trail.module.ts @@ -0,0 +1,15 @@ +import { Global, Module } from '@nestjs/common'; + +import { ReadTrailService } from './read-trail.service'; + +/** + * Global like AuditModule and for the same reason: the read-access trail + * (issue #222, ADR 0023) cuts across every module that serves page content — + * pages, public, public-api, files, import-export. + */ +@Global() +@Module({ + providers: [ReadTrailService], + exports: [ReadTrailService], +}) +export class ReadTrailModule {} diff --git a/apps/api/src/read-trail/read-trail.service.ts b/apps/api/src/read-trail/read-trail.service.ts new file mode 100644 index 0000000..1189145 --- /dev/null +++ b/apps/api/src/read-trail/read-trail.service.ts @@ -0,0 +1,94 @@ +import { Injectable } from '@nestjs/common'; +import { Prisma } from '@prisma/client'; +import { PinoLogger } from 'nestjs-pino'; + +import { PrismaService } from '../prisma/prisma.service'; + +/** + * Every read surface classified content can leave through (issue #222, + * ADR 0023). The trail is worthless unless ALL of them are instrumented, so + * the union is the checklist: adding a read surface means adding a channel + * here and wiring the emission — `docs/architecture/security.md` §Logging + * carries the documented list. + */ +export const READ_CHANNELS = [ + /** Authenticated SPA state fetch and rendered read view, plus the public + * JSON content route the SPA's anonymous read view uses. */ + 'page_view', + /** The server-rendered `/public/:pond/:page` HTML document. */ + 'no_js_shell', + /** `GET /api/public/v1/.../pages/:slug` (PAT-authenticated). */ + 'public_api', + /** `GET /media/:fileId` for an attachment whose effective classification + * is `vs_nfd` (#212 semantics: page level, or pond max when page-less). */ + 'attachment', + /** Markdown download, pond ZIP (one event per classified page included), + * queued `.docx`/`.odt` export, and the account data export. */ + 'export', + /** Collab-token issuance — the api-side proxy for the collab WS join + * (ADR 0023): tokens live 60 s, so a live session re-requests one every + * minute, which gives per-minute granularity without touching the collab + * server (it has no permission context and never learns classifications). */ + 'collab_join', +] as const; +export type ReadChannel = (typeof READ_CHANNELS)[number]; + +/** Who read: resolved once per request by the controller layer. */ +export interface ReadActor { + /** Account id, or null for an anonymous reader on a public grant. */ + actorId: string | null; + /** `session:` (cookie session), `token:` (PAT), `job:` (a + * background build such as the account data export), or the documented + * `anon` marker — the dedup-window key basis (#223). */ + sessionKey: string; +} + +export interface ReadEventInput extends ReadActor { + pageId: string | null; + pondId: string; + channel: ReadChannel; + /** Small structured context (export format, attachment id) — never content. */ + details?: Record; +} + +/** + * The read-access trail for classified pages (issue #222, ADR 0023): + * callers record a `read_events` row whenever a `VS_NFD` page leaves the + * system through one of the {@link READ_CHANNELS}. Unclassified pages are + * never recorded (variant A — the purpose limitation depends on it). + * + * DELIBERATE contrast to `AuditService`: recording failures are NOT + * swallowed. A lost event is a gap in evidence, so a failed write aborts + * the read with the ordinary 500 — the reader retries, the evidence stays + * complete (decision recorded in ADR 0023 and security.md). + */ +@Injectable() +export class ReadTrailService { + constructor( + private readonly prisma: PrismaService, + private readonly logger: PinoLogger, + ) { + this.logger.setContext(ReadTrailService.name); + } + + async record(event: ReadEventInput): Promise { + const { actorId, sessionKey, pageId, pondId, channel, details } = event; + await this.prisma.readEvent.create({ + data: { + actorId, + sessionKey, + pageId, + pondId, + channel, + classification: 'vs_nfd', + details: details ? (details as Prisma.InputJsonObject) : undefined, + }, + }); + // The stdout line mirrors the row (SIEM forwarding beyond this is out of + // scope, #224); it fires only after the row is safely persisted. + this.logger.info( + { actor: actorId, sessionKey, pageId, pondId, channel }, + 'read_trail: classified page read', + ); + } +} diff --git a/docs/architecture/adr/0023-read-access-audit-trail.md b/docs/architecture/adr/0023-read-access-audit-trail.md index 8d8c7e9..38c0147 100644 --- a/docs/architecture/adr/0023-read-access-audit-trail.md +++ b/docs/architecture/adr/0023-read-access-audit-trail.md @@ -48,6 +48,25 @@ traffic per open document. left open: unbounded volume, the no-loss requirement, and a purpose limitation that is much harder to defend. +## Decisions taken in #222 + +- **Failure mode: hard failure.** A failed `read_events` write aborts the + read with the ordinary 500. The alternative (documented degradation) was + rejected: the reader retrying is cheap, a gap in evidence is not. This is + the deliberate contrast to `AuditService`, which swallows failures. +- **Collab WS join: the api emits at token issuance.** The collab server + keeps zero permission/classification context; tokens live 60 s, so a live + session re-requests one per minute — per-minute granularity for free, + which the dedup window (#223) collapses. The trail therefore proves + "held a live connection during this window", not individual sync frames. +- **Scope: full-content channels.** Content _fragments_ (search snippets, + task-overview rows, backlink titles) and the Atom feeds (off in the + reference configuration) are deliberately not instrumented — recorded in + `security.md` §Logging as a residual. +- **Session key vocabulary:** `session:` (cookie), `token:` (PAT — + also the MCP `read_page` path), `job:` (background builds such as the + account data export), `anon` (anonymous reader on a public grant). + ## 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 9034697..882a16b 100644 --- a/docs/architecture/security.md +++ b/docs/architecture/security.md @@ -211,6 +211,37 @@ scan docker-archive:/image.tar -o cyclonedx-json` respectively `audit.pruned` with count and cutoff, so a gap in the trail is always explainable. The read-access trail (#222–#225) is deliberately not covered by this period — it gets its own. +- **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). + 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 + content route, the plugin-API content route, and an expanded embed of a + classified page inside another page's rendering. + - `no_js_shell` — the server-rendered `/public/:pond/:page` document. + - `public_api` — `GET /api/public/v1/.../pages/:slug` and the MCP + `read_page` tool (same emission point); the write echo of the public + API's create/update counts as a read of the returned page. + - `attachment` — `GET /media/:fileId` when the attachment's effective + classification (#212 semantics) is `vs_nfd`. + - `export` — per-page markdown download, one event per classified page in + a pond ZIP or the account data export, and the queued + `.docx`/`.odt`/`.pdf` export (recorded at enqueue — the user's action; + the worker's conversion is machinery, not a second read). + - `collab_join` — collab-token issuance, the api-side proxy for the + collab WS join: the collab server has no permission context, and the + 60 s token TTL yields per-minute granularity for live sessions. + Each event carries timestamp, actor (or the documented `anon` marker), + session key (`session:`/`token:`/`job:`/`anon`), page, pond, channel and + the classification at read time (a later reclassification never rewrites + history). **Failure is not silent**: a failed trail write aborts the read + with a 500 — the deliberate contrast to the audit trail's swallow-and-log, + because a lost event is a gap in evidence (ADR 0023). Deliberately NOT + instrumented (recorded residual): content _fragments_ — search-result + snippets, task-overview rows, backlink titles — and the Atom feeds + (disabled in the VS-NfD reference configuration, #227). Digest mails + carry titles only (the #231 residue). ## Privacy (GDPR) diff --git a/docs/vs-nfd/20-massnahmenplan.md b/docs/vs-nfd/20-massnahmenplan.md index 5330f4f..9d01deb 100644 --- a/docs/vs-nfd/20-massnahmenplan.md +++ b/docs/vs-nfd/20-massnahmenplan.md @@ -132,7 +132,7 @@ Gestaltungsspielraum. Zwei Varianten: Protokolliert werden Lesezugriffe **ausschließlich** auf Seiten mit `classification = VS_NFD`. Setzt P1-2 voraus. -- [ ] Instrumentierung der Lesepfade: Seitenansicht, Public-API-GET, +- [x] Instrumentierung der Lesepfade: Seitenansicht, Public-API-GET, Attachment-Download, Export, No-JS-Shell, Collab-WS-Join · 4 AT · #222 - [ ] Dedup-Fenster (eine Sitzung + eine Seite innerhalb N Minuten = ein Ereignis), sonst erzeugt Yjs-Sync eine Ereignisflut · 2 AT · #223