dorfteich/docs/vs-nfd/31-issue-entwurf.md
Claude Opus 5 fd07f716f6
All checks were successful
CI / Lint, typecheck, test (pull_request) Successful in 4m42s
CI / Build container images (pull_request) Successful in 1m11s
CI / Auth e2e pack (pull_request) Successful in 7m47s
CI / Import/export fidelity gate (pull_request) Successful in 55s
CD / Build and push images (push) Successful in 18s
CD / Deploy to Test (push) Successful in 14s
CD / Smoke tests against Test (push) Successful in 1m16s
CD / Promote to Int (push) Successful in 11s
CI / Lint, typecheck, test (push) Successful in 4m50s
CI / Build container images (push) Has been skipped
CI / Auth e2e pack (push) Successful in 7m38s
CI / Import/export fidelity gate (push) Successful in 56s
docs: VS-NfD readiness planning (ist-aufnahme, plan, ADRs 0019-0026, issue drafts)
Add docs/vs-nfd/: the analysis brief, the as-is assessment (42 findings,
all verified against the code), the prioritized action plan rev. 2 with
issue references written back to every checkbox, the two-stage issue/ADR
brief, and the full reviewed draft used to create the forge state.

Add eight proposed ADRs 0019-0026 covering the VS-NfD architecture
decisions: no security base functions (par. 52 VSA anchor), HKDF token
key separation, external authentication, page classification, read-access
audit trail (variant A), reproducible offline deployment, plugin trust
model, and backup target restriction.

Forge state created alongside this commit: 11 labels, milestones M24-M31,
issues #188-#236 (docs-only change, no code touched).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0168Ph5uBmHm8X28CSVpbpnJ
2026-07-30 01:48:05 +02:00

131 KiB
Raw Permalink Blame History

Entwurf: Meilensteine, Issues und ADRs für die VS-NfD-Ertüchtigung

Stufe 2 ausgeführt am 2026-07-30: ADRs 00190026 geschrieben, 11 Labels, Meilensteine M24M31 und Issues #188#236 im Forge angelegt — die Nummern entsprechen 1:1 den provisorischen dieses Entwurfs, der Korrekturschritt entfiel. Issue-Referenzen stehen im Maßnahmenplan an den Checkboxen. Auftrag: docs/vs-nfd/30-issue-adr-auftrag.md. Quelle: docs/vs-nfd/20-massnahmenplan.md (Rev. 2). Stand Stufe 1: 2026-07-29 (Review-Entwurf, Freigabe durch Stefan 29./30.07.).

Sprache: Meilenstein-Titel, Issue- und ADR-Texte sind englisch (Repo-Konvention), der Rahmen dieses Dokuments deutsch. Deutsche Rechtsbegriffe bleiben unübersetzt: VS-NfD, Verschlusssache, VSA, Geheimschutz.


0. Befunde zum Auftrag selbst (bitte zuerst lesen)

0.1 Die Ist-Aufnahme fehlte — inzwischen nachgezogen

Der Auftrag verlangt, Fundorte ausschließlich aus docs/vs-nfd/10-ist-aufnahme.md zu übernehmen. Diese Datei existierte beim Schreiben dieses Entwurfs nicht — im Repo lag nur ihr Analyse-Auftrag (jetzt docs/vs-nfd/00-analyse-auftrag.md). Der Maßnahmenplan Rev. 2 ist aus einer Analyse entstanden, die nie als Datei abgelegt wurde.

Vorgehen stattdessen: Jede im Plan behauptete Tatsache wurde direkt am Code verifiziert. Das ist strenger als Kopieren, denn es deckt auch Abweichungen auf (siehe 0.2). Jeder Fundort unten ist belegt; wo etwas nicht belegbar war, steht es als UNKLAR da.

Nachtrag (auf Stefans Entscheidung, gleiche Session): docs/vs-nfd/10-ist-aufnahme.md ist inzwischen geschrieben — 42 Befunde (5 BLOCKIEREND, 21 ANPASSEN, 4 UNKLAR, 12 OK) mit Fundort und Bewertung. Sie bestätigt die Plan-Befunde, enthält die drei Korrekturen aus §0.2 und liefert fünf Ergänzungen, die im Plan fehlen (I-22 bis I-26). Vier davon sind hier als Issues #233#236 nachgetragen; die fünfte (I-25, IndexedDB-Kopie auf Endgeräten) ist als Akzeptanzkriterium in #226 und #231 eingearbeitet, weil sie eine Dokumentationspflicht ist und keine Codeänderung.

0.2 Korrekturen am Maßnahmenplan aus der Verifikation

Plan-Aussage Verifikation Konsequenz
deploy/compose/.env prüfen, Beispieldatei statt Realdatei" Die Datei existiert lokal, ist aber nicht im Repo.gitignore:5 schließt .env aus, git ls-files deploy/compose/ listet nur .env.example. Issue #198 wird zur Verifikation + Doku, nicht zur Entschärfung eines Lecks. Aufwand bleibt 0,5 AT.
„drawio-Plugin: lädt es eine externe Editor-URL?" Nein. drawio ist vendored: packages/plugins/drawio/vendor/drawio-30.3.6/. manifest.json nennt keine externe URL (kind: code, permissions: ["blockData","ui"], homepage ist nur Metadatum). Kein Ausschlusskriterium, kein Issue. Restprüfung: eigene Fetches der vendorten Webapp — durch CSP default-src 'self' blockiert.
„Fließen Labels heute in Exporte?" Nein. export.service.ts:98105 lädt labelIds ausschließlich für permissions.filterPages; kein Export-Pfad schreibt Labels in die Ausgabe. Bestätigt die ADR-0022-Begründung: Labels wären als Kennzeichnung schon deshalb untauglich, weil sie die Anwendung nie verlassen. Kein Issue.
„MCP-Gate-Duplikat: divergiert es vom zentralen Modell?" Nein. mcp.service.ts:232253 ruft PermissionService.hasPondRole / canAccessPage. Eigenständig sind nur die Schalter (mcp.enabled + Pond-mcpEnabled), nicht die Entscheidungslogik. Kein Issue.
„Collab-WebSocket: Autorisierung über Permission-Modell oder nur Token?" Nur Token. apps/collab/src/server.ts:108125: verifyCollabToken + Abgleich claims.pageId === documentName; das Permission-Modell wirkt nur bei der Token-Ausgabe in der api, Entzug asynchron per pg_notify-Access-Listener (apps/collab/src/index.ts:6270). Kein eigenes Issue — fließt als Randbedingung in #222 (Instrumentierung des Collab-WS-Join) ein.
„Was bricht ohne Internetzugang?" Offen — beantwortbar nur durch den Testlauf. Fließt in #220 ein, kein eigenes Issue.

Damit sind vier der fünf offenen Punkte des Plans in dieser Session geklärt und brauchen kein Issue; der fünfte ist Teil von M5.

0.3 Aufwandssummen: der Plan ist in sich nicht konsistent

Der Auftrag verlangt, Aufwände zu übernehmen und die Meilenstein-Summen gegen die Tabelle zu prüfen. Ergebnis: Die Meilenstein-Zahlen des Auftrags sind konsistent mit den Phasen-Kopfzahlen des Plans, aber die Phasen-Kopfzahlen sind kleiner als die Summe ihrer eigenen Positionen:

Block Kopfzahl im Plan Summe der Positionen Delta
Phase 2 2228 AT 26,532,5 AT +4,5 AT
Phase 1 · P1-1 1012 AT 1113 AT +1 AT
Phase 1 · P1-2 1418 AT 1418 AT ±0
Phase 1 · P1-3 810 AT 810 AT ±0
Phase 3 Var. A 810 AT 910 AT +1 AT
Phase 5 1520 AT 1820 AT +3 AT

Ich habe nicht neu geschätzt (Auftragsregel). Die Milestone-Tabelle in Abschnitt 2 nennt daher beide Zahlen: die Auftrags-Vorgabe und die Summe der zugeordneten Issues. Klärungsbedarf, siehe Abschnitt 6.

0.4 Forge: Gitea, keine passende CLI

  • git remote -vgit@gitea-fable-5:stwaidele/dorfteich.git, also Gitea 1.22 auf gitea.101010.cloud.
  • CLIs: gh ist installiert, aber gegen github.com authentifiziert (gh auth status → Account stwaidele) — für dieses Repo unbrauchbar. tea, glab, forgejo-cli sind nicht installiert.
  • Der Auftrag verbietet, „mit curl gegen eine geratene API-URL zu improvisieren". Das trifft hier nicht zu: Die Gitea REST API v1 ist der im Projekt etablierte, dokumentierte Weg (CLAUDE.md, Handoff.md → „Typische Handgriffe"), inklusive Konto fable-5 und Passwort-Handhabung. Lesend bereits in dieser Session genutzt (Labels, Meilensteine, höchste Issue-Nummer).
  • Vorhandene Labels: auth, backend, blocked, collab, deployment, docs, frontend, plugins, qa.
  • Vorhandene Meilensteine: M0M23 plus Barrierefreiheit WCAG 2.1 AA (M0M11 offen, M12M23 geschlossen).
  • Höchste Issue-Nummer: #187. Nächste freie: #188.
  • Nächste freie ADR-Nummer: 0019 (docs/architecture/adr/, vorhanden 00010018).

Daraus folgen zwei Namenskollisions-Fragen an Stefan (Abschnitt 6).


1. Umfang dieses Entwurfs

Artefakt Anzahl
Meilensteine 8
Issues 49 (#188#236, Nummern provisorisch)
ADRs 8 (00190026)
Neue Labels 11 (10 aus dem Auftrag + area:ops)

45 Issues aus dem Maßnahmenplan plus 4 aus der Ist-Aufnahme (#233#236, Befunde I-22 bis I-24 und I-26 — im Plan nicht enthalten).

Die 45 liegen über der Auftrags-Schätzung („~30"). Ursache ist keine Ausweitung, sondern die Auftragsregel selbst: Die sieben Ausgabekanäle aus P1-2 werden eigene Issues (statt einer Checkliste), und Phase 5 liefert sechs Dokumentations-Issues. Kürzungsoptionen in Abschnitt 6 — von Stefan verworfen, die Granularität bleibt.


2. Meilensteine

Reihenfolge und Titel wie im Auftrag vorgegeben (dependency-sortiert).

Titel Issues AT (Auftrag) AT (Summe Issues)
VS-NfD: security quick wins #188#198, #233#235 (14) 1518 21,524,5
VS-NfD: hardening & supply chain #199#203, #236 (6) 710 9,512,5
VS-NfD: classification metadata #204#213 (10) 1418 1418 ✓
VS-NfD: external authentication #214#217 (4) 1012 1113
VS-NfD: offline/airgap deployment #218#221 (4) 810 79
VS-NfD: read-access audit trail #222#225 (4) 810 910
VS-NfD: compliance documentation #226#231 (6) 1520 1820
VS-NfD: backlog #232 (1) 810 810 ✓

offline/airgap liegt unter der Vorgabe, weil das Digest-Pinning (1 AT) laut Auftrag nach hardening & supply chain wandert.

Beschreibungstexte (für das Meilenstein-Feld)

VS-NfD: security quick wins

Cheap changes with high assessor signal: token key separation, fail-closed CSRF, bounded sessions, restricted backup targets, and the data-hygiene jobs that are currently missing (pond purge, orphan files, trash in the search index, audit retention). No dependencies — can start immediately.

VS-NfD: hardening & supply chain

The items pulled forward from the roadmap plus supply-chain evidence: attachment integrity hashes, a hard plugin off-switch, a stable SIEM event catalogue, SBOM in CI, and image digest pinning. Digest pinning should land before VS-NfD: offline/airgap deployment — the mirror procedure and the offline update path both build on immutable references.

VS-NfD: classification metadata

classification as first-class page metadata (ADR 0022) and its pass-through into every output channel. Separating classification levels stays outside the application (one instance per level); this milestone delivers the marking. Prerequisite for VS-NfD: read-access audit trail.

VS-NfD: external authentication

OIDC (Authorization Code + PKCE) against the existing UserIdentity.provider slot, proxy-header/mTLS as the alternative path, and a hard auth.local.enabled = false switch including every token flow. Delegates the authentication base function to the operator's platform (ADR 0019).

VS-NfD: offline/airgap deployment

A verified airgap path, not a plausible one: registry mirror procedure, network-free reproducible build, a documented isolated test run, and an offline update path including migrations. Depends on image digest pinning in VS-NfD: hardening & supply chain.

VS-NfD: read-access audit trail

Variant A of the plan: read events only for pages with classification = VS_NFD. Requires VS-NfD: classification metadata. Deliberately narrow — clean purpose limitation, and it keeps Yjs sync from producing an event flood.

VS-NfD: compliance documentation

The §52 VSA delimitation statement, hardening guide, security documentation, operations manual, IT-Grundschutz mapping (APP.3.1, CON.11.1) and the residual-risk list. Runs in parallel and starts early — the delimitation statement does not depend on any implementation.

VS-NfD: backlog

Deliberately deferred: plugin allowlist with hash pinning. The hard off-switch in VS-NfD: hardening & supply chain already closes the "code execution in the VS zone" risk for the offer stage.


3. Labels

Neu anzulegen (10):

Label Farbe (Vorschlag) Bedeutung
vs-nfd #1f3a5f Gehört zur VS-NfD-Ertüchtigung
vs-nfd:blocker #8b0000 Verhindert den Einsatz, bis gelöst (Phase 1)
effort:S #c2e0c6 ≤ 1 AT
effort:M #fef2c0 23 AT
effort:L #f9c9c9 ≥ 4 AT
area:auth #5319e7 Authentisierung, Sessions, Tokens, CSRF
area:export #0e8a16 Ausgabekanäle
area:storage #006b75 Datenhaltung, Löschung, Suchindex, Audit-Tabellen
area:supply-chain #b60205 Images, Abhängigkeiten, Offline-Pfad
area:docs #0075ca Dokumentation
area:ops #6a737d Querschnitt Betrieb: Security-Header/CORS, SIEM-Katalog (Rückfrage 6.3)

Die Effort-Schwellen sind eine Festlegung dieses Entwurfs (der Plan nennt nur AT). Zwei Issues fallen in keinen area:-Wert saubar: #197 (Security-Header) und #201 (Ereigniskatalog) — siehe Rückfrage 6.3.


4. Issues

Nummern waren beim Entwurf provisorisch (nächste freie war #188) und wurden beim Anlegen am 2026-07-30 1:1 bestätigt — alle Depends on-Verweise stimmen unverändert.

Alle Fundorte sind in dieser Session am Code geprüft. Zeilennummern stehen nur dort, wo sie verifiziert wurden.


M1 — VS-NfD: security quick wins

#188 — [VS-NfD] Separate token keys with HKDF and replace the homegrown JWT with jose

Plan reference: docs/vs-nfd/20-massnahmenplan.md → Phase 2, lines 1+2 ADR: ADR 0020 Effort: L (35 AT) Depends on:Labels: vs-nfd effort:L area:auth

Deliberately merged from two plan checkboxes — the plan itself justifies it: both touch packages/shared/src/token-crypto.ts, and doing them separately costs 56 AT instead of 35.

Context

One secret currently serves two unrelated purposes. Purpose-bound subkeys derived via HKDF make a compromise of one path non-transferable, and a vetted JWT implementation removes hand-written crypto from the trust boundary. This does not introduce a new security base function (§52 VSA) — it narrows crypto the application already performs.

Current state

  • COLLAB_TOKEN_SECRET (packages/shared/src/env.ts:55,142) signs collaboration tokens (apps/collab/src/index.ts:46, apps/api/src/pages/pages.service.ts:383) and unsubscribe tokens (apps/api/src/notifications/digest.service.ts:161, apps/api/src/notifications/notifications.controller.ts:51).
  • packages/shared/src/token-crypto.ts:1,37 implements a compact HS256 JWT with node:crypto (createHmac, timingSafeEqual). The file header documents the reason: identical code in the CommonJS api and the ESM collab server.
  • Neither jose nor jsonwebtoken is a dependency of any workspace package.
  • Partial separation already exists: apps/api/src/notifications/unsubscribe-token.ts:14 prefixes a PURPOSE constant into the HMAC input.

Acceptance criteria

  • Subkeys are derived per purpose via HKDF from the configured root secret; no code path signs with the root secret directly.
  • Collaboration tokens are produced and verified by jose, HS256 only, with the algorithm allowlist asserted by a test.
  • A cross-runtime test proves the same token verifies in the api (CommonJS) and the collab server (ESM) — the reason the homegrown implementation existed.
  • Unsubscribe tokens keep a documented dual-verify window (old and new derivation accepted) long enough to cover links already in sent mail; the window's length and expiry date are documented.
  • Negative tests: token signed with a different purpose's subkey is rejected; alg: none and RS256 are rejected.
  • docs/architecture/security.md §"Secrets & configuration" documents the key hierarchy; deploy/compose/.env.example documents the root secret's role.

Out of scope

Rotation automation, moving secrets into an external KMS, and any change to session cookies (see #190).


#189 — [VS-NfD] Make the CSRF origin check fail closed

Plan reference: 20-massnahmenplan.md → Phase 2 ADR: n/a Effort: S (1 AT) Depends on:Labels: vs-nfd effort:S area:auth

Context

A state-changing request that presents neither Origin nor Referer currently passes the same-origin check. An assessor reads that as a fail-open control regardless of whether SameSite cookies happen to cover the browser case.

Current state

  • apps/api/src/auth/auth.guard.ts:108112: assertSameOrigin takes origin ?? referer and returns early when both are absent. The comment names the intent (non-browser clients such as curl and supertest; SameSite cookies as the real defence).

Acceptance criteria

  • Mutating requests without Origin and without Referer are rejected with 403 csrf_origin_mismatch.
  • A documented, deliberate exception path exists for non-browser clients (PAT/bearer authentication), and cookie-authenticated requests never benefit from it.
  • Tests: cookie-auth mutation without either header → 403; PAT-authenticated mutation without either header → success; mismatching origin → 403 (existing behaviour, kept).
  • The api's own test helpers and e2e fixtures are adjusted rather than the check weakened.
  • docs/architecture/security.md records the fail-closed rule.

Out of scope

CSRF tokens as a second mechanism — the origin check plus SameSite is the chosen model.


#190 — [VS-NfD] Make the session lifetime configurable and add an idle timeout

Plan reference: 20-massnahmenplan.md → Phase 2 ADR: n/a Effort: M (12 AT) Depends on:Labels: vs-nfd effort:M area:auth

Context

A sliding 30-day session is far outside what a VS-NfD operating concept accepts, and it is a compile-time constant today. The operator must be able to set both an absolute and an idle bound.

Current state

  • apps/api/src/auth/sessions.service.ts:8: const SESSION_TTL_MS = 30 * 24 * 60 * 60 * 1000; // sliding 30 days, applied at creation (:31) and renewed on every touch (:50).
  • apps/api/src/auth/auth.guard.ts:58 sets the cookie maxAge to the same 30 days.
  • No idle timeout exists; lastSeenAt is written but never used as a bound.

Acceptance criteria

  • Absolute lifetime and idle timeout are separately configurable, with defaults well below 30 days; the cookie maxAge follows the configured value.
  • Idle expiry is enforced server-side against lastSeenAt, not only by cookie expiry.
  • Tests: session past its absolute bound is rejected; session idle past the idle bound is rejected; active use renews idle but never exceeds the absolute bound.
  • Hardening guide (#227) names the recommended VS-NfD values; .env.example documents the settings.

Out of scope

Forced re-authentication for individual actions, and concurrent-session limits.


#191 — [VS-NfD] Remove the feed token from the query string, or allow feeds to be disabled

Plan reference: 20-massnahmenplan.md → Phase 2 ADR: n/a Effort: M (2 AT) Depends on:Labels: vs-nfd effort:M area:auth

Context

Credentials in URLs land in proxy logs, browser history and referrer headers. In a VS zone the proxy log is exactly the place where a long-lived read credential must not appear.

Current state

  • apps/api/src/public/public.controller.ts:28,43 read the credential from @Query('token'); apps/api/src/public/feed.service.ts:25 documents the scheme (the query parameter authenticates the request as the token's user).
  • Feed tokens are stored hashed (apps/api/src/public/feed-tokens.service.ts:73), so the exposure is transport/logging, not storage.
  • There is no instance switch for feeds — instance-settings.service.ts has api.enabled and mcp.enabled but no feed equivalent.

Acceptance criteria

  • Either the token moves out of the query string (header or path segment with documented cache implications), or an instance switch feeds.enabled (default off for the VS-NfD reference config) makes the whole surface answer 404 — the plan allows either.
  • Whichever path is chosen, the other is documented as rejected with a reason.
  • Tests: existing feed reader flow still works; with the switch off every feed route answers 404; no code path logs the token.
  • Hardening guide (#227) lists the setting.

Out of scope

Replacing feeds with a different notification channel.


#192 — [VS-NfD] Restrict backup targets to an allowlist and allow remote targets to be disabled at deploy time

Plan reference: 20-massnahmenplan.md → Phase 2 ADR: ADR 0026 Effort: M (2 AT) Depends on:Labels: vs-nfd effort:M area:storage

Context

A backup destination is an egress path for the full content of the instance. In a VS zone the set of permissible destinations is decided by the operator, not by whoever holds Site-Admin. Encryption stays out — media protection is the platform's base function (ADR 0019).

Current state

  • Remote target is a freely configurable WebDAV/Nextcloud URL: backup.nextcloud.baseUrl in apps/api/src/settings/instance-settings.service.ts (validated as a URL, no host restriction), consumed via apps/api/src/backup/backup-target.service.ts:3,65 and apps/api/src/admin/backup-admin.service.ts:27,112,146.
  • A second remote path is the rsync mirror (apps/backup/src/mirror.ts, ADR 0015, issue #84).
  • No allowlist and no deploy-level kill switch for either path.

Acceptance criteria

  • A deploy-level allowlist (env, not a runtime setting) constrains permissible backup destination hosts; a value outside it is rejected with a clear admin-visible error.
  • An empty allowlist disables all remote targets — WebDAV and rsync mirror — and the admin UI reflects that they are unavailable, not merely unconfigured.
  • Tests: destination outside the allowlist rejected; empty allowlist leaves only the local target; existing configured destination inside the allowlist unaffected.
  • docs/architecture/operations.md and the hardening guide (#227) document the "local only" reference configuration.

Out of scope

Application-side backup encryption (deliberately excluded, ADR 0019/0026) and changes to the restore flow.


#193 — [VS-NfD] Implement pond purge

Plan reference: 20-massnahmenplan.md → Phase 2 ADR: n/a Effort: M (3 AT) Depends on:Labels: vs-nfd effort:M area:storage

Context

Deletion must actually delete. A trashed pond that stays in the database forever is an unbounded residue of classified content and unanswerable in a "deletion and destruction" chapter of the operations manual.

Current state

  • Pond.deletedAt / deletedBy implement a pond-level trash (apps/api/prisma/schema.prisma:175,186, ADR 0013).
  • Purge exists only for pages: apps/api/src/trash/trash.service.ts (purgeNow:96, purgeDuePages:104, purgePage:121) and the endpoint apps/api/src/trash/trash.controller.ts:26. No equivalent for ponds.

Acceptance criteria

  • Retention-driven and manual pond purge exist and remove, in one transaction-safe sequence: pages, revisions, page_updates, page_content_cache rows (including the search vector), attachments on disk, labels, links, comments, favorites, watches, and pond-level grants.
  • Quota counters are corrected; the operation is idempotent and resumable (a purge that races another is a no-op, as for pages).
  • Audit event recorded for both manual and retention purge.
  • DB test proves nothing referencing the pond survives, and a follow-up search for content of the purged pond returns nothing.
  • docs/architecture/operations.md documents retention behaviour; operations manual (#229) covers it under deletion and destruction.

Out of scope

Purge of user accounts (already covered by existing pseudonymization) and the orphan-file sweep (#194).


#194 — [VS-NfD] Implement the orphan-file sweep and resolve Attachment.deletedAt

Plan reference: 20-massnahmenplan.md → Phase 2 ADR: n/a Effort: M (2 AT) Depends on:Labels: vs-nfd effort:M area:storage

Context

Files whose page no longer embeds them are never reclaimed, so content can survive its page indefinitely on disk. An unused nullable column that looks like a soft-delete marker is itself a finding — a reader cannot tell whether deletion is soft or hard.

Current state

  • apps/api/prisma/schema.prisma:530538 documents both facts explicitly: the pageId link is "not touched when an image is later removed from its page's content — an orphan-file sweep to reclaim those is a separate future maintenance job (operations.md), not this one", and "deletedAt stays unused for now — purge hard-deletes attachments".

Acceptance criteria

  • A scheduled sweep identifies attachments no longer referenced by any live page document and removes row plus file, correcting quota usage.
  • A grace period protects the paste-then-insert window (an upload whose page does not exist yet) — proven by a test.
  • Attachment.deletedAt is either used by the sweep with documented semantics or removed by migration; the schema comment matches the outcome.
  • Test: file removed from page content is reclaimed after the grace period; a freshly uploaded, not-yet-embedded file is not.
  • docs/architecture/operations.md documents the job; #229 covers it.

Out of scope

Deduplication by content hash (see #199) and pond purge (#193).


#195 — [VS-NfD] Remove trashed content from the search index instead of filtering at query time

Plan reference: 20-massnahmenplan.md → Phase 2 ADR: n/a Effort: M (2 AT) Depends on:Labels: vs-nfd effort:M area:storage

Context

The full-text index holds plaintext of trashed pages and ponds; only the query hides them. Any future query path that forgets the filter leaks content, and the index is a content copy that a deletion concept has to account for.

Current state

  • apps/api/src/search/postgres-search.provider.ts:41 — the weighted tsvector lives on page_content_cache.search_vector.
  • :142143 — the search query joins pages p ON … p.deleted_at IS NULL and ponds po ON … po.deleted_at IS NULL: query-side filtering.
  • :73 shows the vector can be nulled per page (UPDATE page_content_cache SET search_vector = NULL WHERE page_id = …).

Acceptance criteria

  • Trashing a page or pond clears the search vector of the affected pages; restoring rebuilds it.
  • The query-side deleted_at IS NULL guards stay (defence in depth) and a test asserts both layers independently.
  • Test: a trashed page's unique term is absent from the index rows themselves, not merely from results; restore makes it findable again.
  • A one-off backfill clears vectors of already-trashed content.
  • docs/architecture/security.md records that the index holds no trashed content (the file has no search section today — add one, or extend §"Content & upload security").

Out of scope

Encrypting or removing the plaintext cache for live pages, and any change of search engine.


#196 — [VS-NfD] Add a retention job for audit_log

Plan reference: 20-massnahmenplan.md → Phase 2 ADR: n/a Effort: S (1 AT) Depends on:Labels: vs-nfd effort:S area:storage

Context

An audit trail without a retention rule grows without bound and conflicts with data-protection requirements the operator has to answer for. A configurable period is also what the IT-Grundschutz mapping (#230) needs to reference.

Current state

  • audit_log is the AuditEntry model (apps/api/prisma/schema.prisma:7491, issue #86), written by apps/api/src/audit/audit.service.ts in addition to the audit: … stdout line.
  • No retention or pruning job for the table exists.

Acceptance criteria

  • Retention period is configurable (instance setting or env, matching the pattern used for backup retention) with a documented default.
  • A scheduled job deletes entries past the period; the deletion itself is logged (count, cutoff) so the gap is explainable.
  • Test: entries older than the cutoff are removed, newer ones stay.
  • The read-access trail (#224) is explicitly not covered by this job — it gets its own period.
  • A logging section of docs/architecture/security.md and #229 document the period. Note: the file has no logging section today, although apps/api/prisma/schema.prisma:69 already cites "security.md §Logging" — creating it is part of this issue.

Out of scope

Export of audit entries to a SIEM (#201) and the read-access trail (M6).


#197 — [VS-NfD] Add security response headers and an explicitly restrictive CORS policy

Plan reference: 20-massnahmenplan.md → Phase 2 ADR: n/a Effort: S (1 AT) Depends on:Labels: vs-nfd effort:S area:ops

Context

Response headers are the cheapest verifiable hardening evidence there is, and their absence is the first thing an automated assessment reports. CORS must be a stated decision, not an implicit default.

Current state

  • helmet is not a dependency of apps/api and appears nowhere in apps/api/src; no CORS configuration was found in the api bootstrap.
  • The web tier already ships a strict CSP (default-src 'self', script-src 'self') — the api's own responses are the gap.

Acceptance criteria

  • The api sends HSTS, X-Content-Type-Options, Referrer-Policy, X-Frame-Options/frame-ancestors, and a Permissions-Policy, each value chosen deliberately.
  • CORS is configured explicitly and restrictively (APP_BASE_URL origin only, credentials rules stated); a cross-origin request from another origin is rejected by test.
  • The plugin sandbox's framing requirements (ADR 0008) are verified not to break — covered by an existing or new plugin e2e assertion.
  • A test asserts the header set on a representative api response, so regressions are caught.
  • docs/architecture/security.md lists the headers and their reasons.

Out of scope

Changing the web tier's CSP, and certificate/TLS termination (operator's reverse proxy).


#198 — [VS-NfD] Verify that no real .env is shipped and document the example as authoritative

Plan reference: 20-massnahmenplan.md → Phase 2 ADR: n/a Effort: S (0,5 AT) Depends on:Labels: vs-nfd effort:S area:supply-chain

Context

The plan suspected a real .env in the repository. Verification shows it is not tracked — the remaining work is to make that verifiable and keep it that way, which is what an assessor actually asks for.

Current state

  • deploy/compose/.env exists in the working tree but is not tracked: .gitignore:57 excludes .env and .env.* while allowing .env.example; git ls-files deploy/compose/ lists only .env.example, Caddyfile, compose.dev.yml, docker-compose.yml.
  • deploy/compose/.env.example is present and maintained.

Acceptance criteria

  • A CI check fails if any .env (other than .env.example) is ever tracked, and if a tracked file matches obvious secret patterns.
  • .env.example documents every variable the compose files reference, including the ones added by #188, #190, #191 and #192.
  • The git history is checked once for previously committed secrets, and the result recorded in the residual-risk list (#231) — either "none found" or the concrete finding.
  • docs/self-hosting/README.md states that .env.example is the reference and real values never enter the repository.

Out of scope

Introducing a secret manager, and rotating existing secrets.


M2 — VS-NfD: hardening & supply chain

#199 — [VS-NfD] Add SHA-256 integrity hashes for attachments

Plan reference: 20-massnahmenplan.md → Phase 2 (pulled from roadmap) ADR: n/a Effort: M (23 AT) Depends on:Labels: vs-nfd effort:M area:storage

Context

Integrity is the one security base function §52 VSA leaves to the application in the sense of detecting manipulation of its own payloads — a checksum is not a protection mechanism the platform can supply, because only the application knows what the file should be. Side benefits: orphan sweep, dedup, backup verification.

Current state

  • The Attachment model (apps/api/prisma/schema.prisma:530559) carries no checksum column; the only hashes in the schema are credential/token hashes (token_hash, session id, credential).
  • Files are written by apps/api/src/files/files.service.ts.

Acceptance criteria

  • A sha256 column is added by migration; the hash is computed during upload (streaming, not by re-reading the file) and stored.
  • Download verifies the hash and fails closed with a distinguishable error when it does not match; the mismatch is audited.
  • A backfill migration or job hashes existing attachments and reports progress; unreadable files are reported, not silently skipped.
  • Tests: upload stores the correct hash; tampering with the file on disk makes download fail; backfill is idempotent.
  • docs/architecture/security.md §"Content & upload security" and #229 document the behaviour, including what an operator does when verification fails.

Out of scope

Signatures (needs a signing identity — see ADR 0025), content encryption (ADR 0019), and dedup by hash.


#200 — [VS-NfD] Add a hard plugins.enabled = false switch

Plan reference: 20-massnahmenplan.md → Phase 2 (pulled from roadmap) ADR: ADR 0025 Effort: M (2 AT) Depends on:Labels: vs-nfd effort:M area:supply-chain

Context

"Code execution inside the VS zone" is the question a plugin architecture attracts. A single, verifiable off-switch answers it completely for the offer stage — much cheaper than the trust machinery in #232, and it is what the reference configuration will use.

Current state

  • instance-settings.service.ts provides master switches for the public API (api.enabled, default false) and MCP (mcp.enabled, default false) — the enforcement pattern to copy is apps/api/src/public-api/public-api.guard.ts:64 and apps/api/src/mcp/mcp.controller.ts:50,84 (404 while disabled).
  • No equivalent exists for plugins. Plugin state is per pond plus the installed set; there is no instance-level kill switch.

Acceptance criteria

  • plugins.enabled (default documented; off in the VS-NfD reference config) makes every plugin surface answer 404: manifest and asset routes, the frame route (/api/v1/plugins/<id>/<version>/frame), install/uninstall, and the pond-level toggles.
  • With the switch off, existing plugin blocks in documents render their declared fallback instead of an error, and the editor offers no plugin blocks.
  • Cache note respected: the settings cache is in-process, so the documented procedure includes an api restart (or the setting is read uncached) — verified by test or documented explicitly.
  • Tests: every plugin route 404s while off; a page containing a plugin block still renders; the switch is visible in the admin UI.
  • Hardening guide (#227) lists the setting; docs/architecture/plugin-architecture.md records the switch.

Out of scope

Allowlisting or hash-pinning individual plugins (#232), and removing the plugin architecture.


#201 — [VS-NfD] Define a stable event catalogue for syslog/SIEM export

Plan reference: 20-massnahmenplan.md → Phase 2 (pulled from roadmap) ADR: n/a Effort: L (34 AT) Depends on:Labels: vs-nfd effort:L area:ops

Context

The code part is small — structured JSON on stdout already exists. The value is a stable catalogue: fixed event ids with documented semantics and fields, so the operator can write SIEM rules that survive our updates. Without that contract, every release silently breaks their detection.

Current state

  • apps/api/src/audit/audit.service.ts defines AuditEvent.action: string — the doc comment calls it a "stable dot-namespaced id" but nothing enforces it; it is a free-form string.
  • 34 distinct actions are in use today (auth.login_failed, grant.created, plugin.installed, settings.changed, …) across 37 call sites in apps/api/src.
  • The service comment states the deliberate boundary: "Content activity (pages, files, exports, labels) intentionally stays log-only — the trail answers 'who changed access/configuration', not 'who edited what'."

Acceptance criteria

  • The action set becomes a typed union (or equivalent) so an unknown id cannot be emitted; the existing 34 ids keep their names.
  • A published catalogue documents per event: id, trigger, severity, actor semantics, target semantics, and every field — versioned, with a stated compatibility promise (ids are never repurposed).
  • Log output is structured JSON with a stable field set suitable for forwarding; the documented forwarding path (container stdout → operator's collector) needs no application-side syslog client.
  • A test fails when an event is emitted that the catalogue does not describe — the fence that keeps documentation and code together.
  • The catalogue lives in docs/architecture/security.md or a dedicated file referenced from #228 and #230.

Out of scope

An application-side syslog/TLS shipper, log signing, and read events (M6).


#202 — [VS-NfD] Produce an SBOM and a license report in CI

Plan reference: 20-massnahmenplan.md → Phase 2 ADR: n/a Effort: M (12 AT) Depends on:Labels: vs-nfd effort:M area:supply-chain

Context

An SBOM is the artefact a supply-chain question is answered with rather than argued about, and the license report is needed for the procurement side of the same conversation.

Current state

  • .gitea/workflows/ci.yml runs install, build, lint, typecheck, tests and i18n:check; there is no SBOM step. No reference to sbom, syft or cyclonedx exists anywhere in .gitea/ or the root package.json.
  • Deployment is by container image (deploy/compose/docker-compose.yml), so the SBOM has to cover both the pnpm workspace and the images.

Acceptance criteria

  • CI produces a CycloneDX SBOM per released image plus one for the workspace, and attaches them as build artefacts of the release workflow.
  • A license report lists every dependency with its license; the job fails on a license outside a documented allowlist.
  • Runner constraints respected: the CI runner image lacks python and node tooling outside the workspace, and bind mounts talk to the host daemon — the chosen tool works under those conditions (documented in the PR).
  • The SBOM's provenance is documented so an assessor can regenerate it.
  • #228 references where SBOMs are published per release.

Out of scope

Vulnerability scanning and its triage policy, and signing the SBOM.


#203 — [VS-NfD] Pin all container images by digest

Plan reference: 20-massnahmenplan.md → P1-3 (moved here per the milestone plan) ADR: ADR 0024 Effort: S (1 AT) Depends on:Labels: vs-nfd effort:S area:supply-chain

Context

A floating tag means the deployed artefact is not the reviewed artefact. Digest pinning is the precondition for both the registry mirror and the offline update path (M5), which is why it lands here first.

Current state

deploy/compose/docker-compose.yml pins tags, not digests:

  • postgres:17.5-alpine (:186)
  • pandoc/core:3.6 (:206)
  • gotenberg/gotenberg:8 (:221) — a floating major tag, the loosest of the four
  • caddy:2.10-alpine (:237) Own images are ${IMAGE_PREFIX:-dorfteich}-{web,api,collab,backup}:${TAG:-latest} (:20,36,105,140), pinned per release by the deploy workflow.

Acceptance criteria

  • Every third-party image is referenced as name:tag@sha256:…; the tag stays for readability, the digest decides.
  • A documented, repeatable procedure updates digests (which command, how the new digest is verified) and a CI check fails on any third-party image reference without a digest.
  • All stage composes (test/int/prod) are updated — note that CD does not sync stage composes, so the rollout step is part of this issue's definition of done.
  • deploy/stages.md and #229 document the update procedure.

Out of scope

Building our own base images, and mirroring them (#218).


M3 — VS-NfD: classification metadata

#204 — [VS-NfD] Add classification as a page field with migration and instance default

Plan reference: 20-massnahmenplan.md → P1-2 ADR: ADR 0022 Effort: M (2 AT) Depends on:Labels: vs-nfd vs-nfd:blocker effort:M area:storage

Context

Marking classified content is the one VS-NfD requirement that genuinely belongs in the application: only it knows which page carries which level. Separating the levels stays outside (one instance per level, ADR 0022) — this field is the marking, not a protection mechanism.

Current state

  • model Page (apps/api/prisma/schema.prisma:263300) has no classification field. Metadata is title, slug, sortKey, parentId, timestamps and relations.
  • Label (:423437) is pond-scoped (pondId), hierarchical and user-editable — the reason ADR 0022 rejects labels as the carrier.
  • instance_settings (:1723) is the established place for an instance-wide default (pattern: upload.svgPolicy, api.enabled).

Acceptance criteria

  • Enum field on Page with an explicit "unclassified" default; migration backfills existing pages to it.
  • Instance setting supplies the default for newly created pages; documented and admin-visible.
  • The value is part of the page API representation and of the page metadata the frontend already loads (no extra request per page).
  • Permission-relevant behaviour is unchanged by this issue — asserted by a test, because ADR 0022 makes the ACL claim explicitly not a protection mechanism.
  • docs/architecture/data-model.md documents the field; ADR 0022 is referenced from it.

Out of scope

Inheritance (#205), any output marking (#206#212), and read auditing (M6).


#205 — [VS-NfD] Inherit classification in the page tree; require a dedicated right to downgrade

Plan reference: 20-massnahmenplan.md → P1-2 ADR: ADR 0022 Effort: M (3 AT) Depends on: #204 Labels: vs-nfd vs-nfd:blocker effort:M area:auth

Context

A subpage of a classified page must not silently be unclassified — that is how classified content escapes marking in practice. Downgrading is the sensitive direction and needs its own right plus an audit record.

Current state

  • Pages nest via parentId with a max depth of 6 enforced in the service, cycles rejected at write time (apps/api/prisma/schema.prisma:255266); trashed pages keep parentId and purge promotes children explicitly.
  • Permission decisions run centrally through apps/api/src/permissions/ (deny-wins, default-closed) — the place to add a capability rather than an ad-hoc check.

Acceptance criteria

  • A new page inherits the effective classification of its parent; a page moved under a higher-classified parent is raised.
  • Raising is allowed to any writer; lowering requires a dedicated capability expressed in the central permission model, never an ad-hoc check.
  • Every raise and lower is audited with old value, new value, actor and page.
  • Move operations cannot lower a page's classification as a side effect (proven by test), including the purge-promotes-children path.
  • Tests cover: inherit on create, raise on move, lower denied without the capability, lower audited with the capability.
  • docs/architecture/permissions.md documents the capability.

Out of scope

Output marking, and a UI for bulk re-classification.


Plan reference: 20-massnahmenplan.md → P1-2 (output channels) ADR: ADR 0022 Effort: S (1 AT) Depends on: #204 Labels: vs-nfd vs-nfd:blocker effort:S area:export

Context

The screen is the most-used output channel and sets the visual convention the other channels copy.

Current state

Reading and editing views render page metadata (title, pond) without any classification element; no marking component exists.

Acceptance criteria

  • Reading view, editor and the public page view show the marking at the top and bottom of the page content, using the wording fixed in ADR 0022.
  • Marking is present in de and en (pnpm i18n:check clean) and is not a decorative element only: it is announced to assistive technology, and it survives the theming cascade in light and dark mode with contrast per ADR 0017.
  • Unclassified pages show no marking (no visual noise) — a deliberate, documented decision.
  • e2e coverage in the a11y pack for a classified page in both themes.
  • Screenshot in #228 as evidence of the convention.

Out of scope

Print (#207), PDF (#208) and every other channel.


Plan reference: 20-massnahmenplan.md → P1-2 (output channels) ADR: ADR 0022 Effort: S (1 AT) Depends on: #206 Labels: vs-nfd vs-nfd:blocker effort:S area:export

Context

Printing from the browser is the output channel most likely to produce paper that has to carry a marking on every sheet — and it is currently unstyled entirely.

Current state

  • No @media print block exists anywhere in apps/web/src — verified by search. Browser printing therefore reproduces the screen layout including navigation chrome.

Acceptance criteria

  • A print stylesheet sets page size and margins, suppresses navigation and interactive chrome, and handles break behaviour for headings, tables, code blocks and plugin blocks.
  • The classification appears in a running header and footer on every printed page (@page margin boxes or an equivalent), not once at the top.
  • Verified on a multi-page document as PDF-from-browser in Chromium and one Gecko-based browser; the check is written down so it can be repeated.
  • Unclassified pages print without a marking.
  • Documented in #228 alongside the other channels.

Out of scope

Server-side PDF (#208) and print styles as a general design feature beyond what the marking needs.


#208 — [VS-NfD] Put the classification into the Gotenberg PDF header/footer template

Plan reference: 20-massnahmenplan.md → P1-2 (output channels) ADR: ADR 0022 Effort: S (1 AT) Depends on: #204 Labels: vs-nfd vs-nfd:blocker effort:S area:export

Context

Server-side PDF is the export an authority is most likely to file or forward, so it must be marked per page rather than once at the top.

Current state

  • apps/api/src/import-export/pdf-html.ts builds the export HTML: a document-level <header class="pdf-header"> with pond name and title (:7679) plus print CSS for page size and breaks (:3031, :6971). This header appears once, not per page.
  • Page numbers already come from Gotenberg's footer (:31), so the per-page mechanism exists and is the place to extend.
  • Renderer: apps/api/src/import-export/gotenberg.renderer.ts.

Acceptance criteria

  • Classification is rendered in Gotenberg's header and footer template so it appears on every page of the PDF, next to the existing page numbers.
  • The document-level header keeps working; unclassified pages produce an unchanged PDF (asserted against the existing PDF fidelity snapshots).
  • Fidelity test extended per the repo's fixture-first process (fixtures/README.md): fixture first, snapshot regenerated only with the pinned tool version, committed together.
  • Documented in #228.

Out of scope

DOCX/ODT (#209) and browser print (#207).


Plan reference: 20-massnahmenplan.md → P1-2 (output channels) ADR: ADR 0022 Effort: M (23 AT) Depends on: #204 Labels: vs-nfd vs-nfd:blocker effort:M area:export

Context

DOCX and ODT are editable formats an authority will circulate; the marking has to be part of the document's own header/footer definition, not text in the body that a user can delete without noticing.

Current state

  • apps/api/src/import-export/pandoc.converter.ts drives pandoc; there is no --reference-doc / referenceDoc usage — verified by search, so the output uses pandoc's defaults with no header or footer.
  • The container is pinned to pandoc/core:3.6 (deploy/compose/docker-compose.yml:206), which is also the version the fidelity snapshots are generated with.

Acceptance criteria

  • A reference DOCX and a reference ODT ship in the repository with header/footer fields carrying the classification; the converter passes them for the respective target format.
  • The marking is placed in the document's header/footer definition so it repeats on every page in Word and LibreOffice — verified by opening the output in both.
  • Unclassified pages produce output without a marking.
  • Fixture-first fidelity coverage per fixtures/README.md, snapshots regenerated only with pandoc/core:3.6.
  • How the reference documents are maintained (they are binary) is documented next to them.

Out of scope

Styling the exports beyond what the marking needs, and other formats.


#210 — [VS-NfD] Mark the Markdown ZIP export with frontmatter and a visible imprint

Plan reference: 20-massnahmenplan.md → P1-2 (output channels) ADR: ADR 0022 Effort: S (1 AT) Depends on: #204 Labels: vs-nfd vs-nfd:blocker effort:S area:export

Context

The ZIP export is the bulk egress path: many pages at once, as plain files that get copied onward. It needs both a machine-readable marker and a human-visible one.

Current state

  • apps/api/src/import-export/export.service.ts builds the pond ZIP (Content-Disposition at :68); page selection at :95108 filters by read permission.
  • apps/api/src/import-export/export-markdown.ts emits the Markdown; it writes no YAML frontmatter — frontmatter handling exists only on the Obsidian import side (obsidian-vault.ts:203,528).
  • Verified: labels do not appear in any export (see §0.2), so no existing metadata block can carry the marking.

Acceptance criteria

  • Each exported Markdown file carries the classification in YAML frontmatter and as a visible line at the top and bottom of the file.
  • The ZIP contains a manifest listing every file with its classification, and the highest classification contained is stated once at archive level.
  • Unclassified pages get no marking; existing round-trip and fidelity tests still pass (frontmatter must not confuse our own importer — asserted by a round-trip test).
  • Documented in #228.

Out of scope

The Obsidian vault export variant's own frontmatter modes, beyond keeping them working.


#211 — [VS-NfD] Pass the classification through feeds, public API, search results and the no-JS shell

Plan reference: 20-massnahmenplan.md → P1-2 (output channels) ADR: ADR 0022 Effort: M (23 AT) Depends on: #204 Labels: vs-nfd vs-nfd:blocker effort:M area:export

Context

These four channels emit content without going through the SPA, so each can leak unmarked classified content. The plan groups them under one effort figure, so they stay one issue.

Kept as one issue because the plan gives the four channels a single effort figure (23 AT) and they share one mechanism: the serializer that renders a page representation outside the SPA.

Current state

  • Atom feeds: apps/api/src/public/feed.service.ts, routes in apps/api/src/public/public.controller.ts:28,43.
  • No-JS shell: apps/api/src/public/html-shell.ts (server-rendered HTML for crawlers and no-script clients).
  • Public REST API: apps/api/src/public-api/, gated by api.enabled plus pond apiEnabled.
  • Search results: apps/api/src/search/postgres-search.provider.ts (snippets contain page text).

Acceptance criteria

  • Feed entries carry the classification in a documented element, and the feed document states the highest classification it contains.
  • Public API page representations include the classification field; the API documentation (docs/self-hosting/public-api.md) is updated.
  • The no-JS shell renders the marking in the same places as the SPA (top and bottom) — note the shell is a separate render path from the TipTap view, so it needs its own assertion.
  • Search results show the classification per hit, and a snippet of a classified page is never shown unmarked.
  • One test per channel; the public API test runs with the instance switch on.
  • Documented in #228.

Out of scope

Whether these channels should be available at all in the reference config (#227 turns them off), and MCP (no content egress beyond the public API's model).


#212 — [VS-NfD] Mark attachment downloads by filename prefix and companion file

Plan reference: 20-massnahmenplan.md → P1-2 (output channels) ADR: ADR 0022 Effort: M (12 AT) Depends on: #204 Labels: vs-nfd vs-nfd:blocker effort:M area:export

Context

An attachment leaves the application as an opaque binary — we cannot write into arbitrary file formats, so the marking has to live in the name and in an accompanying file. This is the channel where an honest limitation must be documented rather than papered over.

Current state

  • Download routes: apps/api/src/files/files.controller.ts:51,71; Content-Disposition is set for the pond ZIP (import-export/export.service.ts:68) and the single-page Markdown download (apps/api/src/pages/pages.controller.ts:122).
  • Attachments inherit no classification today (the field arrives with #204 on Page, and an attachment links to a page).

Acceptance criteria

  • A download of an attachment belonging to a classified page carries a documented filename prefix, and a companion text file (or the containing archive) states the classification.
  • The classification an attachment inherits is defined unambiguously for the case where its pageId link is unset (paste-then-insert) — fail closed, and documented.
  • Tests: prefixed filename for a classified page's attachment; unchanged filename for an unclassified one; unset pageId behaves as documented.
  • The residual risk "the file's own content carries no marking" is recorded in #231, not hidden.

Out of scope

Writing markings into file formats (PDF/Office attachments), and blocking downloads (#213 covers the upload side).


#213 — [VS-NfD] Warn or block when attaching files to classified pages

Plan reference: 20-massnahmenplan.md → P1-2 ADR: ADR 0022 Effort: S (1 AT) Depends on: #204 Labels: vs-nfd effort:S area:storage

Context

Uploading to a classified page is the moment a user needs to be told what they are doing — the file inherits a classification it cannot itself carry (#212).

Current state

  • Upload path apps/api/src/files/files.service.ts with pond and page scoping; no classification-aware behaviour exists (the field arrives with #204).

Acceptance criteria

  • Uploading to a classified page shows a clear warning naming the consequence, in de and en.
  • An instance setting can turn the warning into a hard block, with a documented default.
  • Tests: warning shown for a classified page; block enforced server-side (not only in the UI) when enabled.
  • Hardening guide (#227) lists the setting.

Out of scope

Scanning file contents, and MIME/extension policy (already upload.allowedExtensions / upload.svgPolicy).


M4 — VS-NfD: external authentication

#214 — [VS-NfD] Implement OIDC Authorization Code with PKCE, Keycloak as reference IdP

Plan reference: 20-massnahmenplan.md → P1-1 ADR: ADR 0021 Effort: L (56 AT) Depends on: #188 Labels: vs-nfd vs-nfd:blocker effort:L area:auth

Context

Authentication is a security base function §52 VSA assigns to the platform. Delegating it to the operator's IdP is the single most important step in keeping Dorfteich out of the certification obligation under §51 VSA.

Current state

  • The data model has the slot: model UserIdentity (apps/api/prisma/schema.prisma:562577) — "provider is 'password' today and 'oidc:' later", @@unique([provider, subject]), credential holds the Argon2id hash for password identities.
  • No OIDC implementation exists: a search for oidc across apps/api/src and packages/shared/src returns nothing outside that comment. ADR 0007 declares the readiness, not the feature.

Acceptance criteria

  • Authorization Code flow with PKCE, state and nonce validation, discovery-based configuration, and JWKS-based token validation using the vetted library from #188 — no hand-rolled JWT verification.
  • Identity linking follows the existing model: provider = "oidc:<issuer>", subject from the token; an existing local user is linked by a documented, deliberate rule (not silently by e-mail).
  • Login, logout (including IdP-initiated single logout or a documented decision against it) and session creation reuse the existing session service — no parallel session mechanism.
  • Verified against a Keycloak instance; the setup used is documented so the test is repeatable.
  • Tests: successful login creates/links the identity; invalid state, nonce, signature, issuer and audience each rejected; expired token rejected.
  • docs/architecture/security.md and #227 document configuration; ADR 0021 records the decisions.

Out of scope

SAML and LDAP, claim-to-role mapping (#217), and disabling local authentication (#216).


#215 — [VS-NfD] Support a trusted reverse-proxy header or mTLS client certificate as an alternative path

Plan reference: 20-massnahmenplan.md → P1-1 ADR: ADR 0021 Effort: M (2 AT) Depends on: #214 Labels: vs-nfd effort:M area:auth

Context

Some authority environments terminate authentication at the perimeter and expect the application to trust it. Supporting that avoids forcing an IdP into an architecture that already solved authentication — but a trusted header is a loaded gun if it is trusted unconditionally.

Current state

  • Authentication is cookie/session based (apps/api/src/auth/auth.guard.ts, sessions.service.ts) plus PATs; no header- or certificate-based identity path exists.

Acceptance criteria

  • Header-based identity is off by default and requires both an explicit switch and an allowlist of trusted peer addresses; a request arriving from an untrusted peer with the header is rejected and audited.
  • The configured header name and the identity mapping are explicit configuration, never guessed.
  • mTLS variant: the certificate subject/attribute used as identity is configurable, with the same trust-boundary rules.
  • Tests: header from untrusted peer ignored and audited; header from trusted peer authenticates; header ignored entirely while the switch is off; spoofed header alongside a session cookie does not escalate.
  • docs/architecture/security.md documents the trust boundary unambiguously — this is the section an assessor will read closest.

Out of scope

Terminating TLS in the application, and certificate lifecycle management.


#216 — [VS-NfD] Add a hard auth.local.enabled = false switch covering every local credential flow

Plan reference: 20-massnahmenplan.md → P1-1 ADR: ADR 0021 Effort: M (2 AT) Depends on: #214 Labels: vs-nfd vs-nfd:blocker effort:M area:auth

Context

Delegating authentication only counts if the local path is actually closed — including the flows people forget: password reset, self-service signup, personal access tokens and feed tokens. A half-closed local path is worse than none, because the operating concept then describes something untrue.

Current state

  • Local passwords: UserIdentity.credential (Argon2id), apps/api/src/auth/; token flows in apps/api/src/auth/auth-tokens.service.ts, apps/api/src/public-api/api-tokens.service.ts (PATs) and apps/api/src/public/feed-tokens.service.ts.
  • Switch precedent to follow: api.enabled / mcp.enabled in apps/api/src/settings/instance-settings.service.ts, enforced as 404.
  • No switch for local authentication exists.

Acceptance criteria

  • With the switch off: password login, signup, password reset, e-mail-verification-as-login and any other credential-issuing flow are unreachable (404/403 consistently with the project's 404/403 policy) — enumerated in the test, not assumed.
  • PAT and feed-token issuance behaviour with the switch off is a stated decision (blocked, or allowed only for IdP-authenticated users), tested either way.
  • The first-run setup wizard's local admin creation is addressed explicitly — bootstrapping must remain possible without reopening the local path in normal operation.
  • A route-enumeration test proves no authentication route is accidentally left open (extend the existing enumeration fence).
  • The switch is deploy-level, not merely a runtime setting a compromised Site-Admin could flip back — or, if runtime, that residual risk is documented in #231.
  • #227 makes "local auth off" part of the reference configuration.

Out of scope

Migrating existing users to the IdP, and deleting stored password hashes.


#217 — [VS-NfD] Map IdP groups and roles onto the permission model

Plan reference: 20-massnahmenplan.md → P1-1 ADR: ADR 0021 Effort: M (23 AT) Depends on: #214 Labels: vs-nfd effort:M area:auth

Context

Without claim mapping, every authority deployment administers permissions twice — and the second copy drifts. Drifted permissions on classified content is exactly the finding to avoid.

Current state

  • Permissions are grants evaluated centrally (apps/api/src/permissions/, deny-wins, default-closed, ADR-documented route-enumeration test). Grants are created through the API — the project rule is that raw grant rows bypass the PondPermissionCache.
  • Nothing consumes IdP claims (no OIDC exists yet, #214).

Acceptance criteria

  • A declarative, admin-visible mapping turns claims into pond roles and the site-admin flag; the mapping is instance configuration, not code.
  • Mapped grants are applied through the same service path as manual grants, so the permission cache stays correct (no raw row writes).
  • Removal of a claim revokes the corresponding grant on next login, and live collab sessions are terminated by the existing revocation path (pg_notify access listener) — asserted by test.
  • Manually created grants are distinguishable from mapped ones, and a documented rule says which wins.
  • Every mapping-driven change is audited.
  • docs/architecture/permissions.md documents the mapping.

Out of scope

SCIM provisioning, and just-in-time user creation policy beyond what #214 defines.


M5 — VS-NfD: offline/airgap deployment

#218 — [VS-NfD] Document the mirror procedure into an internal registry

Plan reference: 20-massnahmenplan.md → P1-3 ADR: ADR 0024 Effort: S (1 AT) Depends on: #203 Labels: vs-nfd effort:S area:supply-chain

Context

An authority pulls images from its own registry, not from Docker Hub. The procedure must be written so their operations team can execute it without us.

Current state

  • Third-party images come from public registries by tag (deploy/compose/docker-compose.yml:186,206,221,237); own images from the project registry via IMAGE_PREFIX/TAG.
  • deploy/stages.md documents stage deployment, not mirroring.

Acceptance criteria

  • A step-by-step procedure mirrors every required image (ours and third-party) into an internal registry, by digest, including how the digest is verified after the copy.
  • Compose files take the registry prefix from configuration so no image reference needs editing per site.
  • The complete image list is generated, not hand-maintained, so it cannot drift.
  • Executed once end-to-end and the run recorded as evidence.
  • Documented in deploy/stages.md and the operations manual (#229).

Out of scope

Operating a registry for the customer, and the isolated test run (#220).


#219 — [VS-NfD] Make the build reproducible without network access

Plan reference: 20-massnahmenplan.md → P1-3 ADR: ADR 0024 Effort: M (23 AT) Depends on: #203 Labels: vs-nfd effort:M area:supply-chain

Context

"Builds fine offline" is a claim; a documented offline build is evidence. The plan explicitly allows the cheaper answer — prebuilt images only — as long as it is a stated decision.

Current state

  • pnpm workspace with a committed lockfile; CI installs with pnpm install --frozen-lockfile (.gitea/workflows/ci.yml).
  • Images are built in CI with network access; there is no offline store or vendored dependency set.
  • Note for whoever implements: a new workspace dependency also requires touching the api Dockerfile (COPY packages/<x> + build).

Acceptance criteria

  • Either a pnpm offline store / vendored dependency set makes pnpm install and pnpm build succeed with networking disabled, or the decision "prebuilt images only, no customer-side build" is documented with its consequences (no local patching).
  • Whichever path: reproduced twice from a clean checkout with identical results, and the procedure written down.
  • Toolchain versions (node, pnpm, base images) are pinned and stated.
  • Documented in #229; the decision recorded in ADR 0024.

Out of scope

Bit-for-bit reproducible builds as a formal property, and mirroring (#218).


#220 — [VS-NfD] Run and document a deployment in a network-isolated environment

Plan reference: 20-massnahmenplan.md → P1-3 ADR: ADR 0024 Effort: M (2 AT) Depends on: #218, #219 Labels: vs-nfd effort:M area:supply-chain

Context

This is the issue that turns the airgap story from plausible into verified — the plan's reason for pulling P1-3 forward. It also answers the plan's open question "what breaks without internet access", which is not answerable by reading code.

Current state

  • Existing strengths to confirm rather than build: no telemetry, no update checks, no CDNs, self-hosted fonts (ADR 0016), CSP default-src 'self', Postgres full-text search instead of an external engine, drawio vendored (§0.2).
  • Whether an egress-blocked deployment is fully functional has never been tested — the open question in 20-massnahmenplan.md.

Acceptance criteria

  • A full deployment runs with egress blocked: install, first-run setup, login, editing with live collaboration, search, upload, all export formats (PDF via Gotenberg, DOCX/ODT via pandoc), backup and restore.
  • Every outbound connection attempt is captured and listed; each is either eliminated or documented as required with its purpose.
  • Behaviour of outbound e-mail (SMTP) without egress is stated explicitly — it is the one connection an authority may or may not permit.
  • A written protocol (date, environment, versions by digest, results, deviations) exists as assessor-facing evidence.
  • Findings feed back into #221 and #229.

Out of scope

Fixing whatever the run uncovers — that becomes its own issue, referenced from here.


#221 — [VS-NfD] Define the offline update path including migrations

Plan reference: 20-massnahmenplan.md → P1-3 ADR: ADR 0024 Effort: M (23 AT) Depends on: #218, #220 Labels: vs-nfd effort:M area:supply-chain

Context

An installation that cannot be updated safely will not be updated, and an unpatched instance in a VS zone is the outcome nobody wants. Migrations are the risky part.

Current state

  • Stages apply migrations on api start (MIGRATE_ON_START); there is no separate migration step and no documented rollback for a failed migration.
  • Production deployment pins TAG in the stage .env and pulls (deploy/stages.md); rollback today means deploying the previous tag.
  • Constraint to respect: prisma migrate reset is not an available tool here — migrate deploy is the path.

Acceptance criteria

  • A documented procedure covers: obtain the update bundle, verify it (digests), back up, apply, verify health, and roll back.
  • Migration behaviour is explicit: which migrations are irreversible, what a rollback means for the database, and when a restore is the only way back.
  • Rehearsed once in the isolated environment from #220, including one deliberate failed-update rollback.
  • Version skew during the update (api/collab/web) is described: whether a rolling update is supported or downtime is required.
  • Documented in the operations manual (#229) and docs/operations/restore-runbook.md.

Out of scope

Automating updates, and long-term support/backport policy.


M6 — VS-NfD: read-access audit trail

#222 — [VS-NfD] Instrument read paths for classified content

Plan reference: 20-massnahmenplan.md → Phase 3, Variante A ADR: ADR 0023 Effort: L (4 AT) Depends on: #204, #205 Labels: vs-nfd effort:L area:storage

Context

Platform logging cannot answer which classified page was read — proxy logs know URLs, not classifications. That is the gap this milestone closes, and only for classified content, which is what keeps the purpose limitation defensible.

Current state

  • The audit trail is deliberately write-only in scope: apps/api/src/audit/audit.service.ts — "Content activity (pages, files, exports, labels) intentionally stays log-only — the trail answers 'who changed access/configuration', not 'who edited what'." No read events exist (the single action: 'read' occurrence, apps/api/src/mcp/mcp.service.ts:243, is a permission-check parameter, not an audit action).
  • Read paths to instrument: SPA page fetch, public API GET (apps/api/src/public-api/), attachment download (apps/api/src/files/files.controller.ts:51,71), exports (apps/api/src/import-export/), no-JS shell (apps/api/src/public/html-shell.ts), and the collab WS join.
  • Constraint for the WS join: authorization there is token-only (apps/collab/src/server.ts:108125 — signature plus claims.pageId === documentName); the collab server has no permission context and therefore cannot know a page's classification. The api is the natural emission point, and it is a good one: collab tokens live 60 seconds (apps/api/src/pages/pages.service.ts:57,384), so a live session re-requests one every minute — that gives per-minute granularity for free, which the dedup window in #223 then collapses. Decide and document.

Acceptance criteria

  • Every listed read path emits an event for pages with classification = VS_NFD, and none for unclassified pages.
  • Each event carries: timestamp, actor (or documented anonymous marker), page, channel, and the classification at read time.
  • The collab WS join is covered by the decided mechanism, with the reasoning recorded in ADR 0023.
  • Events survive an in-request failure of the trail without breaking the read? — no: for classified content, a failed write must be a hard failure or an explicit, documented degradation. State which, and test it. (This is the deliberate difference from AuditService, which swallows failures.)
  • One test per channel proving both the event and its absence for unclassified pages.
  • The logging section of docs/architecture/security.md created by #196 documents the channel list.

Out of scope

Auditing reads of unclassified content (Variant B, rejected in ADR 0023), and the dedup window (#223).


#223 — [VS-NfD] Add a dedup window so Yjs sync does not flood the trail

Plan reference: 20-massnahmenplan.md → Phase 3, Variante A ADR: ADR 0023 Effort: M (2 AT) Depends on: #222 Labels: vs-nfd effort:M area:storage

Context

A live editing session produces continuous traffic; one event per message is both useless as evidence and a performance problem. One session plus one page within N minutes is one read.

Current state

  • The live document is pages.ydoc_state plus a page_updates log; collab persistence is debounced (~2 s) and sessions are long-lived (apps/collab/src/persistence.ts, session registry in apps/collab/src/session-registry.ts).

Acceptance criteria

  • Deduplication key (session + page + channel) and window length are configurable, with a documented default.
  • The first access in a window is always recorded, and the record states that it represents a window, not a single request — so the evidence is not misread.
  • Reconnects within a window do not create a second event; a new session does, even for the same user.
  • Load evidence: a realistic editing session produces a bounded number of events (measured figure recorded in the PR).
  • The window is documented in ADR 0023 and #228, because it defines what the trail can and cannot prove.

Out of scope

Buffered writing for a high-volume all-reads variant (Variant B).


#224 — [VS-NfD] Store read events in their own table with retention and partitioning

Plan reference: 20-massnahmenplan.md → Phase 3, Variante A ADR: ADR 0023 Effort: M (2 AT) Depends on: #222 Labels: vs-nfd effort:M area:storage

Context

Read events have a different volume profile, a different retention period and a different legal basis than audit_log. Mixing them would force one policy onto both.

Current state

  • audit_log (apps/api/prisma/schema.prisma:7491) holds auth and admin events and has no retention job yet (#196) and no partitioning.

Acceptance criteria

  • Separate table with an index set matched to the expected queries ("who read page X", "what did user Y read", both within a period).
  • Time-based partitioning, with the partition-maintenance job included and tested — not left as an operational chore.
  • Own configurable retention period, independent of #196, with a documented default and the deletion itself logged.
  • A Site-Admin query path exists (or its deliberate absence is documented) — evidence nobody can read is not evidence.
  • Growth measured and stated (rows and bytes per 1000 reads) so an operator can size storage.
  • docs/architecture/data-model.md documents the table.

Out of scope

SIEM forwarding of read events beyond the catalogue from #201, and tamper-proofing.


#225 — [VS-NfD] Make the read trail switchable and document its purpose limitation

Plan reference: 20-massnahmenplan.md → Phase 3, Variante A ADR: ADR 0023 Effort: M (12 AT) Depends on: #222, #224 Labels: vs-nfd effort:M area:docs

Context

Read logging is employee monitoring in the eyes of a works council. A hard switch plus a written purpose limitation is what makes it adoptable — the plan names this as an explicit benefit of Variant A.

Current state

Not applicable — the feature arrives with #222.

Acceptance criteria

  • An instance switch enables/disables the trail; off means no event is written anywhere (including stdout), verified by test.
  • Default is documented and deliberate.
  • A written purpose limitation states: what is recorded, why, who may read it, for how long, and what it may not be used for.
  • The absence of events while switched off is itself explainable (a startup log line stating the trail is off), so a gap is never ambiguous.
  • Text ships as part of the security documentation (#228) and is referenced from the hardening guide (#227).

Out of scope

Four-eyes access control on the trail, and works-council templates.


M7 — VS-NfD: compliance documentation

#226 — [VS-NfD] Write the §52 VSA delimitation statement

Plan reference: 20-massnahmenplan.md → Phase 5 ADR: ADR 0019 Effort: L (3 AT) Depends on:Labels: vs-nfd effort:L area:docs

Context

The most important single document of the whole undertaking: it states which security base functions Dorfteich does not provide and to whom they fall. It is what keeps the application outside the certification obligation under §51 VSA, and it does not depend on any implementation — so it starts first.

Current state

  • ADR 0019 (this entwurf) is its draft; existing material to draw on: docs/architecture/security.md, permissions.md, ADR 0007 (auth/sessions), ADR 0015 (backup), ADR 0008 (plugin sandbox).

Acceptance criteria

  • Each base function (encryption, media protection, network termination, authentication, and integrity as far as it applies) is listed with: what the application does, what it deliberately does not, and which party provides it.
  • Every claim is traceable to code or configuration — no aspirational statements.
  • The deliberate non-features are argued as architecture, not omission (the plan's explicit instruction: "no encryption in the code is correct architecture, not a missing feature").
  • Delta list: where the current state does not yet match the statement, it points at the issue that closes the gap.
  • Endpoint-side content copies are named as an operator duty: every opened page is mirrored into the browser's IndexedDB (apps/web/src/editor/use-collab-provider.ts:76, database dorfteich-page-<pageId>), cleared on leave once synced but deliberately kept for unsynced offline edits. Confidentiality of that copy is endpoint media protection, i.e. the platform's function — but it must be stated, not omitted (10-ist-aufnahme.md → I-25).
  • Reviewed against ADR 0019 for consistency; divergences resolve in the ADR, not in the statement.

Out of scope

Legal review by counsel, and the IT-Grundschutz mapping (#230).


#227 — [VS-NfD] Write the hardening guide with a "VS-NfD operation" reference configuration

Plan reference: 20-massnahmenplan.md → Phase 5 ADR: ADR 0019 Effort: L (3 AT) Depends on: #191, #192, #200, #216 Labels: vs-nfd effort:L area:docs

Context

One named configuration an operator can adopt wholesale is worth more than a list of options. It is also the artefact that makes the switches built in M1/M2/M4 auditable.

Current state

  • docs/self-hosting/README.md covers ordinary self-hosting; no hardened profile exists.
  • Existing switches that belong in the profile: api.enabled (default off), mcp.enabled (default off), upload.svgPolicy, upload.allowedExtensions, backup settings — all in apps/api/src/settings/instance-settings.service.ts.

Acceptance criteria

  • A complete reference configuration for VS-NfD operation: local auth off, public API off, MCP off, feeds off, plugins off, backup local only — each with the exact setting name and value.
  • Every entry states why, so an operator can deviate knowingly.
  • Settings added by M1/M2/M4 are included; the guide is updated in the same PR as each new switch (stated as a rule, not a hope).
  • A verification script or checklist lets an operator confirm the profile is active on a running instance.
  • Cross-referenced from #226 and #230.

Out of scope

Hardening the operator's platform (OS, network, reverse proxy).


#228 — [VS-NfD] Write the security documentation (architecture, data flows, network plan, ports, trust boundaries)

Plan reference: 20-massnahmenplan.md → Phase 5 ADR: ADR 0019 Effort: L (4 AT) Depends on:Labels: vs-nfd effort:L area:docs

Context

This is the document an assessor reads first and returns to. It has to be accurate to the digit on ports, services and trust boundaries.

Current state

  • Substantial material exists and needs consolidating rather than inventing: docs/architecture/security.md, deployment.md, data-model.md, permissions.md, realtime-collaboration.md, plugin-architecture.md, plus deploy/compose/docker-compose.yml as the authoritative service and port list (web, api, collab, backup, db, gotenberg, pandoc, caddy).

Acceptance criteria

  • Component diagram with every service, its purpose and its privileges.
  • Data-flow diagrams for: authentication, editing (api ↔ collab ↔ Postgres LISTEN/NOTIFY), export (pandoc/Gotenberg sidecars), backup, and each read channel from #222.
  • Network plan with all ports and protocols, internal versus externally exposed, matching the compose files exactly.
  • Trust boundaries named explicitly, including the plugin sandbox (ADR 0008) and the reverse-proxy boundary from #215.
  • Every content copy is listed — database, uploads, page_updates, page_content_cache including the search vector, versions, backups, export artefacts — because the deletion concept in #229 depends on this list being complete.
  • Diagrams are maintainable as text (the repo already renders Mermaid), not binary images.

Out of scope

Penetration test and threat model as separate deliverables.


#229 — [VS-NfD] Write the operations manual (installation, update, backup/restore, deletion, role separation)

Plan reference: 20-massnahmenplan.md → Phase 5 ADR: ADR 0019 Effort: L (45 AT) Depends on: #193, #194, #221 Labels: vs-nfd effort:L area:docs

Context

The operator has to run this without us, including on the day something fails. Deletion and destruction is the chapter with the most VS-specific weight.

Current state

  • Partial material: docs/operations/restore-runbook.md, deploy/stages.md, docs/architecture/operations.md, deploy/backup-basel.md, deploy/monitoring.md.
  • Gaps that must be closed by their issues before this can be truthful: pond purge (#193), orphan-file sweep (#194), offline update (#221).

Acceptance criteria

  • Installation including the airgap variant (referencing #218#221).
  • Update and rollback, with the migration caveats from #221.
  • Backup and restore, including a rehearsed restore and the restriction on targets from #192.
  • Deletion and destruction: per content type, what deletion does, which copies it reaches (using the list from #228), how long residues persist, and how an instance is decommissioned.
  • Role separation: which tasks need Site-Admin, which need platform access, and what a Site-Admin can not do.
  • Every procedure has been executed at least once by its author, and says so.

Out of scope

Customer-specific operating concepts, and 24/7 support processes.


#230 — [VS-NfD] Produce the IT-Grundschutz mapping for APP.3.1 and CON.11.1

Plan reference: 20-massnahmenplan.md → Phase 5 ADR: ADR 0019 Effort: L (34 AT) Depends on: #226, #227, #228 Labels: vs-nfd effort:L area:docs

Context

The authority's own documentation obligation runs along these building blocks. Supplying the mapping ourselves saves them the translation work and prevents them from guessing wrong about us.

Current state

No IT-Grundschutz material exists in the repository.

Acceptance criteria

  • Every requirement of APP.3.1 (web applications) and CON.11.1 (Verschlusssachen handling) is classified as product, operator, or not applicable, with a one-line justification.
  • Product requirements point to code, configuration or test evidence; operator requirements say what we hand over to enable it.
  • "Not applicable" is argued, never asserted.
  • Open requirements point at the issue that closes them, so the document doubles as a gap list.
  • The building-block versions used are stated (they get revised).

Out of scope

Requirements from other building blocks, and the authority's Sicherheitskonzept.


#231 — [VS-NfD] Maintain the residual-risk list

Plan reference: 20-massnahmenplan.md → Phase 5 ADR: ADR 0019 Effort: S (1 AT) Depends on: #226 Labels: vs-nfd effort:S area:docs

Context

Naming what is deliberately left open is a credibility instrument. An assessor who finds an undocumented gap distrusts the whole submission; one who finds it already listed does not.

Current state

  • Deliberately unscheduled items are recorded in project documentation today (external search engine, signup admin approval, plugin network allowlist); items already identified in this entwurf that belong on the list: attachment content carries no internal marking (#212), plugin hash pinning deferred (#232), and whether the local-auth switch is runtime-flippable (#216).
  • From 10-ist-aufnahme.md: the IndexedDB copy on endpoints (I-25, apps/web/src/editor/use-collab-provider.ts:76 — survives a browser crash and for unsynced offline edits), page titles in digest mails (I-23), and the page_links.target_slug residue if #235 decides to keep it (I-24).

Acceptance criteria

  • Each entry states: the risk, why it is accepted, its compensating control, and who decided.
  • Entries from #212, #216, #232 and the git-history check in #198 are present.
  • Entries from 10-ist-aufnahme.md I-23, I-24 and I-25 are present.
  • The list is referenced from #226 and #230, and updated in the same PR whenever an issue closes with a knowingly open remainder.

Out of scope

Formal risk scoring, and the customer's own risk acceptance.


M8 — VS-NfD: backlog

#232 — [VS-NfD] Add a plugin allowlist with SHA-256 hash pinning

Plan reference: 20-massnahmenplan.md → Phase 4 ADR: ADR 0025 Effort: L (810 AT) Depends on: #200 Labels: vs-nfd effort:L area:supply-chain

Context

Hash pinning is the right answer to plugin trust; it is not the most urgent one, because #200's hard off-switch already closes the risk for the offer stage. Real code signing is unavailable without a legal entity to hold a signing identity (ADR 0025).

Current state

  • Plugins declare a manifest.json (e.g. packages/plugins/drawio/manifest.json: id, version, apiVersion, kind, extensionPoints, permissions, fallback) — there is no hash or signature field.
  • Bundles are shipped as zip artefacts (packages/plugins/drawio/dist/drawio-1.0.0.zip); loading is gated by the sandbox (ADR 0008) and per-pond enablement, not by identity.
  • instance_settings is the place for the allowlist (apps/api/prisma/schema.prisma:1723).

Acceptance criteria

  • The manifest carries a SHA-256 over the bundle; the hash is verified on install and on every load, failing closed with a clear error.
  • An allowlist in instance_settings names permitted plugin ids with their pinned hashes; a plugin outside it does not load even if installed.
  • Admin UI to review the allowlist and the hash actually seen versus the one pinned.
  • Every rejection is audited (catalogue event per #201).
  • Tests: tampered bundle rejected; unpinned plugin rejected; version bump requires an explicit re-pin.
  • docs/architecture/plugin-architecture.md and #227 document the model.

Out of scope

Code signing with a certificate, a plugin marketplace, and network allowlisting for plugins (deliberately unscheduled).


Nachtrag aus der Ist-Aufnahme (nicht im Maßnahmenplan)

Vier Befunde, die 10-ist-aufnahme.md zusätzlich zum Plan gefunden hat. Drei betreffen unbefristete Inhaltskopien, einer die Reproduzierbarkeit.

#233 — [VS-NfD] Prune conversion job payloads for every job kind

Plan reference: n/a — docs/vs-nfd/10-ist-aufnahme.md → I-22 ADR: n/a Effort: M (2 AT) Depends on:Milestone: M24 — VS-NfD: security quick wins Labels: vs-nfd effort:M area:storage

Context

The raw bytes of every import and export survive indefinitely in the database. A deleted classified page therefore lives on inside its last export — the kind of residue a deletion concept cannot leave unexplained.

Current state

  • ConversionJob.input / .result are the raw document bytes (apps/api/prisma/schema.prisma:746784). The schema calls them "transient, not the durable copy an Attachment is" and refers to "a later maintenance job" for pruning.
  • expiresAt is documented as set only for data-export jobs (#68) and "Null for every other job kind, whose result never expires".
  • Of the five registered scheduled jobs — version-thinning, page-compaction, trash-purge, data-export-purge, notification-digest — only data-export-purge touches conversion job rows (apps/api/src/import-export/import-export.module.ts:62).

Acceptance criteria

  • Payloads of all job kinds are pruned after a configurable period with a documented default; the row may survive for status/audit purposes, the bytes must not.
  • A backfill clears payloads of already-finished jobs.
  • Pruning does not break an in-flight job (lockedAt recovery path respected) — asserted by test.
  • Test: finished export job's result is null after the period; a pending job is untouched.
  • docs/architecture/operations.md documents the job; #229 lists it under deletion and destruction.

Out of scope

Changing where conversion happens, and the data-export purge that already works.


#234 — [VS-NfD] Add retention for mail_outbox

Plan reference: n/a — 10-ist-aufnahme.md → I-23 ADR: n/a Effort: S (1 AT) Depends on:Milestone: M24 — VS-NfD: security quick wins Labels: vs-nfd effort:S area:storage

Context

Sent mails are kept forever, and digest mails contain page titles plus who edited them. For a classified page the title alone can be protected information, so this is an unbounded copy of content-adjacent data.

Current state

  • MailOutbox stores text_body and html_body permanently (apps/api/prisma/schema.prisma:683697); no pruning job is registered.
  • Digest mails include page titles and actor names: apps/api/src/notifications/digest.service.ts:16,125,146 (- ${page.pageTitle}: … (${actorNames})).
  • Transactional mails carry no content — greeting, i18n body, link only (apps/api/src/mail/mail-templates.ts:2544).

Acceptance criteria

  • Sent and permanently failed entries are deleted after a configurable period with a documented default; the retry logic for pending entries is unaffected.
  • Test: sent entry past the period removed, pending entry kept, failed-and-retryable entry kept.
  • docs/architecture/security.md (privacy section) and #229 name the period.
  • Whether digest mails should carry page titles at all for classified pages is decided and recorded — either suppressed or accepted in #231.

Out of scope

Changing the mail templates' content beyond that decision, and mail delivery logging.


Plan reference: n/a — 10-ist-aufnahme.md → I-24 ADR: n/a Effort: S (0,5 AT) Depends on:Milestone: M24 — VS-NfD: security quick wins Milestone note: cheapest issue in the set; deliberately a decision, not necessarily a change. Labels: vs-nfd effort:S area:storage

Context

After a page is purged, other pages' link rows keep its slug. A slug carries the page title, and for a classified page that can itself be protected information.

Current state

  • PageLink.fromPage is onDelete: Cascade, toPage is onDelete: SetNull (apps/api/prisma/schema.prisma:441461). Purging a page nulls to_page_id but leaves target_slug in place — by design, because that is what makes a "phantom" link resolve again if a page with that slug reappears.

Acceptance criteria

  • A decision is made and documented: either purge-time deletion of rows whose target_slug matches the purged page (losing phantom-link re-resolution), or keeping them as an accepted residue.
  • If kept: recorded in #231 with its reasoning, and named in the deletion chapter of #229 so an operator can answer for it.
  • If deleted: a test proves no row referencing the purged slug survives, and the phantom-link behaviour change is noted in the release notes.

Out of scope

Redesigning the wikilink index.


#236 — [VS-NfD] Pin the Node version

Plan reference: n/a — 10-ist-aufnahme.md → I-26 ADR: ADR 0024 Effort: S (0,5 AT) Depends on:Milestone: M25 — VS-NfD: hardening & supply chain Labels: vs-nfd effort:S area:supply-chain

Context

A reproducible offline build cannot rest on "any Node ≥ 22". This is a precondition for the reproducibility claim in #219, which is why it lands before the offline milestone.

Current state

  • package.json:710: "engines": { "node": ">=22" } — a lower bound, not a pin. "packageManager": "pnpm@11.9.0" is exact, so the pattern for pinning is already established in the same file.
  • Dockerfiles and CI (.gitea/workflows/ci.yml, actions/setup-node@v4) each select a version independently.

Acceptance criteria

  • One authoritative Node version is declared and consumed by CI, the Dockerfiles and local development; a drift between them fails CI.
  • The update procedure for that version is documented (it will need raising for security fixes).
  • The version is stated in #228 alongside the other toolchain versions.

Out of scope

Changing the Node major, and pinning image digests (#203).


5. ADRs

Nummerierung fortlaufend nach 0018; Format wie 0018-color-theming.md (# ADR NNNN: …, Status/Date-Bullets, ## Context, ## Decision, ## Consequences, abschließend die umsetzenden Issues).

Status-Vorschlag: proposed beim Anlegen, accepted sobald Stefan die Richtung bestätigt. Abweichung von den bestehenden ADRs (alle accepted), weil diese acht Entscheidungen ein Vorhaben beschreiben, das noch nicht begonnen hat — Rückfrage 6.4.


ADR 0019 — docs/architecture/adr/0019-no-security-base-functions.md

# ADR 0019: No security base functions in the application (§52 VSA)

- Status: proposed
- Date: 2026-07-29

## Context

Dorfteich is to be operable inside an IT environment of a German federal
authority that is approved under the Verschlusssachenanweisung (VSA), for
content classified VS-NfD. **No BSI certification of Dorfteich itself is
sought.**

§51 VSA makes products that provide a _Sicherheitsgrundfunktion_ subject to
certification. §52 VSA enumerates those base functions: encryption, media
protection (Datenträgerschutz), network termination (Netzabschluss), and
authentication. A product that implements one of them itself moves into the
certification obligation — an outcome that would end this undertaking on
cost grounds alone.

Today Dorfteich sits close to the right side of that line, partly by
accident and partly by design: there is no content encryption, no backup
encryption, no own MFA, and no cryptographic primitive of our own beyond
signing short-lived collaboration tokens and hashing credentials. What is
missing is the _decision_ — so that no future feature crosses the line
because nobody had written down where it runs.

## Decision

**Dorfteich does not provide any security base function within the meaning
of §52 VSA. Encryption, media protection, network termination and
authentication belong to the operator's platform.**

Concretely:

1. **No encryption of content**, neither in the database nor on the file
   system. Confidentiality of stored data is provided by the platform
   (full-disk / volume encryption).
2. **No backup encryption in the application.** Media protection is the
   platform's function; the application restricts _where_ backups may go
   (ADR 0026) and nothing more.
3. **No own MFA, no own password policy engine.** Authentication is
   delegated to the operator's identity provider (ADR 0021). Local
   passwords remain available for non-VS deployments and are hard-switchable
   off.
4. **No new cryptographic primitives.** Existing crypto is limited to
   credential hashing (Argon2id), token hashing (SHA-256) and signing
   short-lived tokens, and it uses vetted libraries rather than
   hand-written constructions (ADR 0020).
5. **No TLS termination, no network segmentation** in the application.
6. **No application-side separation of classification levels.** Levels are
   separated by operating one instance per level; the application only
   _marks_ content (ADR 0022).

The application's contribution to security is a different set of
properties, and these it does own: a central, default-closed permission
model; complete absence of outbound connections; verifiable marking of
classified content in every output channel; and an audit trail.

## Consequences

- Deliberate non-features must be argued as architecture, not apologised
  for as gaps. "No encryption in the code" is the correct division of
  labour under §52 VSA.
- Every feature proposal is measured against this ADR. Any change that
  would make the application the bearer of a base function needs to amend
  this ADR first — which is the point of writing it down.
- The operator carries obligations that must be handed over explicitly and
  in writing. This ADR is therefore the **draft of the delimitation
  statement** (Abgrenzungserklärung) that #226 turns into a
  reviewer-facing document; the two must not diverge.
- Anything the platform cannot supply because it lacks application
  knowledge stays with us. Two cases exist today: marking of classified
  content (only the application knows the classification, ADR 0022) and
  integrity of the application's own payloads (#199).
- Residual risks arising from delegation are listed in #231 rather than
  silently accepted.

## Implementing issues

#226 (delimitation statement), #227 (hardening guide), #228 (security
documentation), #229 (operations manual), #230 (IT-Grundschutz mapping),
#231 (residual-risk list).

ADR 0020 — docs/architecture/adr/0020-token-crypto-key-separation.md

# ADR 0020: Token crypto — HKDF key separation and a vetted JWT library

- Status: proposed
- Date: 2026-07-29

## Context

`COLLAB_TOKEN_SECRET` currently signs two unrelated kinds of token:
short-lived collaboration tokens (issue #34) and long-lived unsubscribe
tokens in outgoing mail. A single secret across purposes means a
compromise in one path is transferable to the other.

`packages/shared/src/token-crypto.ts` implements the compact HS256 JWT by
hand on `node:crypto`. The reason is documented in the file and is a good
one: the identical code has to run in the CommonJS api and the ESM collab
server without module-interop or dependency-version drift. The
implementation is careful — HS256 only, constant-time comparison before
any untrusted field is read. It is nevertheless hand-written crypto in the
trust boundary, which is a finding in any assessment regardless of its
quality.

ADR 0019 states the application implements no security base function.
Token signing is not one — but it is crypto we do perform, so it has to be
minimal, purpose-bound and delegated to a vetted implementation.

## Decision

1. **Purpose-bound subkeys via HKDF.** The configured secret becomes a root
   key from which each purpose derives its own subkey (collaboration
   tokens, unsubscribe tokens, any future purpose). No code path signs with
   the root key.
2. **`jose` replaces the homegrown JWT.** It is maintained, audited,
   works in both module systems, and is dependency-free — which matters for
   the supply-chain argument. HS256 stays the only accepted algorithm, as
   an explicit allowlist rather than an implicit default.
3. **Purpose separation is structural, not textual.** The existing
   `PURPOSE` string prefix in `unsubscribe-token.ts` is superseded by key
   separation; a token signed for one purpose cannot verify under another
   because the key differs.
4. **Long-lived tokens get a documented dual-verify window.** Unsubscribe
   links live in mail that has already been sent, so both derivations are
   accepted for a stated period with a stated expiry date. The window is a
   documented fact, not an accident.

## Consequences

- The cross-runtime property that motivated the hand-written code must be
  proven by a test, not assumed — otherwise the reason for the original
  decision is lost silently.
- One new runtime dependency. Accepted: `jose` has no transitive
  dependencies, so the supply-chain delta is one package.
- Rotating the root key invalidates all derived subkeys at once, which is
  the desired behaviour and needs documenting in the operations manual.
- The key hierarchy becomes part of the security documentation (#228) and
  the delimitation statement's crypto section (#226).

## Implementing issues

#188.

ADR 0021 — docs/architecture/adr/0021-external-authentication.md

# ADR 0021: External authentication via OIDC; local passwords optional

- Status: proposed
- Date: 2026-07-29

## Context

ADR 0007 established sessions and identities with OIDC in mind:
`UserIdentity.provider` is documented as `"password"` today and
`"oidc:<issuer>"` later, with `@@unique([provider, subject])` already in
place. No OIDC code exists — the readiness is structural only.

Authentication is a security base function under §52 VSA (ADR 0019), so it
belongs to the operator's platform. An authority environment additionally
brings its own account lifecycle: joiners, movers and leavers are managed
in the IdP, and a second account store inside the application would drift
from it.

Some environments terminate authentication at the perimeter instead and
expect the application to trust a header or a client certificate.

## Decision

1. **OIDC Authorization Code with PKCE is the primary path**, configured by
   discovery, validated against JWKS. Keycloak is the reference IdP we
   verify against; nothing in the implementation is Keycloak-specific.
2. **Identities use the existing slot**: `provider = "oidc:<issuer>"`,
   `subject` from the token. Linking an OIDC identity to an existing local
   user follows an explicit, documented rule — never silently by e-mail
   address, which would be an account-takeover path.
3. **Local authentication is switchable off in full**, via
   `auth.local.enabled = false`. "In full" means every credential-issuing
   flow: password login, self-service signup, password reset,
   verification-as-login, and the token flows (PAT, feed tokens). A
   half-closed local path makes the operating concept untrue, which is
   worse than not closing it.
4. **Proxy header and mTLS are a supported alternative path, off by
   default.** When enabled they require an allowlist of trusted peers; a
   request carrying the header from an untrusted peer is rejected and
   audited. The trust boundary is stated explicitly in the security
   documentation.
5. **Claims map onto the existing permission model** declaratively, and
   mapped grants are written through the same service path as manual ones
   so the permission cache stays correct. The application gains no second
   authorization model.
6. **No MFA, no password policy engine of our own** (ADR 0019). Both are
   the IdP's.

## Consequences

- Bootstrapping needs a documented answer: the first-run wizard creates a
  local admin, so either it stays exempt with a stated compensating
  control, or setup itself runs against the IdP. The choice is recorded in
  #216.
- Whether the switch is deploy-level or runtime matters: a runtime setting
  can be flipped back by a compromised Site-Admin. If it stays runtime,
  that residual risk goes into #231.
- Existing password hashes remain in the database after the switch. Their
  deletion is out of scope and, being Argon2id, they are not a
  confidentiality problem — but the fact is documented.
- Session handling is unchanged: OIDC produces a session through the same
  service, so there is exactly one session mechanism (see #190 for its
  bounds).
- SAML and LDAP stay out. OIDC plus proxy/mTLS covers the environments we
  target; adding SAML would be a new decision.

## Implementing issues

#214 (OIDC + PKCE), #215 (proxy header / mTLS), #216
(`auth.local.enabled`), #217 (claim mapping). Depends on #188 for the
vetted JWT implementation.

ADR 0022 — docs/architecture/adr/0022-page-classification.md

# ADR 0022: Classification as first-class page metadata

- Status: proposed
- Date: 2026-07-29

## Context

VS-NfD content must be marked, in every output that leaves the system.
Dorfteich has no classification concept today: `model Page` carries title,
slug, tree position and timestamps, and nothing else that could express a
protection level.

The obvious shortcut is to reuse labels. Verification shows why that
fails:

- `Label` is **pond-scoped** (`pondId`), so the same classification would
  be a different object in every pond, with no instance-wide meaning.
- Labels are **user-editable** by any editor; a marking must not be
  removable as a matter of routine content work.
- Labels do **not inherit** down the page tree, so a subpage of classified
  content would silently be unmarked.
- Labels **never leave the application**: `export.service.ts` loads
  `labelIds` only to feed `permissions.filterPages`, and no export path
  writes them out. A carrier that does not reach the output channels cannot
  serve as a marking.

The second question is architectural: should the application separate
classification _levels_? It must not (ADR 0019, and the plan's Phase 0
guardrails). Separation is a platform property.

## Decision

1. **A dedicated enum field on `Page`**, with an instance-wide default from
   `instance_settings`. Not labels, for the four reasons above.
2. **Separation of levels happens outside the application: one instance per
   classification level.** The application marks; it does not isolate.
   This is the central operational decision of the whole undertaking and
   belongs here rather than in a manual, because it defines what the
   feature is _not_.
3. **The application-side ACL is order, not a protection mechanism.**
   Permissions keep working as they do (central, default-closed,
   deny-wins), and the classification field does not change them. Anyone
   reading the code must not mistake the field for an isolation boundary —
   the test in #204 pins that.
4. **Classification inherits down the page tree.** A new or moved page
   takes at least its parent's level. Raising is ordinary editorial work;
   **lowering requires a dedicated capability** in the central permission
   model and is audited with old value, new value, actor and page.
5. **Every output channel carries the marking**, and each is an
   independently closable issue: web view, browser print, server-side PDF,
   DOCX/ODT, Markdown ZIP, feeds, public API, search results, no-JS shell,
   attachment download. A channel that cannot carry it internally
   (arbitrary binary attachments) is marked externally — filename prefix
   plus companion file — and the remaining gap is a documented residual
   risk, not a silent one.
6. **Unclassified content shows no marking.** Marking everything trains
   users to ignore markings.

## Consequences

- Ten issues, because there are ten output paths; that is the honest cost
  of "in every output".
- The no-JS shell and the SPA are separate render paths, so each needs its
  own assertion. Likewise the TipTap NodeView path and the server-side
  `docToHtml` path differ structurally.
- The field is a precondition for the read-access audit trail (ADR 0023),
  which is scoped to classified content only.
- Attachments inherit their page's classification. The case where the
  page link is not yet set (paste-then-insert) fails closed.
- Because levels are separated by instance, a page can never "move
  between levels" inside one deployment — export/import across instances is
  the path, and its marking is covered by the export channels.

## Implementing issues

#204 (field + default), #205 (inheritance + downgrade right), #206 (web),
#207 (print), #208 (PDF), #209 (DOCX/ODT), #210 (Markdown ZIP), #211
(feeds/API/search/no-JS), #212 (attachments), #213 (upload warning).

ADR 0023 — docs/architecture/adr/0023-read-access-audit-trail.md

# ADR 0023: Read-access audit trail limited to classified content

- Status: proposed
- Date: 2026-07-29

## Context

The existing audit trail (`audit_log`, issue #86) is deliberately scoped to
"who changed access or configuration", and its own service comment states
that content activity stays log-only. There is no record of _reads_.

For "operable in an approved environment", read logging is not a mandatory
product feature — evidence collection can be a platform function. In
practice platform logging cannot answer the question that matters: a proxy
log knows URLs, not classifications, so it cannot say which _classified_
page was read. Leistungsbeschreibungen tend to list this as a must.

Two variants were considered. Variant B logs all reads (1820 AT) and
brings volume, latency and retention problems, plus the requirement that no
event may be lost. Variant A logs reads of classified pages only (810 AT).

A live editing session is the volume hazard: Yjs sync means continuous
traffic per open document.

## Decision

**Variant A: read events are recorded only for pages with
`classification = VS_NFD`.** Requires ADR 0022.

1. **All read channels are instrumented**, or the feature is worthless:
   SPA page fetch, public API GET, attachment download, export, no-JS
   shell, collab WS join.
2. **A dedup window** (session + page + channel within N minutes = one
   event) keeps Yjs sync from flooding the trail. The recorded event states
   that it represents a window, so the evidence is not overread.
3. **Its own table**, with time partitioning and its own retention period —
   independent of `audit_log`, because volume, purpose and legal basis all
   differ.
4. **Failure is not silent.** `AuditService` swallows write failures by
   design; for classified reads a lost event is a gap in evidence, so the
   behaviour is either hard failure or an explicitly documented
   degradation. Which one is decided in #222 and stated in the security
   documentation.
5. **Switchable, with a written purpose limitation.** Off means nothing is
   written anywhere; a startup log line states the trail is off so a gap is
   never ambiguous.
6. **Variant B is rejected**, and the rejection is recorded rather than
   left open: unbounded volume, the no-loss requirement, and a purpose
   limitation that is much harder to defend.

## Consequences

- The scope limit is the feature's strongest argument in the works-council
  discussion at the customer: only classified content is observed.
- Reads of unclassified content are not evidenced. Deliberate, and it goes
  into the residual-risk list.
- The collab WS join is the awkward channel: authorization there is
  token-only (signature plus `pageId` match) and the collab server has no
  permission context. Either the event carries what the token asserts, or
  the api emits it at token issuance. #222 decides and documents; the
  choice affects what the trail can prove about live sessions.
- Retention and partition maintenance are operational obligations that
  must ship with the feature, not after it.
- Classification at read time is stored with the event: a later
  reclassification must not rewrite history.

## Implementing issues

#222 (instrumentation), #223 (dedup window), #224 (table, retention,
partitioning), #225 (switch + purpose limitation). Depends on #204/#205.

ADR 0024 — docs/architecture/adr/0024-reproducible-offline-deployment.md

# ADR 0024: Reproducible offline deployment

- Status: proposed
- Date: 2026-07-29

## Context

A VS zone has no internet egress. "Should work offline" is the answer that
loses a first meeting; "tested, here is the procedure" is the one that
wins it — which is why the plan pulled this out of the roadmap into
Phase 1.

The current state is favourable but unverified. There is no telemetry, no
update check, no CDN; fonts are self-hosted (ADR 0016); CSP is
`default-src 'self'`; search is Postgres rather than an external engine;
the drawio plugin is vendored rather than loaded from a remote editor. What
is missing is evidence, plus two real gaps: images are referenced by tag
(including the floating `gotenberg/gotenberg:8`), and there is no
documented mirror or update path.

## Decision

1. **All third-party images are pinned by digest** (`name:tag@sha256:…`).
   The tag stays for human readability; the digest decides what runs. A CI
   check rejects any un-digested third-party reference.
2. **The image list is generated, not hand-maintained**, so a mirror
   procedure cannot silently miss a service.
3. **An internal registry is the supported source.** Compose takes the
   registry prefix from configuration; no site edits image references.
4. **Reproducibility without network is a stated choice between two
   paths**: an offline pnpm store enabling `install` + `build` with
   networking disabled, **or** prebuilt images only with no customer-side
   build. Either is acceptable; leaving it unstated is not, because it
   determines whether the customer can patch locally.
5. **The airgap claim is proven by a documented run** in a network-isolated
   environment, covering every function including the export sidecars, and
   listing every outbound connection attempt observed. This run is the
   artefact, and it also answers the plan's open question about what breaks
   offline.
6. **The offline update path is part of the decision, not an afterthought**:
   bundle, verify by digest, back up, apply, verify, roll back — with the
   irreversibility of migrations stated explicitly.

## Consequences

- Digest pinning creates recurring maintenance: security updates now
  require an explicit, reviewable change. That visibility is the point.
- Digest pinning must precede the mirror and update work, so it sits in the
  `hardening & supply chain` milestone rather than this one.
- The isolated test run will surface findings; each becomes its own issue
  referenced from #220 rather than expanding that issue's scope.
- Outbound SMTP is the one connection an authority may or may not permit;
  the deployment must be functional without it, and the consequences of
  disabling it (no notifications, no verification mail — which interacts
  with `auth.local.enabled = false`) are documented.
- CD does not sync stage composes, so digest changes need an explicit
  rollout step on the stage hosts.

## Implementing issues

#203 (digest pinning), #218 (registry mirror), #219 (network-free build),
#220 (isolated test run), #221 (offline update path), #236 (pinned Node
version — added from the Ist-Aufnahme, I-26).

ADR 0025 — docs/architecture/adr/0025-plugin-trust-model.md

# ADR 0025: Plugin trust model

- Status: proposed
- Date: 2026-07-29

## Context

Dorfteich has a plugin architecture with a sandbox (ADR 0008): plugins
declare a manifest, run isolated, and hold declared permissions. In a VS
zone the question this attracts is blunt — can code execute inside the
protected area, and who vouches for it?

Two facts shape the answer. First, the manifest has no integrity or
identity field: nothing binds a bundle to what was reviewed. Second, real
code signing needs a signing identity, and without a legal entity behind
the project there is none to be had — a self-generated key that we also
distribute proves nothing.

There is also an asymmetry in cost: a hard off-switch is ~2 AT and closes
the risk completely for a deployment that does not need plugins; a trust
model is 810 AT and only _manages_ the risk.

## Decision

1. **Short term: hard, verifiable off-switch.** `plugins.enabled = false`
   makes every plugin surface answer 404 — manifests, assets, the frame
   route, install/uninstall, and the per-pond toggles — following the
   established pattern of `api.enabled` and `mcp.enabled`. Off is part of
   the VS-NfD reference configuration.
2. **Documents stay readable with plugins off.** An existing plugin block
   renders its declared `fallback`, never an error. Disabling a feature must
   not damage content.
3. **Medium term: hash pinning, not code signing.** A SHA-256 over the
   bundle in the manifest, an allowlist of id + pinned hash in
   `instance_settings`, verification on install and on every load, failing
   closed. A version bump requires an explicit re-pin.
4. **Signing is deliberately rejected for now**, with its reason on the
   record: no signing identity is available. Should a legal entity exist
   later, signing becomes an amendment to this ADR, not a new discovery.
5. **The sandbox remains the containment mechanism.** Hash pinning answers
   "is this the reviewed code", not "what may it do". Both are needed and
   neither substitutes for the other.
6. **Network allowlisting for plugins stays unscheduled**, consistent with
   the existing project decision; in the VS-NfD profile plugins are off, so
   it is not the binding constraint.

## Consequences

- The offer stage can answer the code-execution question with a switch and
  a test, without waiting for #232.
- Vendored third-party plugin code (drawio 30.3.6 under
  `packages/plugins/drawio/vendor/`) is part of our supply chain and
  appears in the SBOM (#202). It loads no external editor URL — verified —
  and CSP would block it if it tried.
- Hash pinning makes plugin updates a deliberate act, which is the intended
  friction.
- The plugin ecosystem stays small by construction. Accepted.

## Implementing issues

#200 (hard off-switch), #232 (allowlist + hash pinning).

ADR 0026 — docs/architecture/adr/0026-backup-target-restriction.md

# ADR 0026: Backup target restriction

- Status: proposed
- Date: 2026-07-29

## Context

Backups are the largest single egress path in the system: the entire
content of the instance, in one artefact. Today the remote destination is a
freely configurable WebDAV/Nextcloud URL in `instance_settings`, validated
as a URL but not restricted to any host, plus an rsync mirror to a private
host (ADR 0015, issue #84). Anyone with Site-Admin can therefore direct a
full copy of the instance to an arbitrary server.

The tempting answer is to encrypt backups in the application. ADR 0019
rules that out: media protection is the platform's base function, and
implementing it here would move Dorfteich into the certification
obligation under §51 VSA.

## Decision

1. **A deploy-level allowlist constrains permissible backup
   destinations.** Deploy-level, not a runtime setting, so a compromised
   Site-Admin account cannot widen it.
2. **An empty allowlist disables every remote target** — WebDAV and rsync
   mirror alike. "Local only" is the VS-NfD reference configuration.
3. **The admin UI distinguishes "unavailable" from "unconfigured"**, so an
   operator is never left guessing whether a missing backup is a
   misconfiguration or policy.
4. **No application-side backup encryption**, following ADR 0019. Backup
   media are protected by the platform.
5. **Integrity of backup artefacts is in scope**, unlike their
   confidentiality: checksums let a restore be verified, which is an
   application concern because only we know what the artefact should
   contain (see #199 for the same reasoning on attachments).

## Consequences

- Existing deployments that use a remote target must have it added to the
  allowlist, or backups stop. This is a breaking change and is called out
  in the release notes.
- The delimitation statement (#226) must state plainly that backups leave
  the application unencrypted and that media protection is the operator's
  duty. That sentence will be read closely; it is the correct one.
- Off-site backup in an airgapped deployment becomes an operator process
  (media handling), not an application feature.
- Restore stays unchanged, including the maintenance-mode interlock that
  closes collab sessions during a restore.

## Implementing issues

#192 (allowlist + deploy-level disable). Related: #199 (integrity
hashes), #229 (backup/restore chapter of the operations manual).

6. Rückfragen an Stefan — beantwortet 2026-07-29

Stefans Entscheidungen:

Frage Entscheidung
6.0 Freigabe Stufe 2 Erst die Ist-Aufnahme nachziehen (10-ist-aufnahme.md), Stufe 2 danach.
6.1 Meilenstein-Titel An das Repo-Schema anpassen: M24 — VS-NfD: security quick winsM31 — VS-NfD: backlog.
6.2 Labels Neue area:* wie vorgegeben, bestehende (auth, docs, …) unberührt.
6.5 Granularität 45 Issues bleiben — keine Bündelung.
6.3 / 6.4 / 6.7 Nicht separat entschieden → die im Entwurf vorgeschlagenen Defaults gelten: area:ops als elftes Label für #197/#201, ADR-Status proposed beim Anlegen, AT-Kopfzahlen des Plans unangetastet (Positions-Summen zusätzlich ausgewiesen).

Damit lauten die Meilenstein-Titel für Stufe 2:

# Titel
M24 M24 — VS-NfD: security quick wins
M25 M25 — VS-NfD: hardening & supply chain
M26 M26 — VS-NfD: classification metadata
M27 M27 — VS-NfD: external authentication
M28 M28 — VS-NfD: offline/airgap deployment
M29 M29 — VS-NfD: read-access audit trail
M30 M30 — VS-NfD: compliance documentation
M31 M31 — VS-NfD: backlog

Die Zuordnungen in Abschnitt 2 und 4 gelten unverändert (M1…M8 dort sind die Kurzreferenzen dieses Entwurfs, nicht die Gitea-Titel).

Die ursprünglichen Fragen bleiben zur Nachvollziehbarkeit stehen:

6.1 Meilenstein-Titel: Namensschema

Die vorhandenen Meilensteine heißen M0 — … bis M23 — …. Der Auftrag gibt für die neuen VS-NfD: security quick wins etc. vor — ohne Nummer. Ergebnis wäre eine gemischte Liste.

  • (a) Auftrags-Titel wörtlich übernehmen (VS-NfD: security quick wins). — Meine Empfehlung: Vorgabe ist Vorgabe, und das Prefix gruppiert in Gitea ohnehin sauber.
  • (b) An das Repo-Schema anpassen: M24 — VS-NfD: security quick wins usw.

6.2 Label-Dopplung

Es gibt bereits auth, docs, plugins, deployment, backend, frontend, collab, qa, blocked. Der Auftrag verlangt zusätzlich area:auth, area:docs, area:export, area:storage, area:supply-chain — teils redundant zu den bestehenden.

  • (a) Neue area:*-Labels wie vorgegeben anlegen, bestehende unberührt lassen (VS-NfD-Issues nutzen nur area:*). — Empfehlung: hält den VS-NfD-Bestand in einem konsistenten Schema.
  • (b) Bestehende Labels weiterverwenden, wo sie passen (auth, docs), nur die fehlenden neu (area:export, area:storage, area:supply-chain).

6.3 Zwei Issues ohne passendes area:-Label

#197 (Security-Header/CORS) und #201 (SIEM-Ereigniskatalog) passen in keine der fünf vorgegebenen Areas. Vorschlag: ein zusätzliches area:ops. Alternativ bleiben sie provisorisch bei area:auth bzw. area:docs (so stehen sie derzeit im Entwurf).

6.4 ADR-Status proposed oder accepted

Alle bestehenden ADRs stehen auf accepted, weil sie umgesetzte Entscheidungen dokumentieren. Diese acht beschreiben ein Vorhaben, das noch nicht begonnen hat. Vorschlag: proposed beim Anlegen, Umstellung auf accepted sobald du die Richtung bestätigst — insbesondere ADR 0019, das die Grundlage aller anderen ist.

6.5 45 Issues statt ~30 — kürzen?

Die Zahl folgt aus den Auftragsregeln (sieben Ausgabekanal-Issues, sechs Doku-Issues). Kürzungsoptionen, falls dir das zu granular ist:

  • M3: die sechs kleinen Ausgabekanäle (#206#210, #212) zu zwei Issues bündeln („Client-seitige Kanäle", „Server-seitige Exporte") → 4
  • M7: die sechs Doku-Issues zu drei bündeln → 3
  • M1: #196 + #198 (je ≤1 AT) an verwandte Issues anhängen → 2

Mein Rat: so lassen. Jedes Issue ist unabhängig abschließbar, und bei einem Vorhaben über 45 Monate ist Granularität hilfreicher als eine kurze Liste. Kürzen ist später schwerer als Zusammenfassen beim Abarbeiten.

6.6 Fehlende Ist-Aufnahme nachziehen?

10-ist-aufnahme.md existiert nicht (§0.1). Die Belegkette für Prüfer hat damit eine Lücke — die Fundorte sind verifiziert, aber nur in diesem Entwurf und in den Issues, nicht im dafür vorgesehenen Dokument. Soll ich die Ist-Aufnahme in einer eigenen Session aus 00-analyse-auftrag.md erzeugen? Die hier verifizierten Fundorte fließen direkt ein, der Aufwand liegt damit deutlich unter einer Erstanalyse.

6.7 Aufwands-Deltas

§0.3: Die Phasen-Kopfzahlen im Plan liegen unter der Summe ihrer eigenen Positionen (Phase 2: +4,5 AT, Phase 5: +3 AT, P1-1: +1 AT, Phase 3: +1 AT). Ich habe nicht neu geschätzt. Soll der Plan in Stufe 2 auf die Positions-Summen korrigiert werden (Gesamtsumme dann ~80101 AT statt 7596 AT), oder bleiben die Kopfzahlen als bewusst gerundete Planwerte stehen?


7. Was Stufe 2 tut (nach Freigabe)

  1. Acht ADR-Dateien nach docs/architecture/adr/0019…0026-*.md schreiben (Volltexte oben, Status proposed).
  2. Elf Labels anlegen (zehn aus dem Auftrag + area:ops).
  3. Acht Meilensteine M24 — VS-NfD: … bis M31 — VS-NfD: backlog mit den Beschreibungstexten aus Abschnitt 2 anlegen.
  4. 49 Issues über die Gitea-API als fable-5 anlegen — sequenziell, mit Nummernprotokoll, damit Depends on korrekt nachgezogen werden kann. Falle beachten: Issue-POST-Antworten können Steuerzeichen enthalten (Parser-Fehler ≠ Anlage-Fehler; erst die Liste prüfen, nie blind erneut posten), und Nicht-ASCII-Kombinationen können 422 auslösen → Bodys per printf > datei und -d @datei.
  5. Issue-Nummern in 20-massnahmenplan.md hinter jede Checkbox zurückschreiben (· #142) und je Phase den Meilenstein nennen; die vier Nachtrags-Issues (#233#236) als eigenen Abschnitt „Ergänzungen aus der Ist-Aufnahme" im Plan führen, damit der Plan Index bleibt.
  6. Provisorische Nummern in ADRs und Issue-Bodys auf die echten korrigieren.
  7. git status prüfen: außer docs/ nichts geändert.

8. Verifikation dieses Entwurfs

  • Jede Checkbox-Zeile des Plans hat genau ein Issue — Ausnahmen begründet: #188 (zwei Zeilen, vom Plan selbst gebündelt), #211 (vier Kanäle, eine Aufwandsangabe im Plan), die fünf „Zu klärenden Punkte" (vier in §0.2 geklärt, einer in #220 aufgehoben).
  • Jeder Befund der Ist-Aufnahme, der nicht im Plan steht, hat ein Issue oder ein Akzeptanzkriterium: I-22 → #233, I-23 → #234, I-24 → #235, I-26 → #236, I-25 → #226 + #231.
  • Jedes Issue hat Meilenstein, Effort-Label, Area-Label und Akzeptanzkriterien — Area bei #197/#201 vorbehaltlich 6.3.
  • Jedes Issue mit Architekturbezug verweist auf ein ADR; jedes ADR listet seine umsetzenden Issues.
  • Alle zitierten Pfade in dieser Session am Code geprüft (Abschnitt §0.1/§0.2); Zeilennummern nur, wo verifiziert.
  • Summe der AT je Meilenstein stimmt mit der Auftragstabelle — nein, Abweichungen dokumentiert in §0.3 und Abschnitt 2, Rückfrage 6.7.
  • git status: nur docs/vs-nfd/ betroffen (drei verschobene Auftragsdateien + dieser Entwurf).