All checks were successful
CI / Lint, typecheck, test (pull_request) Successful in 4m42s
CI / Build container images (pull_request) Successful in 1m11s
CI / Auth e2e pack (pull_request) Successful in 7m47s
CI / Import/export fidelity gate (pull_request) Successful in 55s
CD / Build and push images (push) Successful in 18s
CD / Deploy to Test (push) Successful in 14s
CD / Smoke tests against Test (push) Successful in 1m16s
CD / Promote to Int (push) Successful in 11s
CI / Lint, typecheck, test (push) Successful in 4m50s
CI / Build container images (push) Has been skipped
CI / Auth e2e pack (push) Successful in 7m38s
CI / Import/export fidelity gate (push) Successful in 56s
Add docs/vs-nfd/: the analysis brief, the as-is assessment (42 findings, all verified against the code), the prioritized action plan rev. 2 with issue references written back to every checkbox, the two-stage issue/ADR brief, and the full reviewed draft used to create the forge state. Add eight proposed ADRs 0019-0026 covering the VS-NfD architecture decisions: no security base functions (par. 52 VSA anchor), HKDF token key separation, external authentication, page classification, read-access audit trail (variant A), reproducible offline deployment, plugin trust model, and backup target restriction. Forge state created alongside this commit: 11 labels, milestones M24-M31, issues #188-#236 (docs-only change, no code touched). Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_0168Ph5uBmHm8X28CSVpbpnJ
3193 lines
131 KiB
Markdown
3193 lines
131 KiB
Markdown
# 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/<id>/<version>/frame`), install/uninstall, and the
|
||
pond-level toggles.
|
||
- [ ] With the switch off, existing plugin blocks in documents render their
|
||
declared `fallback` instead of an error, and the editor offers no
|
||
plugin blocks.
|
||
- [ ] Cache note respected: the settings cache is in-process, so the
|
||
documented procedure includes an api restart (or the setting is read
|
||
uncached) — verified by test or documented explicitly.
|
||
- [ ] Tests: every plugin route 404s while off; a page containing a plugin
|
||
block still renders; the switch is visible in the admin UI.
|
||
- [ ] Hardening guide (#227) lists the setting; `docs/architecture/plugin-architecture.md`
|
||
records the switch.
|
||
|
||
## Out of scope
|
||
|
||
Allowlisting or hash-pinning individual plugins (#232), and removing the
|
||
plugin architecture.
|
||
|
||
---
|
||
|
||
#### #201 — [VS-NfD] Define a stable event catalogue for syslog/SIEM export
|
||
|
||
**Plan reference:** `20-massnahmenplan.md` → Phase 2 (pulled from roadmap)
|
||
**ADR:** n/a
|
||
**Effort:** L (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 `<header class="pdf-header">` 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:<issuer>' later", `@@unique([provider, subject])`,
|
||
`credential` holds the Argon2id hash for password identities.
|
||
- **No OIDC implementation exists**: a search for `oidc` across
|
||
`apps/api/src` and `packages/shared/src` returns nothing outside that
|
||
comment. ADR 0007 declares the readiness, not the feature.
|
||
|
||
## Acceptance criteria
|
||
|
||
- [ ] Authorization Code flow with PKCE, state and nonce validation,
|
||
discovery-based configuration, and JWKS-based token validation using
|
||
the vetted library from #188 — no hand-rolled JWT verification.
|
||
- [ ] Identity linking follows the existing model:
|
||
`provider = "oidc:<issuer>"`, `subject` from the token; an existing
|
||
local user is linked by a documented, deliberate rule (not silently
|
||
by e-mail).
|
||
- [ ] Login, logout (including IdP-initiated single logout or a documented
|
||
decision against it) and session creation reuse the existing session
|
||
service — no parallel session mechanism.
|
||
- [ ] Verified against a Keycloak instance; the setup used is documented so
|
||
the test is repeatable.
|
||
- [ ] Tests: successful login creates/links the identity; invalid state,
|
||
nonce, signature, issuer and audience each rejected; expired token
|
||
rejected.
|
||
- [ ] `docs/architecture/security.md` and #227 document configuration;
|
||
ADR 0021 records the decisions.
|
||
|
||
## Out of scope
|
||
|
||
SAML and LDAP, claim-to-role mapping (#217), and disabling local
|
||
authentication (#216).
|
||
|
||
---
|
||
|
||
#### #215 — [VS-NfD] Support a trusted reverse-proxy header or mTLS client certificate as an alternative path
|
||
|
||
**Plan reference:** `20-massnahmenplan.md` → P1-1
|
||
**ADR:** ADR 0021
|
||
**Effort:** M (2 AT)
|
||
**Depends on:** #214
|
||
**Labels:** `vs-nfd` `effort:M` `area:auth`
|
||
|
||
## Context
|
||
|
||
Some authority environments terminate authentication at the perimeter and
|
||
expect the application to trust it. Supporting that avoids forcing an IdP
|
||
into an architecture that already solved authentication — but a trusted
|
||
header is a loaded gun if it is trusted unconditionally.
|
||
|
||
## Current state
|
||
|
||
- Authentication is cookie/session based
|
||
(`apps/api/src/auth/auth.guard.ts`, `sessions.service.ts`) plus PATs; no
|
||
header- or certificate-based identity path exists.
|
||
|
||
## Acceptance criteria
|
||
|
||
- [ ] Header-based identity is **off by default** and requires both an
|
||
explicit switch and an allowlist of trusted peer addresses; a request
|
||
arriving from an untrusted peer with the header is rejected and
|
||
audited.
|
||
- [ ] The configured header name and the identity mapping are explicit
|
||
configuration, never guessed.
|
||
- [ ] mTLS variant: the certificate subject/attribute used as identity is
|
||
configurable, with the same trust-boundary rules.
|
||
- [ ] Tests: header from untrusted peer ignored and audited; header from
|
||
trusted peer authenticates; header ignored entirely while the switch
|
||
is off; spoofed header alongside a session cookie does not escalate.
|
||
- [ ] `docs/architecture/security.md` documents the trust boundary
|
||
unambiguously — this is the section an assessor will read closest.
|
||
|
||
## Out of scope
|
||
|
||
Terminating TLS in the application, and certificate lifecycle management.
|
||
|
||
---
|
||
|
||
#### #216 — [VS-NfD] Add a hard `auth.local.enabled = false` switch covering every local credential flow
|
||
|
||
**Plan reference:** `20-massnahmenplan.md` → P1-1
|
||
**ADR:** ADR 0021
|
||
**Effort:** M (2 AT)
|
||
**Depends on:** #214
|
||
**Labels:** `vs-nfd` `vs-nfd:blocker` `effort:M` `area:auth`
|
||
|
||
## Context
|
||
|
||
Delegating authentication only counts if the local path is actually closed
|
||
— including the flows people forget: password reset, self-service signup,
|
||
personal access tokens and feed tokens. A half-closed local path is worse
|
||
than none, because the operating concept then describes something untrue.
|
||
|
||
## Current state
|
||
|
||
- Local passwords: `UserIdentity.credential` (Argon2id),
|
||
`apps/api/src/auth/`; token flows in
|
||
`apps/api/src/auth/auth-tokens.service.ts`,
|
||
`apps/api/src/public-api/api-tokens.service.ts` (PATs) and
|
||
`apps/api/src/public/feed-tokens.service.ts`.
|
||
- Switch precedent to follow: `api.enabled` / `mcp.enabled` in
|
||
`apps/api/src/settings/instance-settings.service.ts`, enforced as 404.
|
||
- No switch for local authentication exists.
|
||
|
||
## Acceptance criteria
|
||
|
||
- [ ] With the switch off: password login, signup, password reset,
|
||
e-mail-verification-as-login and any other credential-issuing flow
|
||
are unreachable (404/403 consistently with the project's
|
||
404/403 policy) — enumerated in the test, not assumed.
|
||
- [ ] PAT and feed-token issuance behaviour with the switch off is a
|
||
**stated decision** (blocked, or allowed only for IdP-authenticated
|
||
users), tested either way.
|
||
- [ ] The first-run setup wizard's local admin creation is addressed
|
||
explicitly — bootstrapping must remain possible without reopening
|
||
the local path in normal operation.
|
||
- [ ] A route-enumeration test proves no authentication route is
|
||
accidentally left open (extend the existing enumeration fence).
|
||
- [ ] The switch is deploy-level, not merely a runtime setting a
|
||
compromised Site-Admin could flip back — or, if runtime, that
|
||
residual risk is documented in #231.
|
||
- [ ] #227 makes "local auth off" part of the reference configuration.
|
||
|
||
## Out of scope
|
||
|
||
Migrating existing users to the IdP, and deleting stored password hashes.
|
||
|
||
---
|
||
|
||
#### #217 — [VS-NfD] Map IdP groups and roles onto the permission model
|
||
|
||
**Plan reference:** `20-massnahmenplan.md` → P1-1
|
||
**ADR:** ADR 0021
|
||
**Effort:** M (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/<x>` + build).
|
||
|
||
## Acceptance criteria
|
||
|
||
- [ ] Either a pnpm offline store / vendored dependency set makes
|
||
`pnpm install` and `pnpm build` succeed with networking disabled,
|
||
**or** the decision "prebuilt images only, no customer-side build" is
|
||
documented with its consequences (no local patching).
|
||
- [ ] Whichever path: reproduced twice from a clean checkout with identical
|
||
results, and the procedure written down.
|
||
- [ ] Toolchain versions (node, pnpm, base images) are pinned and stated.
|
||
- [ ] Documented in #229; the decision recorded in ADR 0024.
|
||
|
||
## Out of scope
|
||
|
||
Bit-for-bit reproducible builds as a formal property, and mirroring
|
||
(#218).
|
||
|
||
---
|
||
|
||
#### #220 — [VS-NfD] Run and document a deployment in a network-isolated environment
|
||
|
||
**Plan reference:** `20-massnahmenplan.md` → P1-3
|
||
**ADR:** ADR 0024
|
||
**Effort:** M (2 AT)
|
||
**Depends on:** #218, #219
|
||
**Labels:** `vs-nfd` `effort:M` `area:supply-chain`
|
||
|
||
## Context
|
||
|
||
This is the issue that turns the airgap story from plausible into
|
||
verified — the plan's reason for pulling P1-3 forward. It also answers the
|
||
plan's open question "what breaks without internet access", which is not
|
||
answerable by reading code.
|
||
|
||
## Current state
|
||
|
||
- Existing strengths to confirm rather than build: no telemetry, no update
|
||
checks, no CDNs, self-hosted fonts (ADR 0016), CSP `default-src 'self'`,
|
||
Postgres full-text search instead of an external engine, drawio vendored
|
||
(§0.2).
|
||
- Whether an egress-blocked deployment is fully functional has never been
|
||
tested — the open question in `20-massnahmenplan.md`.
|
||
|
||
## Acceptance criteria
|
||
|
||
- [ ] A full deployment runs with egress blocked: install, first-run setup,
|
||
login, editing with live collaboration, search, upload, all export
|
||
formats (PDF via Gotenberg, DOCX/ODT via pandoc), backup and restore.
|
||
- [ ] Every outbound connection attempt is captured and listed; each is
|
||
either eliminated or documented as required with its purpose.
|
||
- [ ] Behaviour of outbound e-mail (SMTP) without egress is stated
|
||
explicitly — it is the one connection an authority may or may not
|
||
permit.
|
||
- [ ] A written protocol (date, environment, versions by digest, results,
|
||
deviations) exists as assessor-facing evidence.
|
||
- [ ] Findings feed back into #221 and #229.
|
||
|
||
## Out of scope
|
||
|
||
Fixing whatever the run uncovers — that becomes its own issue, referenced
|
||
from here.
|
||
|
||
---
|
||
|
||
#### #221 — [VS-NfD] Define the offline update path including migrations
|
||
|
||
**Plan reference:** `20-massnahmenplan.md` → P1-3
|
||
**ADR:** ADR 0024
|
||
**Effort:** M (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-<pageId>`), cleared on leave once synced but
|
||
deliberately kept for unsynced offline edits.
|
||
Confidentiality of that copy is endpoint media protection, i.e. the
|
||
platform's function — but it must be stated, not omitted
|
||
(`10-ist-aufnahme.md` → I-25).
|
||
- [ ] Reviewed against ADR 0019 for consistency; divergences resolve in
|
||
the ADR, not in the statement.
|
||
|
||
## Out of scope
|
||
|
||
Legal review by counsel, and the IT-Grundschutz mapping (#230).
|
||
|
||
---
|
||
|
||
#### #227 — [VS-NfD] Write the hardening guide with a "VS-NfD operation" reference configuration
|
||
|
||
**Plan reference:** `20-massnahmenplan.md` → Phase 5
|
||
**ADR:** ADR 0019
|
||
**Effort:** L (3 AT)
|
||
**Depends on:** #191, #192, #200, #216
|
||
**Labels:** `vs-nfd` `effort:L` `area:docs`
|
||
|
||
## Context
|
||
|
||
One named configuration an operator can adopt wholesale is worth more than
|
||
a list of options. It is also the artefact that makes the switches built in
|
||
M1/M2/M4 auditable.
|
||
|
||
## Current state
|
||
|
||
- `docs/self-hosting/README.md` covers ordinary self-hosting; no hardened
|
||
profile exists.
|
||
- Existing switches that belong in the profile: `api.enabled` (default
|
||
off), `mcp.enabled` (default off), `upload.svgPolicy`,
|
||
`upload.allowedExtensions`, backup settings — all in
|
||
`apps/api/src/settings/instance-settings.service.ts`.
|
||
|
||
## Acceptance criteria
|
||
|
||
- [ ] A complete reference configuration for VS-NfD operation: local auth
|
||
off, public API off, MCP off, feeds off, plugins off, backup local
|
||
only — each with the exact setting name and value.
|
||
- [ ] Every entry states **why**, so an operator can deviate knowingly.
|
||
- [ ] Settings added by M1/M2/M4 are included; the guide is updated in the
|
||
same PR as each new switch (stated as a rule, not a hope).
|
||
- [ ] A verification script or checklist lets an operator confirm the
|
||
profile is active on a running instance.
|
||
- [ ] Cross-referenced from #226 and #230.
|
||
|
||
## Out of scope
|
||
|
||
Hardening the operator's platform (OS, network, reverse proxy).
|
||
|
||
---
|
||
|
||
#### #228 — [VS-NfD] Write the security documentation (architecture, data flows, network plan, ports, trust boundaries)
|
||
|
||
**Plan reference:** `20-massnahmenplan.md` → Phase 5
|
||
**ADR:** ADR 0019
|
||
**Effort:** L (4 AT)
|
||
**Depends on:** —
|
||
**Labels:** `vs-nfd` `effort:L` `area:docs`
|
||
|
||
## Context
|
||
|
||
This is the document an assessor reads first and returns to. It has to be
|
||
accurate to the digit on ports, services and trust boundaries.
|
||
|
||
## Current state
|
||
|
||
- Substantial material exists and needs consolidating rather than
|
||
inventing: `docs/architecture/security.md`, `deployment.md`,
|
||
`data-model.md`, `permissions.md`, `realtime-collaboration.md`,
|
||
`plugin-architecture.md`, plus `deploy/compose/docker-compose.yml` as the
|
||
authoritative service and port list (web, api, collab, backup, db,
|
||
gotenberg, pandoc, caddy).
|
||
|
||
## Acceptance criteria
|
||
|
||
- [ ] Component diagram with every service, its purpose and its
|
||
privileges.
|
||
- [ ] Data-flow diagrams for: authentication, editing (api ↔ collab ↔
|
||
Postgres `LISTEN/NOTIFY`), export (pandoc/Gotenberg sidecars),
|
||
backup, and each read channel from #222.
|
||
- [ ] Network plan with all ports and protocols, internal versus
|
||
externally exposed, matching the compose files exactly.
|
||
- [ ] Trust boundaries named explicitly, including the plugin sandbox
|
||
(ADR 0008) and the reverse-proxy boundary from #215.
|
||
- [ ] Every content copy is listed — database, uploads, `page_updates`,
|
||
`page_content_cache` including the search vector, versions, backups,
|
||
export artefacts — because the deletion concept in #229 depends on
|
||
this list being complete.
|
||
- [ ] Diagrams are maintainable as text (the repo already renders
|
||
Mermaid), not binary images.
|
||
|
||
## Out of scope
|
||
|
||
Penetration test and threat model as separate deliverables.
|
||
|
||
---
|
||
|
||
#### #229 — [VS-NfD] Write the operations manual (installation, update, backup/restore, deletion, role separation)
|
||
|
||
**Plan reference:** `20-massnahmenplan.md` → Phase 5
|
||
**ADR:** ADR 0019
|
||
**Effort:** L (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:<issuer>"` later, with `@@unique([provider, subject])` already in
|
||
place. No OIDC code exists — the readiness is structural only.
|
||
|
||
Authentication is a security base function under §52 VSA (ADR 0019), so it
|
||
belongs to the operator's platform. An authority environment additionally
|
||
brings its own account lifecycle: joiners, movers and leavers are managed
|
||
in the IdP, and a second account store inside the application would drift
|
||
from it.
|
||
|
||
Some environments terminate authentication at the perimeter instead and
|
||
expect the application to trust a header or a client certificate.
|
||
|
||
## Decision
|
||
|
||
1. **OIDC Authorization Code with PKCE is the primary path**, configured by
|
||
discovery, validated against JWKS. Keycloak is the reference IdP we
|
||
verify against; nothing in the implementation is Keycloak-specific.
|
||
2. **Identities use the existing slot**: `provider = "oidc:<issuer>"`,
|
||
`subject` from the token. Linking an OIDC identity to an existing local
|
||
user follows an explicit, documented rule — never silently by e-mail
|
||
address, which would be an account-takeover path.
|
||
3. **Local authentication is switchable off in full**, via
|
||
`auth.local.enabled = false`. "In full" means every credential-issuing
|
||
flow: password login, self-service signup, password reset,
|
||
verification-as-login, and the token flows (PAT, feed tokens). A
|
||
half-closed local path makes the operating concept untrue, which is
|
||
worse than not closing it.
|
||
4. **Proxy header and mTLS are a supported alternative path, off by
|
||
default.** When enabled they require an allowlist of trusted peers; a
|
||
request carrying the header from an untrusted peer is rejected and
|
||
audited. The trust boundary is stated explicitly in the security
|
||
documentation.
|
||
5. **Claims map onto the existing permission model** declaratively, and
|
||
mapped grants are written through the same service path as manual ones
|
||
so the permission cache stays correct. The application gains no second
|
||
authorization model.
|
||
6. **No MFA, no password policy engine of our own** (ADR 0019). Both are
|
||
the IdP's.
|
||
|
||
## Consequences
|
||
|
||
- Bootstrapping needs a documented answer: the first-run wizard creates a
|
||
local admin, so either it stays exempt with a stated compensating
|
||
control, or setup itself runs against the IdP. The choice is recorded in
|
||
#216.
|
||
- Whether the switch is deploy-level or runtime matters: a runtime setting
|
||
can be flipped back by a compromised Site-Admin. If it stays runtime,
|
||
that residual risk goes into #231.
|
||
- Existing password hashes remain in the database after the switch. Their
|
||
deletion is out of scope and, being Argon2id, they are not a
|
||
confidentiality problem — but the fact is documented.
|
||
- Session handling is unchanged: OIDC produces a session through the same
|
||
service, so there is exactly one session mechanism (see #190 for its
|
||
bounds).
|
||
- SAML and LDAP stay out. OIDC plus proxy/mTLS covers the environments we
|
||
target; adding SAML would be a new decision.
|
||
|
||
## Implementing issues
|
||
|
||
#214 (OIDC + PKCE), #215 (proxy header / mTLS), #216
|
||
(`auth.local.enabled`), #217 (claim mapping). Depends on #188 for the
|
||
vetted JWT implementation.
|
||
```
|
||
|
||
---
|
||
|
||
### ADR 0022 — `docs/architecture/adr/0022-page-classification.md`
|
||
|
||
```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).
|