# Entwurf: Meilensteine, Issues und ADRs für die VS-NfD-Ertüchtigung > **Stufe 2 ausgeführt am 2026-07-30:** ADRs 0019–0026 geschrieben, 11 > Labels, Meilensteine `M24`–`M31` 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:98–105` 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:232–253` 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:108–125`: `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:62–70`). | 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 | 22–28 AT | 26,5–32,5 AT | +4,5 AT | | Phase 1 · P1-1 | 10–12 AT | 11–13 AT | +1 AT | | Phase 1 · P1-2 | 14–18 AT | 14–18 AT | ±0 | | Phase 1 · P1-3 | 8–10 AT | 8–10 AT | ±0 | | Phase 3 Var. A | 8–10 AT | 9–10 AT | +1 AT | | Phase 5 | 15–20 AT | 18–20 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 -v` → `git@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:** `M0`…`M23` plus `Barrierefreiheit WCAG 2.1 AA` (M0–M11 offen, M12–M23 geschlossen). - **Höchste Issue-Nummer:** #187. Nächste freie: **#188**. - **Nächste freie ADR-Nummer:** **0019** (`docs/architecture/adr/`, vorhanden 0001–0018). 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** (0019–0026) | | 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) | 15–18 | 21,5–24,5 | | `VS-NfD: hardening & supply chain` | #199–#203, #236 (6) | 7–10 | 9,5–12,5 | | `VS-NfD: classification metadata` | #204–#213 (10) | 14–18 | 14–18 ✓ | | `VS-NfD: external authentication` | #214–#217 (4) | 10–12 | 11–13 | | `VS-NfD: offline/airgap deployment` | #218–#221 (4) | 8–10 | 7–9 | | `VS-NfD: read-access audit trail` | #222–#225 (4) | 8–10 | 9–10 | | `VS-NfD: compliance documentation` | #226–#231 (6) | 15–20 | 18–20 | | `VS-NfD: backlog` | #232 (1) | 8–10 | 8–10 ✓ | `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` | 2–3 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 (3–5 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 5–6 AT instead of 3–5._ ## 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:108–112`: `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 (1–2 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:530–538` 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`. - `:142–143` — 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:74–91`, 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:5–7` 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 (2–3 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:530–559`) 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///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 (3–4 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 (1–2 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:263–300`) has no classification field. Metadata is `title`, `slug`, `sortKey`, `parentId`, timestamps and relations. - `Label` (`:423–437`) is pond-scoped (`pondId`), hierarchical and user-editable — the reason ADR 0022 rejects labels as the carrier. - `instance_settings` (`:17–23`) 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:255–266`); 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. --- #### #206 — [VS-NfD] Show the classification in the web view header and footer **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. --- #### #207 — [VS-NfD] Add print CSS with a per-page classification header and footer **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 `
` with pond name and title (`:76–79`) plus print CSS for page size and breaks (`:30–31`, `:69–71`). 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). --- #### #209 — [VS-NfD] Give pandoc a reference document with classification header and footer **Plan reference:** `20-massnahmenplan.md` → P1-2 (output channels) **ADR:** ADR 0022 **Effort:** M (2–3 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 `:95–108` 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 (2–3 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 (2–3 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 (1–2 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 (5–6 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:562–577`) — "`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:"`, `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 (2–3 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 (2–3 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/` + 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 (2–3 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:108–125` — 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:74–91`) 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 (1–2 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-`), 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 (4–5 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 (3–4 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 (8–10 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:17–23`). ## 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:746–784`). 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:683–697`); 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:25–44`). ## 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. --- #### #235 — [VS-NfD] Decide the fate of `page_links` rows pointing at purged pages **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:441–461`). 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:7–10`: `"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` ```markdown # 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` ```markdown # 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` ```markdown # 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:"` 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:"`, `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` ```markdown # 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` ```markdown # 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 (18–20 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 (8–10 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` ```markdown # 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` ```markdown # 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 8–10 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` ```markdown # 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 wins` … `M31 — 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 4–5 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 ~80–101 AT statt 75–96 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 - [x] 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). - [x] 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. - [x] Jedes Issue hat Meilenstein, Effort-Label, Area-Label und Akzeptanzkriterien — Area bei #197/#201 vorbehaltlich 6.3. - [x] Jedes Issue mit Architekturbezug verweist auf ein ADR; jedes ADR listet seine umsetzenden Issues. - [x] 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. - [x] `git status`: nur `docs/vs-nfd/` betroffen (drei verschobene Auftragsdateien + dieser Entwurf).