From fd07f716f61d830d869e9cdecb1f6c19540ab6fc Mon Sep 17 00:00:00 2001 From: Claude Opus 5 Date: Thu, 30 Jul 2026 01:48:05 +0200 Subject: [PATCH] docs: VS-NfD readiness planning (ist-aufnahme, plan, ADRs 0019-0026, issue drafts) Add docs/vs-nfd/: the analysis brief, the as-is assessment (42 findings, all verified against the code), the prioritized action plan rev. 2 with issue references written back to every checkbox, the two-stage issue/ADR brief, and the full reviewed draft used to create the forge state. Add eight proposed ADRs 0019-0026 covering the VS-NfD architecture decisions: no security base functions (par. 52 VSA anchor), HKDF token key separation, external authentication, page classification, read-access audit trail (variant A), reproducible offline deployment, plugin trust model, and backup target restriction. Forge state created alongside this commit: 11 labels, milestones M24-M31, issues #188-#236 (docs-only change, no code touched). Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_0168Ph5uBmHm8X28CSVpbpnJ --- .../adr/0019-no-security-base-functions.md | 82 + .../adr/0020-token-crypto-key-separation.md | 59 + .../adr/0021-external-authentication.md | 71 + .../adr/0022-page-classification.md | 78 + .../adr/0023-read-access-audit-trail.md | 70 + .../0024-reproducible-offline-deployment.md | 63 + .../adr/0025-plugin-trust-model.md | 61 + .../adr/0026-backup-target-restriction.md | 53 + docs/vs-nfd/00-analyse-auftrag.md | 135 + docs/vs-nfd/10-ist-aufnahme.md | 467 +++ docs/vs-nfd/20-massnahmenplan.md | 281 ++ docs/vs-nfd/30-issue-adr-auftrag.md | 194 + docs/vs-nfd/31-issue-entwurf.md | 3192 +++++++++++++++++ 13 files changed, 4806 insertions(+) create mode 100644 docs/architecture/adr/0019-no-security-base-functions.md create mode 100644 docs/architecture/adr/0020-token-crypto-key-separation.md create mode 100644 docs/architecture/adr/0021-external-authentication.md create mode 100644 docs/architecture/adr/0022-page-classification.md create mode 100644 docs/architecture/adr/0023-read-access-audit-trail.md create mode 100644 docs/architecture/adr/0024-reproducible-offline-deployment.md create mode 100644 docs/architecture/adr/0025-plugin-trust-model.md create mode 100644 docs/architecture/adr/0026-backup-target-restriction.md create mode 100644 docs/vs-nfd/00-analyse-auftrag.md create mode 100644 docs/vs-nfd/10-ist-aufnahme.md create mode 100644 docs/vs-nfd/20-massnahmenplan.md create mode 100644 docs/vs-nfd/30-issue-adr-auftrag.md create mode 100644 docs/vs-nfd/31-issue-entwurf.md diff --git a/docs/architecture/adr/0019-no-security-base-functions.md b/docs/architecture/adr/0019-no-security-base-functions.md new file mode 100644 index 0000000..47b7756 --- /dev/null +++ b/docs/architecture/adr/0019-no-security-base-functions.md @@ -0,0 +1,82 @@ +# 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). diff --git a/docs/architecture/adr/0020-token-crypto-key-separation.md b/docs/architecture/adr/0020-token-crypto-key-separation.md new file mode 100644 index 0000000..ea86796 --- /dev/null +++ b/docs/architecture/adr/0020-token-crypto-key-separation.md @@ -0,0 +1,59 @@ +# 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. diff --git a/docs/architecture/adr/0021-external-authentication.md b/docs/architecture/adr/0021-external-authentication.md new file mode 100644 index 0000000..e045a2a --- /dev/null +++ b/docs/architecture/adr/0021-external-authentication.md @@ -0,0 +1,71 @@ +# ADR 0021: External authentication via OIDC; local passwords optional + +- Status: proposed +- Date: 2026-07-29 + +## Context + +ADR 0007 established sessions and identities with OIDC in mind: +`UserIdentity.provider` is documented as `"password"` today and +`"oidc:"` later, with `@@unique([provider, subject])` already in +place. No OIDC code exists — the readiness is structural only. + +Authentication is a security base function under §52 VSA (ADR 0019), so it +belongs to the operator's platform. An authority environment additionally +brings its own account lifecycle: joiners, movers and leavers are managed +in the IdP, and a second account store inside the application would drift +from it. + +Some environments terminate authentication at the perimeter instead and +expect the application to trust a header or a client certificate. + +## Decision + +1. **OIDC Authorization Code with PKCE is the primary path**, configured by + discovery, validated against JWKS. Keycloak is the reference IdP we + verify against; nothing in the implementation is Keycloak-specific. +2. **Identities use the existing slot**: `provider = "oidc:"`, + `subject` from the token. Linking an OIDC identity to an existing local + user follows an explicit, documented rule — never silently by e-mail + address, which would be an account-takeover path. +3. **Local authentication is switchable off in full**, via + `auth.local.enabled = false`. "In full" means every credential-issuing + flow: password login, self-service signup, password reset, + verification-as-login, and the token flows (PAT, feed tokens). A + half-closed local path makes the operating concept untrue, which is + worse than not closing it. +4. **Proxy header and mTLS are a supported alternative path, off by + default.** When enabled they require an allowlist of trusted peers; a + request carrying the header from an untrusted peer is rejected and + audited. The trust boundary is stated explicitly in the security + documentation. +5. **Claims map onto the existing permission model** declaratively, and + mapped grants are written through the same service path as manual ones + so the permission cache stays correct. The application gains no second + authorization model. +6. **No MFA, no password policy engine of our own** (ADR 0019). Both are + the IdP's. + +## Consequences + +- Bootstrapping needs a documented answer: the first-run wizard creates a + local admin, so either it stays exempt with a stated compensating + control, or setup itself runs against the IdP. The choice is recorded in + #216. +- Whether the switch is deploy-level or runtime matters: a runtime setting + can be flipped back by a compromised Site-Admin. If it stays runtime, + that residual risk goes into #231. +- Existing password hashes remain in the database after the switch. Their + deletion is out of scope and, being Argon2id, they are not a + confidentiality problem — but the fact is documented. +- Session handling is unchanged: OIDC produces a session through the same + service, so there is exactly one session mechanism (see #190 for its + bounds). +- SAML and LDAP stay out. OIDC plus proxy/mTLS covers the environments we + target; adding SAML would be a new decision. + +## Implementing issues + +#214 (OIDC + PKCE), #215 (proxy header / mTLS), #216 +(`auth.local.enabled`), #217 (claim mapping). Depends on #188 for the +vetted JWT implementation. diff --git a/docs/architecture/adr/0022-page-classification.md b/docs/architecture/adr/0022-page-classification.md new file mode 100644 index 0000000..1eb2174 --- /dev/null +++ b/docs/architecture/adr/0022-page-classification.md @@ -0,0 +1,78 @@ +# 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). diff --git a/docs/architecture/adr/0023-read-access-audit-trail.md b/docs/architecture/adr/0023-read-access-audit-trail.md new file mode 100644 index 0000000..8d8c7e9 --- /dev/null +++ b/docs/architecture/adr/0023-read-access-audit-trail.md @@ -0,0 +1,70 @@ +# 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. diff --git a/docs/architecture/adr/0024-reproducible-offline-deployment.md b/docs/architecture/adr/0024-reproducible-offline-deployment.md new file mode 100644 index 0000000..516cc0d --- /dev/null +++ b/docs/architecture/adr/0024-reproducible-offline-deployment.md @@ -0,0 +1,63 @@ +# 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). diff --git a/docs/architecture/adr/0025-plugin-trust-model.md b/docs/architecture/adr/0025-plugin-trust-model.md new file mode 100644 index 0000000..010bc00 --- /dev/null +++ b/docs/architecture/adr/0025-plugin-trust-model.md @@ -0,0 +1,61 @@ +# 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). diff --git a/docs/architecture/adr/0026-backup-target-restriction.md b/docs/architecture/adr/0026-backup-target-restriction.md new file mode 100644 index 0000000..2db9c2b --- /dev/null +++ b/docs/architecture/adr/0026-backup-target-restriction.md @@ -0,0 +1,53 @@ +# 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). diff --git a/docs/vs-nfd/00-analyse-auftrag.md b/docs/vs-nfd/00-analyse-auftrag.md new file mode 100644 index 0000000..c110d81 --- /dev/null +++ b/docs/vs-nfd/00-analyse-auftrag.md @@ -0,0 +1,135 @@ +# Analyse-Auftrag: VS-NfD-Ist-Aufnahme Dorfteich + +> Diese Datei in das Repo legen (z. B. `docs/vs-nfd/00-analyse-auftrag.md`) +> und Claude Code anweisen: _„Arbeite `docs/vs-nfd/00-analyse-auftrag.md` ab."_ + +--- + +## Rahmen + +**Ziel des Vorhabens:** Dorfteich soll in einer nach VSA freigegebenen +IT-Umgebung einer Bundesbehörde betrieben werden können — Einstufung +VS-NfD. Es wird **keine** eigene BSI-Zulassung angestrebt. + +**Leitprinzip, an dem alles zu messen ist:** +Dorfteich darf **keine Sicherheitsgrundfunktion im Sinne von §52 VSA selbst +implementieren**. Verschlüsselung, Authentisierung, Netzabschluss und +Datenträgerschutz gehören auf die Plattform der Behörde. Jede Stelle, an der +die Anwendung selbst schützt statt zu delegieren, ist ein Befund. + +**Diese Analyse ist read-only.** Keine Codeänderungen, keine Refactorings, +keine Bugfixes. Nur Befunde. + +--- + +## Arbeitsweise + +- Jeder Befund braucht einen **Fundort**: `pfad/zur/datei.ts:123`. +- Wo du unsicher bist, schreib „unklar" statt zu raten. Eine ehrliche + Wissenslücke ist brauchbar, eine erfundene Antwort ist gefährlich. +- Keine Verbesserungsvorschläge im Fließtext — dafür gibt es die Spalte + „Handlungsbedarf". +- Bewertungsskala pro Befund: + - **OK** — unkritisch, keine Anpassung nötig + - **ANPASSEN** — muss geändert werden, aber überschaubar + - **BLOCKIEREND** — verhindert den Einsatz, bis es gelöst ist + - **UNKLAR** — nicht abschließend bewertbar + +--- + +## 1. Sicherheitsgrundfunktionen (höchste Priorität) + +Beantworte präzise, was die Anwendung **selbst** tut: + +1. **Authentisierung** — Gibt es eine eigene Benutzer-/Passwortdatenbank? + Welches Hashing-Verfahren? Existiert bereits OIDC-/SAML-/LDAP-Anbindung + oder Unterstützung für Client-Zertifikate / Reverse-Proxy-Header? +2. **Session-Verwaltung** — Eigene Implementierung oder Framework? + Wo liegen Sessions (Cookie, Server, Redis)? Wie lange gültig? +3. **Kryptographie** — Wird irgendwo im Code selbst ver-/entschlüsselt + oder signiert? Suche nach eigenen Krypto-Aufrufen, nicht nur nach + Bibliotheken. Falls ja: Was, womit, warum? +4. **Zugriffskontrolle** — Wie ist das Berechtigungsmodell aufgebaut? + Wo wird es durchgesetzt (zentral im Middleware-Layer oder verstreut)? + Gibt es Pfade, die es umgehen (API, Suche, Export, Anhänge)? +5. **Integrität** — Gibt es Prüfsummen, Signaturen, Manipulationsschutz? + +## 2. Datenhaltung + +6. Wo liegen Seiteninhalte — Datenbank, Filesystem, beides? +7. Wo liegen Anhänge und hochgeladene Dateien? +8. Existiert ein Volltextsuchindex? Welche Technologie, wo persistiert er, + und enthält er Klartext der Seiteninhalte? +9. Welche weiteren Kopien der Inhalte entstehen im Betrieb — Caches, + Thumbnails, Vorschaubilder, Render-Artefakte, Temp-Dateien? +10. Wie ist die Revisions-/Versionshistorie abgelegt? + +## 3. Löschen und Vernichtung + +11. Was passiert beim Löschen einer Seite — Soft- oder Hard-Delete? +12. Werden dabei erfasst: Revisionen, Anhänge, Suchindex, Caches, + Thumbnails, Papierkorb, Backlinks? +13. Gibt es einen Weg, eine Seite samt **aller** Spuren rückstandsfrei zu + entfernen? Falls nein: Was bleibt konkret übrig und wo? + +## 4. Ausgehende Verbindungen + +14. Liste **jede** Stelle, an der die Anwendung eine Verbindung nach außen + aufbaut oder aufbauen könnte: Update-Checks, Telemetrie, Analytics, + Crash-Reporting, Lizenzprüfung, Link-Vorschauen, oEmbed, Avatar-Dienste, + Karten, externe Schriften, Icons, Skripte. +15. Prüfe auch die gepinnten Fremdmodule (Editor, Flowchart) — laden diese + zur Laufzeit etwas nach? +16. Backups: Wohin kann konfiguriert werden? Gibt es eine Einschränkung oder + ist jedes Ziel erlaubt? Werden Backups verschlüsselt, und wenn ja, wie? +17. Läuft die Anwendung vollständig ohne Internetzugang? Was bricht? + +## 5. Ausgabekanäle (für die Kennzeichnungspflicht) + +18. Liste alle Wege, auf denen Inhalte die Anwendung verlassen: + Web-Ansicht, Druck, PDF-/DOCX-Export, API, Feeds, Suchergebnisse, + Anhang-Download, Backup. +19. Gibt es bereits ein Metadatenmodell pro Seite, in das eine Einstufung + aufgenommen werden könnte? Wie ist es strukturiert, und wird es an die + Ausgabekanäle durchgereicht? +20. Wo genau müsste eingegriffen werden, damit ein Kopf-/Fußaufdruck in + _allen_ Ausgaben erscheint? Nenne die konkreten Stellen. + +## 6. Protokollierung + +21. Was wird heute protokolliert — nur Änderungen oder auch Lesezugriffe? +22. Wohin (Datei, stdout, DB)? Ist Syslog-/SIEM-Export möglich? +23. Ist die Aufbewahrungsdauer konfigurierbar? +24. Landen Inhalte oder personenbezogene Daten in Logs, die dort nicht + hingehören? + +## 7. Lieferkette + +25. Erzeuge eine vollständige Abhängigkeitsliste mit Versionen und Lizenzen. +26. Welche Abhängigkeiten sind nicht aus EU-/DACH-Quellen oder haben einen + unklaren Maintainer-Status? +27. Sind alle Versionen tatsächlich gepinnt — auch transitiv (Lockfiles)? +28. Wie läuft das Deployment: Container-Images (welche Basis?), Pakete, + Quellcode? Wäre eine Offline-Installation möglich? + +## 8. Betriebsmodell + +29. Ist die Anwendung mandantenfähig oder Single-Tenant? +30. Welche Konfiguration ist zur Laufzeit änderbar, welche nur beim Deploy? +31. Gibt es Funktionen, die ein Betreiber hart abschalten kann (Feature-Flags)? + +--- + +## Ergebnis + +Schreibe das Resultat nach `docs/vs-nfd/10-ist-aufnahme.md` mit folgendem +Aufbau: + +1. **Management-Zusammenfassung** — max. 15 Zeilen. Wie weit ist Dorfteich + vom Ziel entfernt? Was sind die drei größten Brocken? +2. **Befundtabelle** — je Zeile: Nr. | Thema | Fundort | Ist-Zustand | + Bewertung | Handlungsbedarf | grobe Aufwandsschätzung (S/M/L) +3. **Detailbefunde** — pro Kapitel oben, mit Codeauszügen wo hilfreich +4. **Offene Punkte** — was du nicht klären konntest und warum + +Sortiere die Befundtabelle nach Bewertung: BLOCKIEREND zuerst. diff --git a/docs/vs-nfd/10-ist-aufnahme.md b/docs/vs-nfd/10-ist-aufnahme.md new file mode 100644 index 0000000..b3d7927 --- /dev/null +++ b/docs/vs-nfd/10-ist-aufnahme.md @@ -0,0 +1,467 @@ +# VS-NfD-Ist-Aufnahme Dorfteich + +> Auftrag: `docs/vs-nfd/00-analyse-auftrag.md`. Read-only-Analyse — keine +> Codeänderungen. +> Stand: 2026-07-29, Code-Stand `main` = `32c8baa` (Prod v0.12.0). +> Nachgezogen **nach** dem Maßnahmenplan (`20-massnahmenplan.md`) und dem +> Issue-Entwurf (`31-issue-entwurf.md`): der Plan entstand aus einer +> Analyse, die nie als Datei abgelegt wurde. Alle Befunde hier sind am +> Code verifiziert; wo ein Befund den Plan korrigiert, steht es dabei. + +Leitprinzip der Bewertung: Dorfteich darf **keine Sicherheitsgrundfunktion +im Sinne von §52 VSA selbst implementieren**. Verschlüsselung, +Authentisierung, Netzabschluss und Datenträgerschutz gehören auf die +Plattform der Behörde. Jede Stelle, an der die Anwendung selbst schützt +statt zu delegieren, ist ein Befund. + +Bewertung: **OK** · **ANPASSEN** · **BLOCKIEREND** · **UNKLAR**. +Aufwand: **S** ≤ 1 AT · **M** 2–3 AT · **L** ≥ 4 AT. + +--- + +## 1. Management-Zusammenfassung + +Dorfteich ist näher am Ziel als bei einem Wiki dieser Größe zu erwarten +wäre — aber aus einem Grund, der genau benannt werden muss: Es _erbringt_ +kaum Sicherheitsgrundfunktionen, weil es sie schlicht nicht hat. Keine +Inhalts- oder Backup-Verschlüsselung, kein eigenes MFA, keine Krypto +jenseits von Argon2id-Credential-Hashing und kurzlebigen Token-Signaturen. +Das ist unter §52 VSA die _richtige_ Architektur, nicht eine Lücke — und +so ist es zu vertreten. + +Die drei größten Brocken: + +1. **Authentisierung liegt vollständig in der Anwendung.** Es gibt keine + OIDC-Anbindung (nur den vorbereiteten `UserIdentity.provider`-Slot) und + keinen Weg, die lokale Anmeldung abzuschalten. Solange das so ist, + _ist_ Dorfteich Träger einer Sicherheitsgrundfunktion. +2. **Es gibt kein Einstufungskonzept.** Seiten haben kein + Einstufungsmetadatum, und kein Ausgabekanal kennt einen Aufdruck — + Print-CSS fehlt sogar komplett. Kennzeichnung ist die eine + VS-NfD-Anforderung, die niemand außer der Anwendung erfüllen kann. +3. **Der Offline-/Airgap-Betrieb ist plausibel, aber unbelegt.** Keine + Telemetrie, keine CDNs, keine Update-Checks, Fonts self-hosted, drawio + vendored — nachweislich. Getestet wurde es nie, und alle Images hängen + an Tags statt an Digests (`gotenberg/gotenberg:8` ist ein gleitender + Major-Tag). + +Nebenbefunde mit Substanz: Rohdokument-Bytes jedes Im-/Exports bleiben +unbefristet in `conversion_jobs` liegen, der Volltextindex enthält +Papierkorb-Inhalte, getrashte Teiche werden nie endgültig gelöscht, und +Seiteninhalte landen als Yjs-Kopie in der IndexedDB des Endgeräts. + +**42 Befunde: 5 BLOCKIEREND, 21 ANPASSEN, 4 UNKLAR, 12 OK.** + +--- + +## 2. Befundtabelle + +Sortiert nach Bewertung. Die Spalte „Handlungsbedarf" nennt in Klammern +das Issue aus `31-issue-entwurf.md`, das den Befund adressiert. + +### BLOCKIEREND + +| Nr. | Thema | Fundort | Ist-Zustand | Handlungsbedarf | Aufw. | +| ---- | ---------------------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ----------------------------------------------------------------------------------------- | ----- | +| I-01 | Fremdauthentisierung fehlt | `apps/api/prisma/schema.prisma:562–577`; kein `oidc`-Treffer in `apps/api/src` | `UserIdentity.provider` ist als `"password"` heute / `"oidc:"` später dokumentiert. Implementiert ist nur `password`. ADR 0007 erklärt Bereitschaft, nicht Funktion. | OIDC Auth Code + PKCE gegen den vorhandenen Slot (#214) | L | +| I-02 | Lokale Auth nicht abschaltbar | `apps/api/src/auth/`, `apps/api/src/settings/instance-settings.service.ts` | Kein `auth.local.enabled`. Muster für harte Schalter existiert (`api.enabled`, `mcp.enabled`, beide Default aus), wird für Auth aber nicht genutzt. Betroffen sind auch Reset, Registrierung, PATs, Feed-Tokens. | Harter Schalter über alle Credential-Flows (#216) | M | +| I-03 | Kein Einstufungsmetadatum | `apps/api/prisma/schema.prisma:263–301` | `Page` trägt Titel, Slug, Baumposition, Zeitstempel — kein Feld, das ein Schutzniveau ausdrücken könnte. Labels sind kein Ersatz (I-03a im Detailteil). | Enum-Feld + Vererbung + Herabstufungsrecht (#204, #205) | M | +| I-04 | Kein Einstufungsaufdruck in Ausgaben | `apps/web/src` (kein `@media print`), `apps/api/src/import-export/pdf-html.ts:76–79`, `pandoc.converter.ts`, `export-markdown.ts`, `apps/api/src/public/html-shell.ts` | Kein Kanal kennt einen Aufdruck. Print-CSS fehlt **vollständig** — Browserdruck reproduziert die Bildschirmansicht inkl. Navigation. PDF hat einen Kopf **einmalig** statt je Seite; pandoc läuft ohne Reference-Doc, also ohne Kopf-/Fußzeile. | Sieben Kanal-Issues (#206–#212) | L | +| I-05 | Airgap unverifiziert, Images nur per Tag | `deploy/compose/docker-compose.yml:186,206,221,237`; `.gitea/workflows/ci.yml` | `postgres:17.5-alpine`, `pandoc/core:3.6`, `gotenberg/gotenberg:8`, `caddy:2.10-alpine` — Tags, keine Digests; `gotenberg:8` gleitet über Minor/Patch. Kein Mirror-Verfahren, kein Offline-Update-Pfad, kein Testlauf. | Digest-Pinning, Mirror, Offline-Build, isolierter Testlauf, Update-Pfad (#203, #218–#221) | L | + +### ANPASSEN + +| Nr. | Thema | Fundort | Ist-Zustand | Handlungsbedarf | Aufw. | +| ---- | ---------------------------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------- | ----- | +| I-06 | Ein Secret für zwei Zwecke | `packages/shared/src/env.ts:55,142`; `apps/collab/src/index.ts:46`; `apps/api/src/pages/pages.service.ts:383`; `apps/api/src/notifications/digest.service.ts:161`; `notifications.controller.ts:51` | `COLLAB_TOKEN_SECRET` signiert Collab-Tokens **und** Unsubscribe-Tokens. Teil-Trennung existiert textuell über ein `PURPOSE`-Präfix (`unsubscribe-token.ts:14`), nicht über Schlüssel. | HKDF-Subkeys je Zweck (#188) | L | +| I-07 | Eigenbau-HMAC-JWT | `packages/shared/src/token-crypto.ts:1,37,74` | Kompakter HS256-JWT handgebaut auf `node:crypto`; nur HS256, Signatur in konstanter Zeit vor jedem Lesen ungeprüfter Felder. Sorgfältig — aber handgeschriebene Krypto in der Vertrauensgrenze. Grund ist dokumentiert: identischer Code in CommonJS-api und ESM-collab. Weder `jose` noch `jsonwebtoken` ist Dependency. | Ersatz durch `jose`, Cross-Runtime-Test als Zaun (#188) | L | +| I-08 | CSRF lässt fehlende Header durch | `apps/api/src/auth/auth.guard.ts:108–112` | `assertSameOrigin` nimmt `origin ?? referer`; fehlen **beide**, kehrt die Prüfung ohne Entscheidung zurück. Kommentar nennt die Absicht (Nicht-Browser-Clients; SameSite als eigentliche Abwehr). Fail-open bleibt es trotzdem. | Fail-closed mit dokumentierter Ausnahme für Bearer-Clients (#189) | S | +| I-09 | Session 30 Tage, hart kodiert | `apps/api/src/auth/sessions.service.ts:8,31,50`; `apps/api/src/auth/auth.guard.ts:58` | `SESSION_TTL_MS = 30 * 24 * 60 * 60 * 1000 // sliding 30 days`, bei jeder Berührung erneuert; Cookie-`maxAge` gleich. Kein Idle-Timeout — `lastSeenAt` wird geschrieben, aber nie als Grenze ausgewertet. Nicht konfigurierbar. | Absolute + Idle-Grenze konfigurierbar, Default deutlich darunter (#190) | M | +| I-10 | Feed-Token im Query-Parameter | `apps/api/src/public/public.controller.ts:28,43`; `feed.service.ts:25` | Langlebiges Lese-Credential als `?token=…` — landet in Proxy-Logs, Historie, Referrer. Speicherung ist gehasht (`feed-tokens.service.ts:73`), das Problem ist der Transport. Kein Instanzschalter für Feeds. | Token aus dem Query holen oder `feeds.enabled` (#191) | M | +| I-11 | Backup-Ziel frei wählbar | `apps/api/src/settings/instance-settings.service.ts` (`backup.nextcloud.baseUrl`); `apps/api/src/backup/backup-target.service.ts:3,65`; `apps/api/src/admin/backup-admin.service.ts:27,112,146`; `apps/backup/src/mirror.ts` | WebDAV-Ziel ist als URL validiert, aber auf keinen Host beschränkt; zweiter Remote-Pfad ist der rsync-Mirror. Keine Allowlist, kein Deploy-Kill-Switch. Wer Site-Admin hat, kann eine Vollkopie der Instanz umleiten. | Deploy-Allowlist, leere Liste = nur lokal (#192) | M | +| I-12 | Kein Pond-Purge | `apps/api/prisma/schema.prisma:175,186,277,432,553`; `apps/api/src/trash/trash.service.ts` | `Pond.deletedAt` bildet den Teich-Papierkorb; endgültiges Löschen gibt es **nur für Seiten** (`purgeNow:96`, `purgeDuePages:104`, `purgePage:121`). `Page.pond`, `Attachment.pond` und `Label.pond` haben keine `onDelete`-Aktion → Prisma-Default `Restrict` blockt das Löschen ohnehin. Getrashte Teiche bleiben unbegrenzt liegen. | Pond-Purge über alle abhängigen Tabellen (#193) | M | +| I-13 | Kein Orphan-File-Sweep | `apps/api/prisma/schema.prisma:530–538`; registrierte Jobs in `apps/api/src/*/*.module.ts` | Das Schema sagt es selbst: der `pageId`-Link wird nicht angefasst, wenn ein Bild später aus dem Inhalt entfernt wird — „an orphan-file sweep … is a separate future maintenance job"; „`deletedAt` stays unused for now". Von fünf registrierten Jobs (`version-thinning`, `page-compaction`, `trash-purge`, `data-export-purge`, `notification-digest`) ist keiner der Sweep. | Sweep implementieren, `deletedAt` nutzen oder entfernen (#194) | M | +| I-14 | Papierkorb im Suchindex | `apps/api/src/search/postgres-search.provider.ts:41,73,142–143` | Der gewichtete `tsvector` liegt auf `page_content_cache.search_vector`; getrashte Inhalte bleiben **im Index** und werden nur query-seitig ausgeblendet (`p.deleted_at IS NULL`, `po.deleted_at IS NULL`). Ein künftiger Abfragepfad ohne diesen Filter leakt Inhalt. | Vektor beim Trashen leeren, Query-Filter als zweite Ebene behalten (#195) | M | +| I-15 | Keine Retention für `audit_log` | `apps/api/prisma/schema.prisma:74–91`; `apps/api/src/audit/audit.service.ts` | Persistenter Trail (`AuditEntry`, Issue #86) wächst unbegrenzt; kein Pruning-Job registriert. | Konfigurierbare Aufbewahrung + Job (#196) | S | +| I-16 | Keine Security-Response-Header | `apps/api/src/main.ts`; `apps/api/package.json` | `helmet` ist keine Dependency und kommt in `apps/api/src` nicht vor. `app.enableCors()` wird **nicht** aufgerufen — CORS ist damit implizit restriktiv (keine CORS-Header, Browser blockt cross-origin), aber nirgends als Entscheidung festgehalten. Der Web-Tier hat eine strenge CSP; die api-Antworten selbst sind die Lücke. | HSTS, `X-Content-Type-Options`, `Referrer-Policy`, Frame-/Permissions-Policy, CORS explizit (#197) | S | +| I-17 | Kein SBOM, kein Lizenzreport | `.gitea/workflows/ci.yml`; kein `sbom`/`syft`/`cyclonedx` in `.gitea/` oder `package.json` | CI macht install, build, lint, typecheck, test, `i18n:check`. Kein Lieferketten-Artefakt. (Die Lizenzlage selbst ist unkritisch, s. I-37.) | CycloneDX je Image + Workspace, Lizenzreport als Artefakt (#202) | M | +| I-18 | Keine Attachment-Integritätshashes | `apps/api/prisma/schema.prisma:541–559`; `apps/api/src/files/file-storage.service.ts:21,25` | `Attachment` hat keine Checksumme; die einzigen Hashes im Schema sind Credential-/Token-Hashes. Dateien liegen unter `UPLOADS_DIR//`. Manipulation am Dateisystem ist nicht erkennbar. | SHA-256 beim Upload, Prüfung beim Download, Backfill (#199) | M | +| I-19 | Plugins nicht instanzweit abschaltbar | `apps/api/src/settings/instance-settings.service.ts`; `apps/api/src/public-api/public-api.guard.ts:64`; `apps/api/src/mcp/mcp.controller.ts:50,84` | Für Public-API und MCP gibt es harte Schalter mit 404-Semantik, für Plugins nicht. Plugin-Zustand ist installierte Menge + Freischaltung je Teich. „Codeausführung in der VS-Zone" ist damit nicht mit einem Schalter beantwortbar. | `plugins.enabled = false` nach demselben Muster (#200) | M | +| I-20 | Ereigniskatalog nicht stabil | `apps/api/src/audit/audit.service.ts:9`; 37 Aufrufstellen in `apps/api/src` | `AuditEvent.action: string` — der Doc-Kommentar nennt es „stable dot-namespaced id", erzwungen wird nichts. 34 verschiedene Ids sind in Gebrauch (`auth.login_failed`, `grant.created`, `plugin.installed`, `settings.changed`, …). Ohne Vertrag brechen SIEM-Regeln beim Update. | Typisierte Union + veröffentlichter Katalog + Zaun-Test (#201) | L | +| I-21 | Keine Lesezugriffsprotokollierung | `apps/api/src/audit/audit.service.ts` | Bewusst begrenzt: „Content activity (pages, files, exports, labels) intentionally stays log-only — the trail answers 'who changed access/configuration', not 'who edited what'." Lesezugriffe existieren gar nicht. Das einzige `action: 'read'` (`apps/api/src/mcp/mcp.service.ts:243`) ist ein Permission-Parameter, kein Ereignis. | Lesetrail nur für eingestufte Inhalte, Variante A (#222–#225) | L | +| I-22 | Rohdokument-Bytes ohne Pruning | `apps/api/prisma/schema.prisma:746–784`; `apps/api/src/import-export/import-export.module.ts:62` | `ConversionJob.input`/`result` sind die **rohen Dokumentbytes** jedes Im-/Exports. Das Schema nennt sie „transient, not the durable copy" und verweist auf „a later maintenance job". Registriert ist nur `data-export-purge`, und `expiresAt` ist laut Schema „Null for every other job kind, whose result never expires". Für `export_docx`/`import_docx` bleiben die Bytes damit unbefristet. | Pruning für alle Job-Arten; im Löschkonzept (#229) ausweisen | M | +| I-23 | `mail_outbox` ohne Retention, mit Seitentiteln | `apps/api/prisma/schema.prisma:683–697`; `apps/api/src/notifications/digest.service.ts:16,125,146` | Die Outbox speichert `textBody`/`htmlBody` dauerhaft; kein Pruning-Job. Digest-Mails enthalten **Seitentitel** und Akteursnamen (`- ${page.pageTitle}: … (${actorNames})`). Transaktionsmails selbst tragen keinen Inhalt (`mail-templates.ts:25–44`: Anrede + i18n-Text + Link). | Retention für `mail_outbox`; im Löschkonzept ausweisen | S | +| I-24 | Slug-Residuum nach Purge | `apps/api/prisma/schema.prisma:441–461` | `PageLink.fromPage` kaskadiert, `toPage` ist `onDelete: SetNull` — nach dem Purge bleibt in fremden Seiten eine Zeile mit `target_slug` der gelöschten Seite (`toPageId` genullt). Der Titel/Slug einer eingestuften Seite kann selbst eingestufte Information sein. | Bewusst entscheiden: Zeilen mitlöschen oder als Restrisiko (#231) führen | S | +| I-25 | Inhaltskopie auf dem Endgerät | `apps/web/src/editor/use-collab-provider.ts:32,76,134–141,184` | Jede geöffnete Seite wird als Yjs-Dokument in die IndexedDB gespiegelt (`dorfteich-page-`, Issue #38, ADR 0003). Beim Verlassen wird sie gelöscht, **wenn** der Server synchronisiert hat (`clearData()`); bei unsynchronisierten Offline-Änderungen bleibt sie absichtlich stehen. Bricht der Browser vorher ab, läuft die Aufräum-Routine nicht. Für Entzugsfälle gibt es `discardLocal` (#39). | Nicht „keine Spuren auf dem Endgerät" behaupten; Endgeräteverschlüsselung als Betreiberpflicht dokumentieren (#226, #231) | S | +| I-26 | Node-Version nicht gepinnt | `package.json:7–10` | `"engines": { "node": ">=22" }` ist eine Untergrenze, kein Pin; `packageManager: "pnpm@11.9.0"` ist exakt. Für einen reproduzierbaren Offline-Build fehlt die Node-Festlegung. | Node-Version pinnen und im Build-Verfahren nennen (#219) | S | + +### UNKLAR + +| Nr. | Thema | Fundort | Ist-Zustand | Warum unklar | +| ---- | --------------------------------------------------------- | -------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | +| I-27 | Herkunft/Maintainer-Status der Abhängigkeiten | `pnpm-lock.yaml` (1380 aufgelöste Pakete, 366 davon prod) | Lizenzlage geprüft (I-37), Jurisdiktion nicht. | Die npm-Registry weist keine Rechtsträger oder Jurisdiktionen aus. Eine belastbare Antwort braucht Recherche je Paket; für die Kernpakete (React/Meta, TipTap/überwiegend DE-Umfeld, Yjs, NestJS, Prisma) ist sie machbar, für 366 Pakete nicht mit vertretbarem Aufwand. Vorschlag: Kernabhängigkeiten einzeln, Rest über den SBOM (#202) offenlegen. | +| I-28 | Verhalten ohne Internetzugang | s. I-05, I-31 | Kein Codepfad baut eine Verbindung nach außen auf außer Sidecars, SMTP und Backup-Ziel. | Ob eine egress-geblockte Instanz **vollständig** funktioniert, ist eine Laufzeitfrage (Container-Pulls, DNS, Zertifikatsprüfungen, SMTP-Timeouts). Nur durch den Testlauf (#220) beantwortbar. | +| I-29 | Wirksamkeit der Sicherheitsschalter bei laufendem Betrieb | `apps/api/src/settings/instance-settings.service.ts` | Der Settings-Cache der api ist in-process; ein DB-Write an `instance_settings` wirkt erst nach api-Neustart. | Für `api.enabled`/`mcp.enabled` ist das dokumentiert und betrieblich beherrschbar. Für einen künftigen `plugins.enabled` (#200) oder einen Auth-Schalter (#216) muss geklärt werden, ob „Schalter umgelegt" auch „sofort wirksam" heißt — sonst ist die Referenzkonfiguration zeitweise unwahr. | +| I-30 | Vollständigkeit der Ausgabekanal-Liste | `apps/api/src/import-export/`, `apps/api/src/public/`, `apps/api/src/public-api/`, `apps/api/src/files/` | Gefunden wurden: Web-Ansicht, Browserdruck, PDF (Gotenberg), DOCX/ODT (pandoc), Markdown-Einzeldownload, Pond-ZIP, Obsidian-Vault-Export, Atom-Feeds, Public-API, MCP, Suchergebnisse, No-JS-Shell, Anhang-Download, Datenexport (DSGVO), Backup. | Die Liste ist durch Codelesen entstanden, nicht durch eine erschöpfende Routen-Enumeration. Der vorhandene Route-Enumeration-Test des Permission-Modells wäre die Grundlage, das mechanisch zu belegen — empfohlen als Teil von #211. | + +### OK — Stärken, die zu belegen sind + +| Nr. | Thema | Fundort | Ist-Zustand | +| ---- | ------------------------------------ | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| I-31 | Keine ausgehenden Verbindungen | `apps/api/src/health/readiness.service.ts:83,102`; `apps/api/src/import-export/gotenberg.renderer.ts:59,85`; `pandoc.converter.ts:102,121`; `apps/api/src/mail/smtp-config.service.ts:54,71`; `apps/backup/src/remote.ts`, `mirror.ts` | **Jeder** ausgehende Aufruf im Code geht an einen internen Sidecar (`PANDOC_URL`, `GOTENBERG_URL`), an SMTP oder an das konfigurierte Backup-Ziel. Keine Telemetrie, keine Update-Checks, keine Analytics, kein Crash-Reporting, keine Lizenzprüfung, keine Link-Vorschauen, kein oEmbed, keine Avatar-Dienste, keine externen Karten, Fonts self-hosted (ADR 0016). Das ist der stärkste Einzelbefund des ganzen Berichts. | +| I-32 | Passwort-Hashing | `apps/api/src/users/password.ts:1,9–25` | Argon2id über die `argon2`-Bibliothek, mit `needsRehash`-Pfad. Kein Eigenbau. | +| I-33 | Zentrales Berechtigungsmodell | `apps/api/src/permissions/`; `apps/api/src/mcp/mcp.service.ts:232–253` | Default-closed, deny-wins, zentral durchgesetzt, mit Route-Enumeration-Test. Der MCP-Endpoint **divergiert nicht**: er ruft `PermissionService.hasPondRole` / `canAccessPage`; eigenständig sind nur seine Schalter. Korrigiert eine offene Frage des Maßnahmenplans. | +| I-34 | Volltextsuche ohne externe Engine | `apps/api/src/search/postgres-search.provider.ts:40–41` (ADR 0010) | Postgres-`tsvector`. Kein Elasticsearch, kein zweiter Datenhalter, keine weitere Netzwerkgrenze. | +| I-35 | Single-Tenant | `apps/api/prisma/schema.prisma` (33 Modelle, kein `Tenant`/`Organization`) | Teiche sind Container innerhalb _einer_ Instanz. Passt zur empfohlenen Betriebsform „eine Instanz je Einstufungsniveau". | +| I-36 | Service Worker cached keine Inhalte | `apps/web/vite.config.ts:12–20` | VitePWA precacht ausschließlich Build-Assets; **kein** Runtime-Caching, `navigateFallbackDenylist` schließt `/api` und `/collab` aus. API-Antworten landen nie im SW-Cache. | +| I-37 | Lizenzlage durchweg permissiv | `pnpm licenses list --prod` | 366 Produktionspakete, 11 verschiedene Lizenzen: MIT 301, ISC 24, Apache-2.0 22, BSD-3-Clause 5, BlueOak-1.0.0 4, BSD-2-Clause 4, MIT-0 2, Python-2.0 1, CC0-1.0 1, 0BSD 1, `(MPL-2.0 OR Apache-2.0)` 1. **Kein GPL/AGPL, kein Copyleft mit Verteilungsfolgen.** Projektlizenz MIT. | +| I-38 | Log-Redaction | `apps/api/src/app.module.ts:84–93`; `apps/collab/src/logger.ts` | pino-JSON auf stdout, `LOG_LEVEL` konfigurierbar, `redact: { paths: ['req.headers.authorization','req.headers.cookie'], remove: true }`. Kein Datei- oder Syslog-Transport — Weiterleitung ist Sache der Container-Runtime, was für SIEM-Anbindung die richtige Arbeitsteilung ist. | +| I-39 | Public-API und MCP standardmäßig aus | `apps/api/src/settings/instance-settings.service.ts` (`api.enabled` / `mcp.enabled`, je `.default(false)`); `public-api.guard.ts:64`; `mcp.controller.ts:50,84` | Beide Oberflächen antworten im ausgeschalteten Zustand mit 404 (Existenz verstecken), zusätzlich muss jeder Teich einzeln zustimmen. Vorbildliches Muster für I-02 und I-19. | +| I-40 | drawio ist vendored | `packages/plugins/drawio/vendor/drawio-30.3.6/`; `packages/plugins/drawio/manifest.json` | Kein externer Editor-URL: das Manifest nennt `kind: code`, `permissions: ["blockData","ui"]`, `fallback`, `license: Apache-2.0`; `homepage` ist reines Metadatum. Der drawio-Webapp-Baum liegt im Repo. Korrigiert eine offene Frage des Maßnahmenplans (kein Ausschlusskriterium). | +| I-41 | Entzug beendet laufende Sitzungen | `apps/collab/src/index.ts:62–70`; `apps/collab/src/server.ts:108–125`; `apps/api/src/pages/pages.service.ts:57,384` | Collab-Tokens leben **60 Sekunden** (`COLLAB_TOKEN_TTL_SECONDS = 60`); die WS-Ebene autorisiert nur gegen Signatur und `claims.pageId === documentName`, ohne Permission-Kontext. Das ist tragfähig, weil die api bei jedem Token neu prüft und ein Grant-Entzug offene Verbindungen sofort per `pg_notify`-Access-Listener schließt (#39). Für den Lesetrail folgt daraus: die api ist der richtige Emissionsort, mit natürlicher Minutengranularität. | +| I-42 | Seiten-Purge ist vollständig | `apps/api/src/trash/trash.service.ts:121–144`; Kaskaden in `apps/api/prisma/schema.prisma:114,313,344,361,373,409,469,486` | `purgePage` löscht Anhänge (Datei + Quota), Content-Cache, Update-Log und Watches explizit; Kommentare, Versionen, Mentions, Pending-Contributors, Labels-Zuordnungen und Favoriten hängen an `onDelete: Cascade`. Kindseiten werden bewusst an den Großeltern-Knoten gehoben. Residuen bleiben nur laut I-24. | + +--- + +## 3. Detailbefunde + +### 3.1 Sicherheitsgrundfunktionen + +**(1) Authentisierung.** Es gibt eine eigene Benutzer- und +Credential-Haltung: `User` plus `UserIdentity` mit +`@@unique([provider, subject])`; `credential` hält den Argon2id-Hash +(`schema.prisma:562–577`). Gehasht wird mit `argon2id` über die +`argon2`-Bibliothek inklusive `needsRehash` (`users/password.ts:9–25`) — +kein Eigenbau, korrektes Verfahren. **Eine OIDC-, SAML- oder +LDAP-Anbindung existiert nicht**; ein Suchlauf über `apps/api/src` und +`packages/shared/src` findet `oidc` ausschließlich im Schema-Kommentar. +Unterstützung für Client-Zertifikate oder vertrauenswürdige +Reverse-Proxy-Header ist ebenfalls nicht vorhanden. → I-01, I-02. + +**(2) Session-Verwaltung.** Eigene Implementierung, nicht Framework: +`Session` als serverseitige Tabelle, deren `id` der SHA-256-Hash des +Session-Tokens ist (`schema.prisma:580–595`, `sessions.service.ts:84`) — +ein gestohlener DB-Dump gibt keine gültigen Tokens her. Das Cookie ist +`httpOnly`, `sameSite: 'lax'`, `secure` in Produktion +(`auth.guard.ts:52–58`). Gültigkeit: gleitende 30 Tage, hart kodiert, +ohne Idle-Grenze. → I-09. + +**(3) Kryptographie.** Vollständige Inventur der eigenen Krypto-Aufrufe: + +| Zweck | Verfahren | Fundort | +| ------------------------ | --------------------------------- | ------------------------------------------------------- | +| Passwörter | Argon2id | `users/password.ts:9–25` | +| Session-Ids | SHA-256 über das Token | `auth/sessions.service.ts:84` | +| Auth-Tokens (Mail-Flows) | SHA-256 | `auth/auth-tokens.service.ts:55` | +| PATs | SHA-256 | `public-api/api-tokens.service.ts:149` | +| Feed-Tokens | SHA-256 | `public/feed-tokens.service.ts:73` | +| Collab-Tokens | HS256-JWT, handgebaut, TTL 60 s | `shared/token-crypto.ts:1,37,74`; `pages.service.ts:57` | +| Unsubscribe-Tokens | HMAC-SHA-256 mit `PURPOSE`-Präfix | `notifications/unsubscribe-token.ts:14,40` | + +**Nirgends werden Inhalte ver- oder entschlüsselt.** Es gibt keine +Backup-Verschlüsselung und keine Verschlüsselung in DB oder Dateisystem. +Unter §52 VSA ist das die gewollte Arbeitsteilung. Die beiden +Befunde betreffen nicht das _Ob_, sondern die Hygiene: ein Secret für zwei +Zwecke (I-06) und handgeschriebenes JWT (I-07). + +**(4) Zugriffskontrolle.** Zentral in `apps/api/src/permissions/`, +default-closed mit deny-wins, per Guards durchgesetzt und durch einen +Route-Enumeration-Test abgesichert. Die geprüften Umgehungskandidaten sind +sauber: der Export filtert über `permissions.filterPages` +(`export.service.ts:102–108`), die Suche joint auf lebende Seiten und +Teiche (`postgres-search.provider.ts:142–143`), der MCP-Endpoint nutzt den +zentralen Service (I-33), und die Collab-WS-Ebene ist über kurzlebige +Tokens plus Entzugs-Listener abgesichert (I-41). Public-API und MCP sind +zusätzlich zweifach gegated (Instanz + Teich) und standardmäßig aus +(I-39). Ratenbegrenzung existiert als Decorator-Mechanik +(`rate-limit/rate-limit.guard.ts:24`). + +**(5) Integrität.** Keine Prüfsummen auf Anhängen (I-18), keine +Signaturen auf Plugin-Bundles (`manifest.json` hat kein Hash-Feld), kein +Manipulationsschutz auf dem `audit_log`. Prüfsummen existieren nur als +Token-Hashes, also zur Authentisierung, nicht zur Integritätssicherung von +Nutzdaten. + +### 3.2 Datenhaltung + +**(6) Seiteninhalte** liegen ausschließlich in Postgres, aber in +**fünf** Repräsentationen: + +1. `pages.ydoc_state` (Bytes) — das lebende Yjs-Dokument +2. `page_updates` — das Update-Log; gemergt erst bei Compaction +3. `page_versions.ydoc_snapshot` — vollständige, selbstständige Snapshots + (AUTO/MANUAL/PRE_RESTORE), damit ein Restore nie vom Update-Log abhängt + (`schema.prisma:318–347`) +4. `page_content_cache` — `plain_text`, `markdown`, `html`, `outline` + (jsonb) — vier abgeleitete **Klartext**-Formen, bei jedem Save + erneuert (`schema.prisma:392–411`) +5. `page_content_cache.search_vector` — der gewichtete `tsvector` + +Für ein Löschkonzept ist Punkt 4 der wichtigste: der Klartext jeder Seite +liegt vierfach vor, unabhängig vom Yjs-Zustand. + +**(7) Anhänge** liegen im Dateisystem unter +`UPLOADS_DIR//` (`file-storage.service.ts:21,25`, +Default `./data/uploads`), mit Metadaten in `attachments`. Keine +Verschlüsselung, keine Prüfsumme. + +**(8) Volltextindex:** Postgres-FTS, persistiert **auf derselben Zeile wie +der Klartext** (`page_content_cache`), GIN-Index per Raw-SQL-Migration. +Er enthält damit zwangsläufig Klartext der Inhalte — und Papierkorb-Inhalte +(I-14). + +**(9) Weitere Kopien im Betrieb:** + +| Kopie | Fundort | Lebensdauer | +| -------------------------------------------------------------------------- | --------------------------- | ------------------------------------------------------------------ | +| `conversion_jobs.input` / `.result` — rohe Dokumentbytes jedes Im-/Exports | `schema.prisma:746–784` | **unbefristet** außer für Datenexport-Jobs (I-22) | +| `mail_outbox.text_body` / `.html_body` — Digest-Mails mit Seitentiteln | `schema.prisma:683–697` | **unbefristet** (I-23) | +| IndexedDB je geöffneter Seite auf dem Endgerät | `use-collab-provider.ts:76` | bis zum Verlassen, bei unsynchronisierten Änderungen länger (I-25) | +| `comments`, `notifications` | `schema.prisma:100–164` | an Seite/Nutzer gekoppelt, kaskadierend | +| Backups | `apps/backup/`, ADR 0015 | Retention konfigurierbar | +| PWA-Service-Worker | `vite.config.ts:12–20` | **enthält keine Inhalte** (I-36) | + +Thumbnails, Vorschaubilder oder serverseitige Temp-Dateien gibt es +nicht: eine Suche nach `tmpdir`/`mkdtemp`/`/tmp` in `apps/api/src` bleibt +leer — die Konverter arbeiten über HTTP gegen die Sidecars. + +**(10) Revisionshistorie:** `page_versions` mit vollständigen Snapshots, +Trigger-Enum (AUTO/MANUAL/PRE_RESTORE), `contributor_ids` als Array; +angelegt von collab bei Sitzungsende und im Bearbeitungsintervall, von der +api auf Zuruf. Ausgedünnt durch den registrierten Job +`version-thinning`. + +### 3.3 Löschen und Vernichtung + +**(11) Seiten** werden zweistufig gelöscht: `deletedAt` setzt den +Papierkorb (ADR 0013), der Job `trash-purge` löscht nach Retention +endgültig, zusätzlich gibt es „Purge einzeln" (`trash.controller.ts:26`). + +**(12) Vom Purge erfasst** (I-42): Anhänge inklusive Datei und +Quota-Rückgabe, Content-Cache (und damit der Suchvektor), Update-Log, +Watches — explizit; Kommentare, Versionen, Mentions, +Pending-Contributors, Label-Zuordnungen und Favoriten über +`onDelete: Cascade`. Kindseiten werden bewusst an den Elternknoten der +gelöschten Seite gehoben. + +**(13) Rückstandsfrei? Nein — vier konkrete Reste:** + +1. `page_links`-Zeilen fremder Seiten behalten den `target_slug` der + gelöschten Seite, `to_page_id` wird genullt (I-24). +2. `conversion_jobs` behalten die Rohbytes jedes Exports dieser Seite + (I-22). +3. `mail_outbox` behält Digest-Mails mit dem Seitentitel (I-23). +4. IndexedDB-Kopien auf Endgeräten, die die Seite offline geöffnet hatten + (I-25). + +Für **Teiche** gibt es überhaupt kein endgültiges Löschen (I-12) — und +die FK-Restriktionen würden es derzeit auch blockieren. Backups sind ein +fünfter, gewollter Rest mit eigener Retention. + +### 3.4 Ausgehende Verbindungen + +**(14)–(15)** Die vollständige Liste der Stellen, an denen die Anwendung +nach außen geht, ist in I-31 aufgeführt: zwei interne Sidecars (pandoc, +Gotenberg), SMTP, Backup-Ziel (WebDAV und/oder rsync). **Sonst nichts.** +Keine Update-Checks, keine Telemetrie, kein Analytics, kein +Crash-Reporting, keine Lizenzprüfung, keine Link-Vorschauen, kein oEmbed, +keine Avatar-Dienste, keine externen Karten, Schriften oder Skripte. +Die gepinnten Fremdmodule laden zur Laufzeit nichts nach: drawio ist +vendored (I-40), Mermaid und Excalidraw sind gebündelte +Workspace-Pakete, und die CSP `default-src 'self'` würde einen Nachlader +ohnehin blocken. + +**(16) Backups:** Ziel ist frei konfigurierbar, ohne Allowlist; nicht +verschlüsselt (I-11). Letzteres ist gewollt (§52 VSA), Ersteres nicht. + +**(17) Betrieb ohne Internet:** Codeseitig nichts entgegen, empirisch +unbelegt (I-28). Offene Einzelfrage mit Betriebsrelevanz: ohne +ausgehendes SMTP funktionieren Registrierung, Verifikation und +Passwort-Reset nicht — was mit `auth.local.enabled = false` (#216) +zusammenfällt und dort mitentschieden werden sollte. + +### 3.5 Ausgabekanäle + +**(18)** Gefundene Wege, auf denen Inhalte die Anwendung verlassen +(Vollständigkeit s. I-30): Web-Ansicht (SPA), Browserdruck, PDF über +Gotenberg, DOCX/ODT über pandoc, Markdown-Einzeldownload +(`pages.controller.ts:122`), Pond-ZIP (`export.service.ts:68`), +Obsidian-Vault-Export, Atom-Feeds, Public-API, MCP, Suchergebnisse +(Snippets), No-JS-Shell (`public/html-shell.ts`), Anhang-Download +(`files.controller.ts:51,71`), DSGVO-Datenexport, Backup. + +**(19) Metadatenmodell:** `Page` trägt Titel, Slug, `parentId`, `sortKey`, +Zeitstempel, Ersteller — nichts, wo eine Einstufung hineinpasste (I-03). +Der naheliegende Ersatz „Labels" trägt nicht, und zwar aus vier +belegbaren Gründen (**I-03a**): + +- `Label` ist **teich-gebunden** (`pondId`, `schema.prisma:423–437`) — + dieselbe Einstufung wäre in jedem Teich ein anderes Objekt ohne + instanzweite Bedeutung. +- Labels sind von jedem Editor **bearbeitbar**. +- Labels **vererben nicht** im Seitenbaum. +- Labels **verlassen die Anwendung nie**: `export.service.ts:98–105` lädt + `labelIds` ausschließlich für `permissions.filterPages`; kein + Export-Pfad schreibt sie in die Ausgabe. Das korrigiert eine offene + Frage des Maßnahmenplans mit einem klaren Nein. + +**(20) Eingriffspunkte für einen Kopf-/Fußaufdruck** — konkret: + +| Kanal | Eingriffsstelle | Zustand | +| --------------- | -------------------------------------------------------------------------------------------------------------------------- | --------------------------------------------------------------------------------------------------- | +| Web-Ansicht | Seiten-Layout der SPA | kein Aufdruck-Element | +| Browserdruck | **fehlt komplett** — kein `@media print` in `apps/web/src` | Neubau nötig, `@page`-Randboxen für Wiederholung je Blatt | +| PDF | `import-export/pdf-html.ts:76–79` (Dokumentkopf, einmalig) + Gotenberg-Footer-Template (`:31`, liefert heute Seitenzahlen) | Mechanik für „je Seite" existiert, wird für den Aufdruck nicht genutzt | +| DOCX/ODT | `import-export/pandoc.converter.ts` | **kein** `--reference-doc` — es gibt keine Kopf-/Fußzeilendefinition, in die der Aufdruck gehörte | +| Markdown-ZIP | `import-export/export-markdown.ts` | keine YAML-Frontmatter-Ausgabe (Frontmatter existiert nur im _Import_, `obsidian-vault.ts:203,528`) | +| Atom-Feeds | `public/feed.service.ts` | kein Feld | +| Public-API | `public-api/` | Seitenrepräsentation ohne Einstufung | +| Suchergebnisse | `search/postgres-search.provider.ts` | Snippets ohne Markierung | +| No-JS-Shell | `public/html-shell.ts` | eigener Renderpfad, braucht eigene Behandlung | +| Anhang-Download | `files.controller.ts:51,71` | Dateiname/Begleitdatei sind die einzigen Träger | + +### 3.6 Protokollierung + +**(21)** Protokolliert werden Authentisierungs- und Admin-Ereignisse +(34 Ids, s. I-20) — persistent in `audit_log` **und** als +`audit: …`-stdout-Zeile. Inhaltsaktivität bleibt bewusst log-only, +Lesezugriffe fehlen ganz (I-21). + +**(22) Wohin:** pino-JSON auf stdout, kein Datei- oder Syslog-Transport +(I-38). Ein SIEM-Export ist damit über die Container-Runtime möglich und +braucht keinen anwendungsseitigen Syslog-Client — die Lücke ist nicht der +Transport, sondern der stabile Ereigniskatalog (I-20). + +**(23) Aufbewahrungsdauer:** für `audit_log` **nicht** konfigurierbar und +ohne Job (I-15); für Backups konfigurierbar +(`BACKUP_RETENTION_DAYS`, `backup.localRetentionDays`, +`backup.remoteRetentionDays`); `LOG_LEVEL` konfigurierbar, Log-Retention +ist Sache der Runtime. + +**(24) Unerwünschte Daten in Logs:** `authorization`- und +`cookie`-Header werden entfernt (`app.module.ts:93`); der +`AuditEvent.details`-Kommentar verlangt „never secrets, tokens, or page +content". Seitentitel und Nutzernamen erscheinen in Logs und +Digest-Mails (I-23) — für eine eingestufte Seite kann bereits der Titel +schützenswert sein. Das gehört in die Restrisikoliste, nicht in einen +Bugfix. + +### 3.7 Lieferkette + +**(25) Abhängigkeiten:** 1380 aufgelöste Pakete im Lockfile, davon **366 +im Produktionsbaum**. Direkte Produktionsabhängigkeiten: api 31, +web 26, collab 7, shared 5, backup 5. Die Kernpakete sind +NestJS 11, Prisma 6, Postgres-Client `pg` 8, Argon2, nodemailer 9, +pino 9, Yjs 13 mit Hocuspocus 4, TipTap 3 und ProseMirror, React 19, +`@modelcontextprotocol/sdk` 1. Lizenzverteilung s. I-37 — durchweg +permissiv. + +**(26) Herkunft:** nicht belastbar beantwortbar (I-27). + +**(27) Pinning:** `pnpm-lock.yaml` ist eingecheckt und deckt transitiv +alle 1380 Pakete; CI installiert mit `--frozen-lockfile`. Die +Manifest-Ranges sind Caret-Ranges, was in Kombination mit dem Lockfile +korrekt ist. Ungepinnt sind: **Container-Image-Digests** (I-05) und die +**Node-Version** (I-26). `pnpm` ist exakt gepinnt. Randnotiz ohne +Sicherheitsbezug: `zod` läuft in api/shared als ^3, im Web als ^4. + +**(28) Deployment:** Container-Images per Docker Compose +(`deploy/compose/docker-compose.yml`) — vier eigene Images (web, api, +collab, backup) plus `postgres:17.5-alpine`, `pandoc/core:3.6`, +`gotenberg/gotenberg:8`, `caddy:2.10-alpine` (letzteres im Profil +`caddy`). Migrationen laufen beim api-Start (`main.ts:runMigrations`, +mit Advisory-Lock und einer Warteschleife gegen laufende Restores). +Eine Offline-Installation ist plausibel und unbelegt (I-05, I-28). + +### 3.8 Betriebsmodell + +**(29) Mandantenfähigkeit:** Single-Tenant (I-35). + +**(30) Konfiguration.** Zwei klar getrennte Ebenen: + +- **Deploy-Zeit (Env):** `DATABASE_URL`, `APP_BASE_URL`, `PORT`, + `UPLOADS_DIR`, `COLLAB_TOKEN_SECRET`, `SMTP_*`, `LOG_LEVEL`, + `GOTENBERG_URL`, `PANDOC_URL`, `BACKUP_RETENTION_DAYS`, + `MIGRATE_ON_START` (`packages/shared/src/env.ts`). +- **Laufzeit (`instance_settings`):** `upload.allowedExtensions`, + `upload.svgPolicy`, `api.enabled`, `mcp.enabled`, `backup.*` + (Retention, Nextcloud-Ziel, Upload-Zeitplan), `legal.imprint`, + `legal.privacyPolicy`, `home.content`, Setup-Abschluss. + +Wichtig für Sicherheitsschalter: der Settings-Cache ist in-process, eine +Änderung wirkt erst nach api-Neustart (I-29). + +**(31) Hart abschaltbare Funktionen:** Public-API und MCP (instanzweit, +Default aus, plus Zustimmung je Teich, I-39); SVG-Uploads +(`upload.svgPolicy: reject`); Dateiendungen per Allowlist; Caddy per +Compose-Profil. **Nicht** abschaltbar: lokale Authentisierung (I-02), +Plugins (I-19), Feeds (I-10). + +--- + +## 4. Offene Punkte + +Was in dieser Analyse **nicht** geklärt werden konnte, mit Grund: + +1. **Jurisdiktion und Maintainer-Status der 366 Produktionspakete** + (I-27) — die Registry weist es nicht aus. Vorschlag: Kernpakete + einzeln belegen, den Rest über den SBOM offenlegen. +2. **Funktionsfähigkeit ohne Internetzugang** (I-28) — nur durch den + Testlauf in netzisolierter Umgebung beantwortbar. Der Testlauf ist + deshalb selbst ein Arbeitspaket (#220), kein Nachweisdokument. +3. **Sofortige Wirksamkeit künftiger Sicherheitsschalter** (I-29) — + hängt davon ab, ob `plugins.enabled` und ein Auth-Schalter am + in-process-Cache vorbei gelesen werden. Zu entscheiden in #200/#216. +4. **Vollständigkeit der Ausgabekanal-Liste** (I-30) — durch Codelesen + erstellt, nicht mechanisch belegt. Empfehlung: über den vorhandenen + Route-Enumeration-Test absichern. +5. **Wirkung von `PRE_RESTORE`-Snapshots und Backups auf ein + Löschverlangen** — beides sind gewollte Kopien mit eigener Retention; + wie lange ein gelöschter eingestufter Inhalt darin fortlebt, ist eine + Frage der Retention-Konfiguration beim Betreiber, nicht des Codes. Für + das Löschkonzept (#229) muss die Zahl trotzdem benannt werden. +6. **Werden Suchsnippets aus dem Klartext-Cache oder aus dem `tsvector` + erzeugt?** Für die Kennzeichnungspflicht (#211) reicht der Befund + „Snippets enthalten Seitentext"; für die Frage, ob ein Treffer + Inhaltsfragmente an nicht-lesende Nutzer ausgeben könnte, wäre eine + genauere Betrachtung von `postgres-search.provider.ts` nötig. Die + Berechtigungsprüfung joint auf lebende, sichtbare Seiten — ein + Leck ist nicht ersichtlich, aber nicht abschließend geprüft. + +--- + +## 5. Verhältnis zum Maßnahmenplan + +Diese Ist-Aufnahme wurde **nach** `20-massnahmenplan.md` erstellt. Sie +bestätigt dessen Befunde bis auf drei Korrekturen und liefert vier +Ergänzungen, die im Plan fehlen: + +**Bestätigt:** alle Phase-1- und Phase-2-Positionen des Plans, jeweils mit +Fundort (Tabelle in Abschnitt 2). + +**Korrigiert:** + +1. `deploy/compose/.env` ist **nicht** im Repo (`.gitignore:5–7`, + `git ls-files`) — der Punkt bleibt sinnvoll, aber als Verifikation + statt als Leck-Behebung. +2. Das **drawio-Plugin lädt keine externe URL** (I-40) — kein + Ausschlusskriterium. +3. **Labels fließen nicht in Exporte** (I-03a) — die offene Frage des + Plans ist mit Nein beantwortet, und der MCP-Gate divergiert nicht + (I-33). + +**Ergänzt (im Plan nicht enthalten):** + +| Befund | Warum es zählt | +| ----------------------------------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------- | +| I-22 `conversion_jobs` halten Rohdokument-Bytes unbefristet | Direkt löschkonzeptrelevant: eine gelöschte eingestufte Seite lebt in ihrem letzten Export weiter. | +| I-23 `mail_outbox` ohne Retention, mit Seitentiteln | Zweite unbefristete Kopie inhaltsnaher Daten. | +| I-24 `page_links.target_slug` überlebt den Purge | Der Slug einer eingestuften Seite kann selbst schützenswert sein. | +| I-25 IndexedDB-Kopie auf Endgeräten | Inhalte verlassen den Server auf Endgeräte-Datenträger — muss in der Abgrenzungserklärung als Betreiberpflicht (Endgeräteverschlüsselung) auftauchen. | +| I-26 Node-Version nicht gepinnt | Blockiert die Reproduzierbarkeitsaussage in #219. | + +Empfehlung: I-22 bis I-26 als fünf zusätzliche Issues in die Meilensteine +`M24 — VS-NfD: security quick wins` (I-22, I-23, I-24, I-26) bzw. in die +Dokumentation `M30` (I-25) aufnehmen. Das hebt den Umfang von 45 auf +**50 Issues**; Aufwand zusätzlich ca. **4–6 AT** (I-22 M, I-23 S, I-24 S, +I-25 in #226/#231 enthalten, I-26 S). diff --git a/docs/vs-nfd/20-massnahmenplan.md b/docs/vs-nfd/20-massnahmenplan.md new file mode 100644 index 0000000..fec2ab4 --- /dev/null +++ b/docs/vs-nfd/20-massnahmenplan.md @@ -0,0 +1,281 @@ +# Maßnahmenplan VS-NfD — Dorfteich (Rev. 2, volle Tiefe) + +Ziel: **einsetzbar in einer nach VSA freigegebenen Umgebung**, keine eigene +BSI-Zulassung. Diese Revision zieht die vormals als Roadmap geführten Punkte +in die Planung und weist die Terminwirkung aus. + +Aufwand in **Arbeitstagen (AT)** für eine Person mit Claude Code. +Erfahrungswert: Implementierung ist der kleinere Teil, Test und Dokumentation +der größere. Die Schätzungen enthalten beides. + +--- + +**Stand 2026-07-30:** In Meilensteine `M24`–`M31` und Issues #188–#236 +überführt (Volltexte und Anlage-Protokoll: `31-issue-entwurf.md`, +Befundgrundlage: `10-ist-aufnahme.md`). Issue-Nummern stehen an den +Checkboxen, Meilensteine an den Phasen. + +## Phase 0 — Nicht bauen (Leitplanken) + +Diese Dinge machen die Situation **schlechter**, weil sie die Anwendung zur +Trägerin einer Sicherheitsgrundfunktion nach §52 VSA machen würden: + +- ❌ Keine eigene Backup-Verschlüsselung — Datenträgerschutz ist Plattformsache +- ❌ Keine Verschlüsselung von Inhalten in DB oder Filesystem +- ❌ Kein eigenes MFA/TOTP, keine eigene Passwort-Policy-Engine +- ❌ Keine neuen Krypto-Primitive +- ❌ Keine anwendungsseitige Trennung von Einstufungsniveaus + +Stattdessen: **delegieren und dokumentieren.** + +--- + +## Phase 1 — Blocker · Summe 30–38 AT + +### P1-1 Fremdauthentisierung + lokale Auth abschaltbar · 10–12 AT + +_Meilenstein: `M27 — VS-NfD: external authentication`_ + +- [ ] OIDC Authorization Code + PKCE gegen `UserIdentity.provider` (ADR 0007 + ausbauen), Keycloak als Referenz-IdP · 5–6 AT · #214 +- [ ] Alternativpfad vertrauenswürdiger Reverse-Proxy-Header bzw. mTLS- + Client-Zertifikat · 2 AT · #215 +- [ ] **Harter Schalter `auth.local.enabled = false`** inkl. Reset- und + Registrierungs-Flows, PATs und Feed-Tokens · 2 AT · #216 +- [ ] Gruppen-/Rollen-Mapping aus IdP-Claims auf das Permission-Modell · 2–3 AT · #217 + +### P1-2 Einstufung als First-Class-Metadatum · 14–18 AT + +_Meilenstein: `M26 — VS-NfD: classification metadata`_ + +- [ ] Enum-Feld `classification` an `Page`, Migration, Default aus + Instance-Setting · 2 AT · #204 +- [ ] Vererbung im Seitenbaum, Herabstufung nur mit eigenem Recht + Audit · 3 AT · #205 +- [ ] Durchreichen in alle Ausgabekanäle · 8–12 AT · #206–#212 + - Web-Ansicht (Kopf/Fuß) · 1 AT · #206 + - **Print-CSS** (`@media print`, Kopf/Fuß je Seite) — fehlt komplett · 1 AT · #207 + - PDF via gotenberg (`pdf-html.ts` Header/Footer-Template) · 1 AT · #208 + - DOCX/ODT via pandoc (Reference-Doc mit Kopf-/Fußzeile) · 2–3 AT · #209 + - Markdown-ZIP (Frontmatter + Aufdruck) · 1 AT · #210 + - Atom-Feeds, Public-API, Suchergebnisse, No-JS-Shell · 2–3 AT · #211 + - Attachment-Download (Dateiname-Präfix + Begleitdatei) · 1–2 AT · #212 +- [ ] Warnung/Sperre beim Anhängen an eingestufte Seiten · 1 AT · #213 + +### P1-3 Verifizierter Offline-/Airgap-Pfad · 8–10 AT ⟵ neu aus Roadmap + +_Meilenstein: `M28 — VS-NfD: offline/airgap deployment` — das +Digest-Pinning (#203) läuft vorgezogen in `M25`_ + +Hochgezogen, weil das eine Frage im **ersten** Behördengespräch ist. „Sollte +gehen" ist dort eine schlechtere Antwort als „getestet, hier ist die Anleitung". + +- [ ] Alle Images auf Digest pinnen (schließt den `gotenberg:8`-Punkt ein) · 1 AT · #203 +- [ ] Mirror-Verfahren in interne Registry dokumentieren · 1 AT · #218 +- [ ] Build ohne Netz reproduzierbar (pnpm Offline-Store / reine + Prebuilt-Images) · 2–3 AT · #219 +- [ ] Testlauf in netzisolierter Umgebung, Protokoll als Beleg · 2 AT · #220 +- [ ] Offline-Update-Pfad inkl. Migrationen · 2–3 AT · #221 + +--- + +## Phase 2 — Billig, hohe Prüfer-Signalwirkung · Summe 22–28 AT + +_Meilensteine: `M24 — VS-NfD: security quick wins`; die nachgezogenen +Punkte (#199, #200, #201, #202) in `M25 — VS-NfD: hardening & supply +chain`_ + +- [ ] **Schlüsseltrennung `COLLAB_TOKEN_SECRET`** per HKDF (zweckgebundene + Subkeys) — echter Fund, vor allen Features · 1–2 AT · #188 +- [ ] **Eigenbau-HMAC-JWT durch `jose` ersetzen** · +2–3 AT · #188 ⟵ neu aus Roadmap + _Gebündelt mit der Zeile darüber, weil dieselbe Datei + (`packages/shared/src/token-crypto.ts`). Einzeln wären es 5–6 AT._ + Achtung: Unsubscribe-Tokens leben lang in versandten Mails → + Dual-Verify-Fenster einplanen. +- [ ] **CSRF fail-closed** — fehlendes Origin _und_ Referer wird derzeit + durchgelassen · 1 AT · #189 +- [ ] **Session-Timeout konfigurierbar**, Default deutlich unter 30 Tagen, + separates Idle-Timeout · 1–2 AT · #190 +- [ ] **Feed-Token raus aus dem Query-Parameter**, alternativ Feeds hart + abschaltbar · 2 AT · #191 +- [ ] **Backup-Ziele einschränkbar** — Allowlist, WebDAV/rsync per Deploy + vollständig deaktivierbar · 2 AT · #192 +- [ ] **Pond-Purge implementieren** — getrashte Ponds bleiben ewig liegen · 3 AT · #193 +- [ ] **Orphan-File-Sweep** implementieren, `Attachment.deletedAt` nutzen + oder entfernen · 2 AT · #194 +- [ ] **Papierkorb aus dem Suchindex** entfernen statt query-seitig filtern · 2 AT · #195 +- [ ] **Retention-Job für `audit_log`** · 1 AT · #196 +- [ ] **Security-Header** (helmet), CORS explizit restriktiv · 1 AT · #197 +- [ ] **SBOM in CI** (CycloneDX/syft) + Lizenzreport als Artefakt · 1–2 AT · #202 +- [ ] `deploy/compose/.env` prüfen, Beispieldatei statt Realdatei · 0,5 AT · #198 +- [ ] **Attachment-Integritätshashes** · +2–3 AT · #199 ⟵ neu aus Roadmap + SHA-256-Spalte, Berechnung beim Upload, Prüfung beim Download, + Backfill-Migration. Nebennutzen: Orphan-Sweep, Dedup, Backup-Verifikation. +- [ ] **Plugins hart abschaltbar** (`plugins.enabled = false`) · +2 AT · #200 ⟵ neu + Deckt das Risiko „Codeausführung in der VS-Zone" für den + Angebotsstand vollständig ab. Hash-Pinning siehe Phase 4. +- [ ] **Syslog/SIEM: Ereigniskatalog** · +3–4 AT · #201 ⟵ neu aus Roadmap + Der Code-Anteil ist klein (stdout-JSON reicht meist). Wert liegt im + **stabilen Ereigniskatalog**: feste Event-IDs, dokumentierte Semantik + und Felder, damit die Behörde SIEM-Regeln schreiben kann. + +--- + +## Phase 3 — Beweissicherung / Lesezugriffe · 8–20 AT ⟵ neu aus Roadmap + +_Meilenstein: `M29 — VS-NfD: read-access audit trail` (Variante A)_ + +Der aufwändigste der nachgezogenen Punkte, und der mit dem größten +Gestaltungsspielraum. Zwei Varianten: + +### Variante A (empfohlen): nur eingestufte Inhalte · 8–10 AT + +Protokolliert werden Lesezugriffe **ausschließlich** auf Seiten mit +`classification = VS_NFD`. Setzt P1-2 voraus. + +- [ ] Instrumentierung der Lesepfade: Seitenansicht, Public-API-GET, + Attachment-Download, Export, No-JS-Shell, Collab-WS-Join · 4 AT · #222 +- [ ] Dedup-Fenster (eine Sitzung + eine Seite innerhalb N Minuten = ein + Ereignis), sonst erzeugt Yjs-Sync eine Ereignisflut · 2 AT · #223 +- [ ] Getrennte Tabelle mit eigener Retention und Partitionierung · 2 AT · #224 +- [ ] Abschaltbar, Zweckbindung dokumentiert · 1–2 AT · #225 + +Vorteil über den Aufwand hinaus: Die Zweckbindung ist sauber begründbar +(„nur eingestufte Inhalte"), was die Personalrats-Diskussion beim Kunden +erheblich entschärft. + +### Variante B: alle Lesezugriffe · 18–20 AT + +Zusätzlich Volumen-, Latenz- und Aufbewahrungsprobleme: gepufferte Schreibung +ohne Ereignisverlust (ein verlorenes Ereignis ist eine Lücke in der +Beweissicherung), Suchtreffer als eigene Ereignisklasse, Partitionierung +zwingend. + +**Einordnung:** Für „einsetzbar in zugelassener Umgebung" ist das kein +zwingendes Produktmerkmal — Beweissicherung kann die Plattform erbringen. In +der Praxis kann Plattform-Logging aber nicht beantworten, _welche eingestufte +Seite_ gelesen wurde (Proxy-Logs kennen URLs, nicht Einstufungen). In +Leistungsbeschreibungen taucht das als Muss-Kriterium auf. Deshalb rein — +aber in Variante A. + +--- + +## Phase 4 — Verbleibende Roadmap + +_Meilenstein: `M31 — VS-NfD: backlog`_ + +Nur noch ein Punkt bleibt draußen: + +- **Plugin-Allowlist mit Hash-Pinning** · 8–10 AT · #232 + Manifest mit SHA-256, Allowlist in `instance_settings`, Prüfung beim Laden, + Admin-UI. Bleibt zurückgestellt, weil Phase 2 mit der harten Abschaltung das + Risiko bereits schließt — und weil echte Code-Signierung ohne juristische + Person ohnehin nicht verfügbar ist. Hash-Pinning ist die richtige Antwort, + aber nicht die dringendste. + +--- + +## Phase 5 — Dokumentation · 15–20 AT (vorher 12–15) + +_Meilenstein: `M30 — VS-NfD: compliance documentation`_ + +Wächst um ca. 25 %, weil jede neue Funktion Handbuch- und Härtungsabschnitte +nach sich zieht. + +- [ ] **Abgrenzungserklärung §52 VSA** — welche Sicherheitsgrundfunktionen die + Anwendung _nicht_ erbringt und wem sie zufallen. Wichtigstes + Einzeldokument. · 3 AT · #226 +- [ ] **Härtungsleitfaden** mit Referenzkonfiguration „VS-NfD-Betrieb": + lokale Auth aus, Public-API aus, MCP aus, Feeds aus, Plugins aus, + Backup nur lokal · 3 AT · #227 +- [ ] **Sicherheitsdokumentation**: Architektur, Datenflüsse, Netzplan, + Ports/Dienste, Vertrauensgrenzen · 4 AT · #228 +- [ ] **Betriebshandbuch**: Installation (inkl. Airgap), Update, Backup/Restore, + Löschung und Vernichtung, Rollentrennung · 4–5 AT · #229 +- [ ] **Zuarbeit IT-Grundschutz** APP.3.1 und CON.11.1, je Anforderung + „Produkt / Betreiber / nicht anwendbar" · 3–4 AT · #230 +- [ ] **Restrisikoliste** mit bewusst offenen Punkten · 1 AT · #231 + +--- + +## Terminwirkung + +| Block | vorher | Rev. 2 | +| ----------------------- | ------------ | ---------------- | +| Phase 1 Blocker | 22–28 AT | 30–38 AT | +| Phase 2 Billigblock | 15–18 AT | 22–28 AT | +| Phase 3 Beweissicherung | — | 8–10 AT (Var. A) | +| Phase 5 Dokumentation | 12–15 AT | 15–20 AT | +| **Summe** | **49–61 AT** | **75–96 AT** | + +Bei 4 produktiven Tagen pro Woche: + +- **vorher:** ca. 3–3,5 Monate +- **Rev. 2 mit Variante A:** ca. **4,5–5,5 Monate** +- **Rev. 2 mit Variante B:** ca. **5,5–6,5 Monate** +- Plus Phase 4 (Hash-Pinning): weitere ~0,5 Monate + +Der Zuwachs von ~26–35 AT verteilt sich zu etwa zwei Dritteln auf +Beweissicherung und Airgap-Verifikation. Beides sind Punkte, nach denen +gefragt wird — nicht Punkte, die man erklären muss. + +**Empfehlung zur Reihenfolge:** Termin für den Angebotsstand nicht +verschieben. Phase 5 und der Billigblock sind nach ca. 3 Monaten fertig — das +genügt, um Gespräche zu führen. Phase 1-3 laufen dahinter weiter. Ein Angebot +mit belegter Dokumentation und laufender Umsetzung ist besser als ein +fertiges Produkt ohne Gesprächspartner. + +--- + +## Zu klärende Punkte aus der Ist-Aufnahme + +- [x] **drawio-Plugin**: geklärt — vendored unter + `packages/plugins/drawio/vendor/`, lädt keine externe Editor-URL; + kein Ausschlusskriterium (Ist-Aufnahme I-40) +- [x] Fließen Labels heute in Exporte? **Nein** — `export.service.ts` lädt + `labelIds` nur für die Permission-Filterung (Ist-Aufnahme I-03a) +- [ ] Was bricht ohne Internetzugang? → wird durch den Testlauf #220 + beantwortet (Ist-Aufnahme I-28) +- [x] MCP-Gate-Duplikat: **divergiert nicht** — nutzt den zentralen + `PermissionService`, eigenständig sind nur die Schalter + (Ist-Aufnahme I-33) +- [x] Collab-WebSocket: WS-Ebene prüft nur das Token, aber Tokens leben + 60 s und Grant-Entzug schließt Verbindungen per `pg_notify`; + Randbedingung für #222 (Ist-Aufnahme I-41) + +--- + +## Ergänzungen aus der Ist-Aufnahme (2026-07-30) + +Befunde der nachgezogenen `10-ist-aufnahme.md`, die in diesem Plan +fehlten — als Issues angelegt: + +- [ ] Conversion-Job-Payloads prunen — Rohbytes jedes Im-/Exports liegen + unbefristet in `conversion_jobs` · 2 AT · #233 (M24, I-22) +- [ ] Retention für `mail_outbox` — Digest-Mails tragen Seitentitel + · 1 AT · #234 (M24, I-23) +- [ ] `page_links.target_slug`-Residuum nach Purge entscheiden + · 0,5 AT · #235 (M24, I-24) +- [ ] Node-Version pinnen — Voraussetzung für #219 · 0,5 AT · #236 (M25, I-26) + +Ohne eigenes Issue: IndexedDB-Kopie auf Endgeräten (I-25) — als +Akzeptanzkriterium in #226 (Abgrenzungserklärung) und #231 +(Restrisikoliste) verankert. + +--- + +## Vorhandene Stärken (im Angebot nach vorne stellen) + +Nicht ausbauen, sondern **belegen**: + +- Keine Telemetrie, keine Update-Checks, keine CDNs, kein Runtime-Nachladen, + Fonts self-hosted, CSP `default-src 'self'` — hier scheitern die meisten + Konkurrenzprodukte +- Zentrales, default-closed Berechtigungsmodell mit deny-wins und + Route-Enumeration-Test — ein prüfbares Artefakt +- Volltextsuche in Postgres statt externer Suchmaschine +- Single-Tenant — passt zur empfohlenen Betriebsform „eine Instanz pro + Einstufungsniveau" +- Public-API und MCP zur Laufzeit hart abschaltbar, Default aus +- Open Source unter MIT — Quelloffenheit ist im Prüfprozess ein Vorteil +- Keine Verschlüsselung im Code = korrekte Architektur, nicht fehlende + Funktion. So argumentieren. diff --git a/docs/vs-nfd/30-issue-adr-auftrag.md b/docs/vs-nfd/30-issue-adr-auftrag.md new file mode 100644 index 0000000..943d159 --- /dev/null +++ b/docs/vs-nfd/30-issue-adr-auftrag.md @@ -0,0 +1,194 @@ +# Übergabe-Auftrag: Maßnahmenplan → ADRs, Issues, Meilensteine + +> Ablage: `docs/vs-nfd/30-issue-adr-auftrag.md` +> Aufruf in Claude Code: _„Arbeite `docs/vs-nfd/30-issue-adr-auftrag.md` ab."_ + +--- + +## Kontext + +`docs/vs-nfd/20-massnahmenplan.md` enthält den priorisierten Maßnahmenplan für +die VS-NfD-Ertüchtigung, abgeleitet aus `docs/vs-nfd/10-ist-aufnahme.md`. +Ziel des Vorhabens: Dorfteich soll in einer nach VSA freigegebenen Umgebung +einer Bundesbehörde (VS-NfD) betrieben werden können, **ohne eigene +BSI-Zulassung**. + +Dieser Auftrag überführt den Plan in die Projektkonventionen: ADRs für +Architekturentscheidungen, Issues für Arbeitspakete, Meilensteine als Bündel. + +## Entscheidungen für diesen Auftrag + +| Punkt | Vorgabe | +| --------------------- | ------------------------------------------------------------------------------------------------------------------------ | +| Sprache ADRs + Issues | **Englisch** (Repo-Konvention). Deutsche Rechtsbegriffe bleiben unübersetzt: VS-NfD, Verschlusssache, VSA, Geheimschutz. | +| Codeänderungen | **Keine.** Diese Session erzeugt nur Doku, Issues und Meilensteine. | +| Anlegen im Forge | **Erst nach Freigabe.** Siehe Zweistufigkeit unten. | + +## Zweistufiges Vorgehen (verbindlich) + +**Stufe 1 — Entwurf zur Review.** Erzeuge `docs/vs-nfd/31-issue-entwurf.md` +mit allen geplanten Meilensteinen, Issues und ADRs im Volltext. Nichts im +Forge anlegen. Am Ende der Stufe: kurze Zusammenfassung, wie viele +Meilensteine/Issues/ADRs entstehen würden, und Rückfrage an Stefan. + +**Stufe 2 — nach ausdrücklicher Freigabe.** ADR-Dateien schreiben, Labels, +Meilensteine und Issues anlegen, Issue-Nummern in den Maßnahmenplan +zurückschreiben. + +Grund: ~30 Issues über eine API anzulegen ist mühsam zu korrigieren. + +## Forge ermitteln, nicht raten + +Bestimme aus `git remote -v` und den verfügbaren CLIs (`gh`, `glab`, `tea`, +`forgejo-cli`), gegen welche Plattform gearbeitet wird. Prüfe, ob eine +Authentifizierung besteht (`gh auth status` o. ä.). Falls unklar oder keine +CLI verfügbar: **Stufe 1 vollständig ausführen und dann fragen** — nicht mit +`curl` gegen eine geratene API-URL improvisieren. + +--- + +## Meilensteine + +Dependency-sortiert, nicht phasen-sortiert. Titel und Reihenfolge übernehmen: + +| # | Meilenstein | Inhalt aus Plan | Aufwand | +| --- | ----------------------------------- | ------------------------------------------------------------------------------------------- | -------- | +| M1 | `VS-NfD: security quick wins` | Phase 2 ohne die drei nachgezogenen Punkte | 15–18 AT | +| M2 | `VS-NfD: hardening & supply chain` | P2 nachgezogen: Attachment-Hashes, Plugin-Abschaltung, Ereigniskatalog, SBOM, Image-Digests | 7–10 AT | +| M3 | `VS-NfD: classification metadata` | P1-2 vollständig | 14–18 AT | +| M4 | `VS-NfD: external authentication` | P1-1 vollständig | 10–12 AT | +| M5 | `VS-NfD: offline/airgap deployment` | P1-3 vollständig | 8–10 AT | +| M6 | `VS-NfD: read-access audit trail` | Phase 3, Variante A | 8–10 AT | +| M7 | `VS-NfD: compliance documentation` | Phase 5 | 15–20 AT | +| M8 | `VS-NfD: backlog` | Phase 4 (Plugin-Hash-Pinning) | 8–10 AT | + +Abhängigkeiten in die Meilenstein-Beschreibung schreiben: +M6 setzt M3 voraus. M2 (Image-Digests) sollte vor M5 liegen. M7 läuft +parallel und beginnt früh — die Abgrenzungserklärung ist nicht von +Implementierung abhängig. + +## Labels + +Anlegen, falls nicht vorhanden: +`vs-nfd` · `vs-nfd:blocker` · `effort:S` · `effort:M` · `effort:L` · +`area:auth` · `area:export` · `area:storage` · `area:supply-chain` · +`area:docs` + +--- + +## Issue-Vorlage + +Ein Issue pro Checkbox-Zeile des Maßnahmenplans. Unterpunkte mit eigenem +Aufwand (z. B. die Ausgabekanäle in P1-2) werden **eigene Issues**, nicht +Checklisten in einem Sammel-Issue — sie sind unabhängig abschließbar. + +```markdown +**Title:** [VS-NfD] + +**Plan reference:** `docs/vs-nfd/20-massnahmenplan.md` → +**ADR:** +**Effort:** ( AT) +**Depends on:** <#issue or "—"> + +## Context + +Why this matters for VS-NfD operation. One or two sentences. +Where relevant, reference the §52 VSA principle: the application must not +implement security base functions itself. + +## Current state + +Findings from `10-ist-aufnahme.md`, with file paths. Do not invent paths — +copy only what the Ist-Aufnahme actually cites, and drop line numbers that +were not verified there. + +## Acceptance criteria + +- [ ] verifiable, testable statements +- [ ] including the required test +- [ ] including the documentation touched (operations manual / hardening guide) + +## Out of scope + +What explicitly does not belong here. +``` + +Regeln: + +- **Keine erfundenen Fundorte.** Nur Pfade aus der Ist-Aufnahme übernehmen. + Wo diese keine verifizierte Zeilennummer nennt, ohne Zeilennummer zitieren. +- Jedes Issue nennt die Dokumentation, die es mitzieht. Nicht dokumentierte + Funktionen sind für dieses Vorhaben wertlos. +- Aufwandsangaben aus dem Plan übernehmen, nicht neu schätzen. + +--- + +## ADRs + +Nächste freie Nummer aus `docs/adr/` (o. ä.) ermitteln — vorhanden sind u. a. +0007 (UserIdentity/provider) und 0016 (self-hosted fonts). Bestehendes +Dateinamens- und Statusschema übernehmen. + +Anzulegen, in dieser Reihenfolge: + +**A. `No security base functions in the application (§52 VSA)`** — Ankerdokument +Entscheidung: Die Anwendung erbringt keine Sicherheitsgrundfunktion i.S.v. +§52 VSA. Verschlüsselung, Datenträgerschutz, Netzabschluss und +Authentisierung liegen bei der Plattform des Betreibers. Konsequenz: kein +eigenes MFA, keine Inhalts- oder Backup-Verschlüsselung, keine neuen +Krypto-Primitive. Begründung: hält die Anwendung außerhalb der +Zulassungspflicht nach §51 VSA. +_Dieses ADR ist zugleich der Rohentwurf der Abgrenzungserklärung aus M7 — +entsprechend sorgfältig schreiben._ + +**B. `Token crypto: HKDF key separation and vetted JWT library`** +Zweckgebundene Subkeys statt gemeinsamem `COLLAB_TOKEN_SECRET`; Ersatz des +Eigenbau-HMAC-JWT durch `jose`. Dual-Verify-Fenster für langlebige +Unsubscribe-Tokens dokumentieren. + +**C. `External authentication via OIDC; local passwords optional`** +Erweitert ADR 0007. Enthält die Entscheidung für einen harten Schalter +`auth.local.enabled = false` und die Alternativpfade Proxy-Header / mTLS. + +**D. `Classification as first-class page metadata`** +Warum ein eigenes Enum-Feld an `Page` und **nicht** Labels: Labels sind +pond-scoped, nutzerbearbeitbar und vererben nicht. Enthält außerdem die +zentrale Betriebsentscheidung: **Trennung der Einstufungsniveaus erfolgt +außerhalb der Anwendung** (eine Instanz je Niveau), die Kennzeichnung +innerhalb. Die anwendungsseitige ACL ist Ordnung, nicht Schutzmechanismus. + +**E. `Read-access audit trail limited to classified content`** +Variante A. Begründung: Ereignisvolumen bei Yjs-Sync, saubere Zweckbindung, +Entschärfung der Mitbestimmungsfrage beim Betreiber. + +**F. `Reproducible offline deployment`** +Image-Digest-Pinning, interne Registry, Offline-Update-Pfad. + +**G. `Plugin trust model`** +Kurzfristig harte Abschaltbarkeit; Hash-Pinning statt Code-Signierung, weil +ohne juristische Person keine Signaturidentität verfügbar ist. + +**H. `Backup target restriction`** +Allowlist für Backup-Ziele; keine anwendungsseitige Verschlüsselung +(folgt aus A). + +Jedes ADR verlinkt am Ende die umsetzenden Issues; jedes Issue verlinkt sein ADR. + +--- + +## Rückschreiben in den Plan + +Nach Stufe 2: In `20-massnahmenplan.md` hinter jeder Checkbox die Issue-Nummer +ergänzen (`· #142`) und je Phase den Meilenstein nennen. Der Plan bleibt der +Index, der Forge hält den Zustand. + +## Verifikation + +- [ ] Jede Checkbox-Zeile des Plans hat genau ein Issue (oder ist als + bewusst zusammengefasst begründet) +- [ ] Jedes Issue hat Meilenstein, Effort-Label, Area-Label und + Akzeptanzkriterien +- [ ] Jedes Issue mit Architekturbezug verweist auf ein ADR +- [ ] Alle zitierten Pfade existieren (`ls`/`grep` stichprobenartig prüfen) +- [ ] Summe der AT je Meilenstein stimmt mit der Tabelle oben überein +- [ ] `git status`: außer `docs/` nichts geändert diff --git a/docs/vs-nfd/31-issue-entwurf.md b/docs/vs-nfd/31-issue-entwurf.md new file mode 100644 index 0000000..765b130 --- /dev/null +++ b/docs/vs-nfd/31-issue-entwurf.md @@ -0,0 +1,3192 @@ +# Entwurf: Meilensteine, Issues und ADRs für die VS-NfD-Ertüchtigung + +> **Stufe 2 ausgeführt am 2026-07-30:** ADRs 0019–0026 geschrieben, 11 +> Labels, Meilensteine `M24`–`M31` und Issues **#188–#236** im Forge +> angelegt — die Nummern entsprechen 1:1 den provisorischen dieses +> Entwurfs, der Korrekturschritt entfiel. Issue-Referenzen stehen im +> Maßnahmenplan an den Checkboxen. +> Auftrag: `docs/vs-nfd/30-issue-adr-auftrag.md`. +> Quelle: `docs/vs-nfd/20-massnahmenplan.md` (Rev. 2). +> Stand Stufe 1: 2026-07-29 (Review-Entwurf, Freigabe durch Stefan +> 29./30.07.). + +Sprache: Meilenstein-Titel, Issue- und ADR-Texte sind **englisch** +(Repo-Konvention), der Rahmen dieses Dokuments deutsch. Deutsche +Rechtsbegriffe bleiben unübersetzt: VS-NfD, Verschlusssache, VSA, +Geheimschutz. + +--- + +## 0. Befunde zum Auftrag selbst (bitte zuerst lesen) + +### 0.1 Die Ist-Aufnahme fehlte — inzwischen nachgezogen + +Der Auftrag verlangt, Fundorte **ausschließlich** aus +`docs/vs-nfd/10-ist-aufnahme.md` zu übernehmen. Diese Datei existierte beim +Schreiben dieses Entwurfs **nicht** — im Repo lag nur ihr +Analyse-_Auftrag_ (jetzt `docs/vs-nfd/00-analyse-auftrag.md`). Der +Maßnahmenplan Rev. 2 ist aus einer Analyse entstanden, die nie als Datei +abgelegt wurde. + +**Vorgehen stattdessen:** Jede im Plan behauptete Tatsache wurde direkt am +Code verifiziert. Das ist strenger als Kopieren, denn es deckt auch +Abweichungen auf (siehe 0.2). Jeder Fundort unten ist belegt; wo etwas +nicht belegbar war, steht es als `UNKLAR` da. + +**Nachtrag (auf Stefans Entscheidung, gleiche Session):** +`docs/vs-nfd/10-ist-aufnahme.md` ist inzwischen geschrieben — 42 Befunde +(5 BLOCKIEREND, 21 ANPASSEN, 4 UNKLAR, 12 OK) mit Fundort und Bewertung. +Sie bestätigt die Plan-Befunde, enthält die drei Korrekturen aus §0.2 und +liefert **fünf Ergänzungen, die im Plan fehlen** (I-22 bis I-26). Vier +davon sind hier als Issues #233–#236 nachgetragen; die fünfte (I-25, +IndexedDB-Kopie auf Endgeräten) ist als Akzeptanzkriterium in #226 und +#231 eingearbeitet, weil sie eine Dokumentationspflicht ist und keine +Codeänderung. + +### 0.2 Korrekturen am Maßnahmenplan aus der Verifikation + +| Plan-Aussage | Verifikation | Konsequenz | +| ------------------------------------------------------------------------ | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------- | +| „`deploy/compose/.env` prüfen, Beispieldatei statt Realdatei" | Die Datei existiert lokal, ist aber **nicht im Repo** — `.gitignore:5` schließt `.env` aus, `git ls-files deploy/compose/` listet nur `.env.example`. | Issue #198 wird zur **Verifikation + Doku**, nicht zur Entschärfung eines Lecks. Aufwand bleibt 0,5 AT. | +| „drawio-Plugin: lädt es eine externe Editor-URL?" | **Nein.** drawio ist vendored: `packages/plugins/drawio/vendor/drawio-30.3.6/`. `manifest.json` nennt keine externe URL (`kind: code`, `permissions: ["blockData","ui"]`, `homepage` ist nur Metadatum). | Kein Ausschlusskriterium, kein Issue. Restprüfung: eigene Fetches der vendorten Webapp — durch CSP `default-src 'self'` blockiert. | +| „Fließen Labels heute in Exporte?" | **Nein.** `export.service.ts:98–105` lädt `labelIds` ausschließlich für `permissions.filterPages`; kein Export-Pfad schreibt Labels in die Ausgabe. | Bestätigt die ADR-0022-Begründung: Labels wären als Kennzeichnung schon deshalb untauglich, weil sie die Anwendung nie verlassen. Kein Issue. | +| „MCP-Gate-Duplikat: divergiert es vom zentralen Modell?" | **Nein.** `mcp.service.ts:232–253` ruft `PermissionService.hasPondRole` / `canAccessPage`. Eigenständig sind nur die _Schalter_ (`mcp.enabled` + Pond-`mcpEnabled`), nicht die Entscheidungslogik. | Kein Issue. | +| „Collab-WebSocket: Autorisierung über Permission-Modell oder nur Token?" | **Nur Token.** `apps/collab/src/server.ts:108–125`: `verifyCollabToken` + Abgleich `claims.pageId === documentName`; das Permission-Modell wirkt nur bei der Token-_Ausgabe_ in der api, Entzug asynchron per `pg_notify`-Access-Listener (`apps/collab/src/index.ts:62–70`). | Kein eigenes Issue — fließt als Randbedingung in #222 (Instrumentierung des Collab-WS-Join) ein. | +| „Was bricht ohne Internetzugang?" | Offen — beantwortbar nur durch den Testlauf. | Fließt in #220 ein, kein eigenes Issue. | + +Damit sind **vier der fünf** offenen Punkte des Plans in dieser Session +geklärt und brauchen kein Issue; der fünfte ist Teil von M5. + +### 0.3 Aufwandssummen: der Plan ist in sich nicht konsistent + +Der Auftrag verlangt, Aufwände zu übernehmen und die Meilenstein-Summen +gegen die Tabelle zu prüfen. Ergebnis: Die Meilenstein-Zahlen des Auftrags +sind **konsistent mit den Phasen-Kopfzahlen** des Plans, aber die +Phasen-Kopfzahlen sind **kleiner als die Summe ihrer eigenen +Positionen**: + +| Block | Kopfzahl im Plan | Summe der Positionen | Delta | +| -------------- | ---------------- | -------------------- | ------- | +| Phase 2 | 22–28 AT | 26,5–32,5 AT | +4,5 AT | +| Phase 1 · P1-1 | 10–12 AT | 11–13 AT | +1 AT | +| Phase 1 · P1-2 | 14–18 AT | 14–18 AT | ±0 | +| Phase 1 · P1-3 | 8–10 AT | 8–10 AT | ±0 | +| Phase 3 Var. A | 8–10 AT | 9–10 AT | +1 AT | +| Phase 5 | 15–20 AT | 18–20 AT | +3 AT | + +Ich habe **nicht neu geschätzt** (Auftragsregel). Die Milestone-Tabelle in +Abschnitt 2 nennt daher beide Zahlen: die Auftrags-Vorgabe und die Summe +der zugeordneten Issues. Klärungsbedarf, siehe Abschnitt 6. + +### 0.4 Forge: Gitea, keine passende CLI + +- `git remote -v` → `git@gitea-fable-5:stwaidele/dorfteich.git`, also + **Gitea 1.22 auf `gitea.101010.cloud`**. +- CLIs: `gh` ist installiert, aber gegen **github.com** authentifiziert + (`gh auth status` → Account `stwaidele`) — für dieses Repo unbrauchbar. + `tea`, `glab`, `forgejo-cli` sind nicht installiert. +- Der Auftrag verbietet, „mit curl gegen eine geratene API-URL zu + improvisieren". Das trifft hier nicht zu: Die **Gitea REST API v1** ist + der im Projekt etablierte, dokumentierte Weg (`CLAUDE.md`, `Handoff.md` + → „Typische Handgriffe"), inklusive Konto `fable-5` und + Passwort-Handhabung. Lesend bereits in dieser Session genutzt (Labels, + Meilensteine, höchste Issue-Nummer). +- **Vorhandene Labels:** `auth`, `backend`, `blocked`, `collab`, + `deployment`, `docs`, `frontend`, `plugins`, `qa`. +- **Vorhandene Meilensteine:** `M0`…`M23` plus + `Barrierefreiheit WCAG 2.1 AA` (M0–M11 offen, M12–M23 geschlossen). +- **Höchste Issue-Nummer:** #187. Nächste freie: **#188**. +- **Nächste freie ADR-Nummer:** **0019** (`docs/architecture/adr/`, + vorhanden 0001–0018). + +Daraus folgen zwei Namenskollisions-Fragen an Stefan (Abschnitt 6). + +--- + +## 1. Umfang dieses Entwurfs + +| Artefakt | Anzahl | +| ------------ | ---------------------------------------- | +| Meilensteine | **8** | +| Issues | **49** (#188–#236, Nummern provisorisch) | +| ADRs | **8** (0019–0026) | +| Neue Labels | **11** (10 aus dem Auftrag + `area:ops`) | + +45 Issues aus dem Maßnahmenplan plus **4 aus der Ist-Aufnahme** +(#233–#236, Befunde I-22 bis I-24 und I-26 — im Plan nicht enthalten). + +Die 45 liegen über der Auftrags-Schätzung („~30"). Ursache ist keine +Ausweitung, sondern die Auftragsregel selbst: Die sieben Ausgabekanäle aus +P1-2 werden eigene Issues (statt einer Checkliste), und Phase 5 liefert +sechs Dokumentations-Issues. Kürzungsoptionen in Abschnitt 6 — von Stefan +verworfen, die Granularität bleibt. + +--- + +## 2. Meilensteine + +Reihenfolge und Titel wie im Auftrag vorgegeben (dependency-sortiert). + +| Titel | Issues | AT (Auftrag) | AT (Summe Issues) | +| ----------------------------------- | ------------------------- | ------------ | ----------------- | +| `VS-NfD: security quick wins` | #188–#198, #233–#235 (14) | 15–18 | 21,5–24,5 | +| `VS-NfD: hardening & supply chain` | #199–#203, #236 (6) | 7–10 | 9,5–12,5 | +| `VS-NfD: classification metadata` | #204–#213 (10) | 14–18 | 14–18 ✓ | +| `VS-NfD: external authentication` | #214–#217 (4) | 10–12 | 11–13 | +| `VS-NfD: offline/airgap deployment` | #218–#221 (4) | 8–10 | 7–9 | +| `VS-NfD: read-access audit trail` | #222–#225 (4) | 8–10 | 9–10 | +| `VS-NfD: compliance documentation` | #226–#231 (6) | 15–20 | 18–20 | +| `VS-NfD: backlog` | #232 (1) | 8–10 | 8–10 ✓ | + +`offline/airgap` liegt unter der Vorgabe, weil das Digest-Pinning (1 AT) +laut Auftrag nach `hardening & supply chain` wandert. + +### Beschreibungstexte (für das Meilenstein-Feld) + +**`VS-NfD: security quick wins`** + +> Cheap changes with high assessor signal: token key separation, fail-closed +> CSRF, bounded sessions, restricted backup targets, and the data-hygiene +> jobs that are currently missing (pond purge, orphan files, trash in the +> search index, audit retention). No dependencies — can start immediately. + +**`VS-NfD: hardening & supply chain`** + +> The items pulled forward from the roadmap plus supply-chain evidence: +> attachment integrity hashes, a hard plugin off-switch, a stable SIEM event +> catalogue, SBOM in CI, and image digest pinning. **Digest pinning should +> land before `VS-NfD: offline/airgap deployment`** — the mirror procedure +> and the offline update path both build on immutable references. + +**`VS-NfD: classification metadata`** + +> `classification` as first-class page metadata (ADR 0022) and its +> pass-through into every output channel. Separating classification _levels_ +> stays outside the application (one instance per level); this milestone +> delivers the _marking_. **Prerequisite for `VS-NfD: read-access audit +trail`.** + +**`VS-NfD: external authentication`** + +> OIDC (Authorization Code + PKCE) against the existing +> `UserIdentity.provider` slot, proxy-header/mTLS as the alternative path, +> and a hard `auth.local.enabled = false` switch including every token flow. +> Delegates the authentication base function to the operator's platform +> (ADR 0019). + +**`VS-NfD: offline/airgap deployment`** + +> A _verified_ airgap path, not a plausible one: registry mirror procedure, +> network-free reproducible build, a documented isolated test run, and an +> offline update path including migrations. **Depends on image digest +> pinning in `VS-NfD: hardening & supply chain`.** + +**`VS-NfD: read-access audit trail`** + +> Variant A of the plan: read events **only** for pages with +> `classification = VS_NFD`. **Requires `VS-NfD: classification +metadata`.** Deliberately narrow — clean purpose limitation, and it keeps +> Yjs sync from producing an event flood. + +**`VS-NfD: compliance documentation`** + +> The §52 VSA delimitation statement, hardening guide, security +> documentation, operations manual, IT-Grundschutz mapping (APP.3.1, +> CON.11.1) and the residual-risk list. **Runs in parallel and starts +> early** — the delimitation statement does not depend on any +> implementation. + +**`VS-NfD: backlog`** + +> Deliberately deferred: plugin allowlist with hash pinning. The hard +> off-switch in `VS-NfD: hardening & supply chain` already closes the +> "code execution in the VS zone" risk for the offer stage. + +--- + +## 3. Labels + +Neu anzulegen (10): + +| Label | Farbe (Vorschlag) | Bedeutung | +| ------------------- | ----------------- | ----------------------------------------------------------------------- | +| `vs-nfd` | `#1f3a5f` | Gehört zur VS-NfD-Ertüchtigung | +| `vs-nfd:blocker` | `#8b0000` | Verhindert den Einsatz, bis gelöst (Phase 1) | +| `effort:S` | `#c2e0c6` | ≤ 1 AT | +| `effort:M` | `#fef2c0` | 2–3 AT | +| `effort:L` | `#f9c9c9` | ≥ 4 AT | +| `area:auth` | `#5319e7` | Authentisierung, Sessions, Tokens, CSRF | +| `area:export` | `#0e8a16` | Ausgabekanäle | +| `area:storage` | `#006b75` | Datenhaltung, Löschung, Suchindex, Audit-Tabellen | +| `area:supply-chain` | `#b60205` | Images, Abhängigkeiten, Offline-Pfad | +| `area:docs` | `#0075ca` | Dokumentation | +| `area:ops` | `#6a737d` | Querschnitt Betrieb: Security-Header/CORS, SIEM-Katalog (Rückfrage 6.3) | + +Die Effort-Schwellen sind eine Festlegung dieses Entwurfs (der Plan nennt +nur AT). Zwei Issues fallen in keinen `area:`-Wert saubar: #197 +(Security-Header) und #201 (Ereigniskatalog) — siehe Rückfrage 6.3. + +--- + +## 4. Issues + +Nummern waren beim Entwurf provisorisch (nächste freie war #188) und +wurden beim Anlegen am 2026-07-30 **1:1 bestätigt** — alle `Depends +on`-Verweise stimmen unverändert. + +Alle Fundorte sind in dieser Session am Code geprüft. Zeilennummern stehen +nur dort, wo sie verifiziert wurden. + +--- + +### M1 — `VS-NfD: security quick wins` + +#### #188 — [VS-NfD] Separate token keys with HKDF and replace the homegrown JWT with `jose` + +**Plan reference:** `docs/vs-nfd/20-massnahmenplan.md` → Phase 2, lines 1+2 +**ADR:** ADR 0020 +**Effort:** L (3–5 AT) +**Depends on:** — +**Labels:** `vs-nfd` `effort:L` `area:auth` + +_Deliberately merged from two plan checkboxes — the plan itself justifies +it: both touch `packages/shared/src/token-crypto.ts`, and doing them +separately costs 5–6 AT instead of 3–5._ + +## Context + +One secret currently serves two unrelated purposes. Purpose-bound subkeys +derived via HKDF make a compromise of one path non-transferable, and a +vetted JWT implementation removes hand-written crypto from the trust +boundary. This does **not** introduce a new security base function +(§52 VSA) — it narrows crypto the application already performs. + +## Current state + +- `COLLAB_TOKEN_SECRET` (`packages/shared/src/env.ts:55,142`) signs + collaboration tokens (`apps/collab/src/index.ts:46`, + `apps/api/src/pages/pages.service.ts:383`) **and** unsubscribe tokens + (`apps/api/src/notifications/digest.service.ts:161`, + `apps/api/src/notifications/notifications.controller.ts:51`). +- `packages/shared/src/token-crypto.ts:1,37` implements a compact HS256 JWT + with `node:crypto` (`createHmac`, `timingSafeEqual`). The file header + documents the reason: identical code in the CommonJS api and the ESM + collab server. +- Neither `jose` nor `jsonwebtoken` is a dependency of any workspace + package. +- Partial separation already exists: `apps/api/src/notifications/unsubscribe-token.ts:14` + prefixes a `PURPOSE` constant into the HMAC input. + +## Acceptance criteria + +- [ ] Subkeys are derived per purpose via HKDF from the configured root + secret; no code path signs with the root secret directly. +- [ ] Collaboration tokens are produced and verified by `jose`, HS256 only, + with the algorithm allowlist asserted by a test. +- [ ] A cross-runtime test proves the same token verifies in the api + (CommonJS) and the collab server (ESM) — the reason the homegrown + implementation existed. +- [ ] Unsubscribe tokens keep a documented **dual-verify window** (old and + new derivation accepted) long enough to cover links already in sent + mail; the window's length and expiry date are documented. +- [ ] Negative tests: token signed with a different purpose's subkey is + rejected; `alg: none` and `RS256` are rejected. +- [ ] `docs/architecture/security.md` §"Secrets & configuration" documents + the key hierarchy; `deploy/compose/.env.example` documents the root + secret's role. + +## Out of scope + +Rotation automation, moving secrets into an external KMS, and any change to +session cookies (see #190). + +--- + +#### #189 — [VS-NfD] Make the CSRF origin check fail closed + +**Plan reference:** `20-massnahmenplan.md` → Phase 2 +**ADR:** n/a +**Effort:** S (1 AT) +**Depends on:** — +**Labels:** `vs-nfd` `effort:S` `area:auth` + +## Context + +A state-changing request that presents neither `Origin` nor `Referer` +currently passes the same-origin check. An assessor reads that as a +fail-open control regardless of whether SameSite cookies happen to cover +the browser case. + +## Current state + +- `apps/api/src/auth/auth.guard.ts:108–112`: `assertSameOrigin` takes + `origin ?? referer` and returns early when both are absent. The comment + names the intent (non-browser clients such as curl and supertest; + SameSite cookies as the real defence). + +## Acceptance criteria + +- [ ] Mutating requests without `Origin` and without `Referer` are + rejected with `403 csrf_origin_mismatch`. +- [ ] A documented, deliberate exception path exists for non-browser + clients (PAT/bearer authentication), and cookie-authenticated + requests never benefit from it. +- [ ] Tests: cookie-auth mutation without either header → 403; + PAT-authenticated mutation without either header → success; + mismatching origin → 403 (existing behaviour, kept). +- [ ] The api's own test helpers and e2e fixtures are adjusted rather than + the check weakened. +- [ ] `docs/architecture/security.md` records the fail-closed rule. + +## Out of scope + +CSRF tokens as a second mechanism — the origin check plus SameSite is the +chosen model. + +--- + +#### #190 — [VS-NfD] Make the session lifetime configurable and add an idle timeout + +**Plan reference:** `20-massnahmenplan.md` → Phase 2 +**ADR:** n/a +**Effort:** M (1–2 AT) +**Depends on:** — +**Labels:** `vs-nfd` `effort:M` `area:auth` + +## Context + +A sliding 30-day session is far outside what a VS-NfD operating concept +accepts, and it is a compile-time constant today. The operator must be able +to set both an absolute and an idle bound. + +## Current state + +- `apps/api/src/auth/sessions.service.ts:8`: + `const SESSION_TTL_MS = 30 * 24 * 60 * 60 * 1000; // sliding 30 days`, + applied at creation (`:31`) and renewed on every touch (`:50`). +- `apps/api/src/auth/auth.guard.ts:58` sets the cookie `maxAge` to the same + 30 days. +- No idle timeout exists; `lastSeenAt` is written but never used as a bound. + +## Acceptance criteria + +- [ ] Absolute lifetime and idle timeout are separately configurable, with + defaults well below 30 days; the cookie `maxAge` follows the + configured value. +- [ ] Idle expiry is enforced server-side against `lastSeenAt`, not only by + cookie expiry. +- [ ] Tests: session past its absolute bound is rejected; session idle past + the idle bound is rejected; active use renews idle but never exceeds + the absolute bound. +- [ ] Hardening guide (#227) names the recommended VS-NfD values; + `.env.example` documents the settings. + +## Out of scope + +Forced re-authentication for individual actions, and concurrent-session +limits. + +--- + +#### #191 — [VS-NfD] Remove the feed token from the query string, or allow feeds to be disabled + +**Plan reference:** `20-massnahmenplan.md` → Phase 2 +**ADR:** n/a +**Effort:** M (2 AT) +**Depends on:** — +**Labels:** `vs-nfd` `effort:M` `area:auth` + +## Context + +Credentials in URLs land in proxy logs, browser history and referrer +headers. In a VS zone the proxy log is exactly the place where a long-lived +read credential must not appear. + +## Current state + +- `apps/api/src/public/public.controller.ts:28,43` read the credential from + `@Query('token')`; `apps/api/src/public/feed.service.ts:25` documents the + scheme (the query parameter authenticates the request as the token's + user). +- Feed tokens are stored hashed (`apps/api/src/public/feed-tokens.service.ts:73`), + so the exposure is transport/logging, not storage. +- There is no instance switch for feeds — `instance-settings.service.ts` + has `api.enabled` and `mcp.enabled` but no feed equivalent. + +## Acceptance criteria + +- [ ] Either the token moves out of the query string (header or path + segment with documented cache implications), **or** an instance + switch `feeds.enabled` (default off for the VS-NfD reference config) + makes the whole surface answer 404 — the plan allows either. +- [ ] Whichever path is chosen, the other is documented as rejected with a + reason. +- [ ] Tests: existing feed reader flow still works; with the switch off + every feed route answers 404; no code path logs the token. +- [ ] Hardening guide (#227) lists the setting. + +## Out of scope + +Replacing feeds with a different notification channel. + +--- + +#### #192 — [VS-NfD] Restrict backup targets to an allowlist and allow remote targets to be disabled at deploy time + +**Plan reference:** `20-massnahmenplan.md` → Phase 2 +**ADR:** ADR 0026 +**Effort:** M (2 AT) +**Depends on:** — +**Labels:** `vs-nfd` `effort:M` `area:storage` + +## Context + +A backup destination is an egress path for the full content of the +instance. In a VS zone the set of permissible destinations is decided by +the operator, not by whoever holds Site-Admin. Encryption stays out — +media protection is the platform's base function (ADR 0019). + +## Current state + +- Remote target is a freely configurable WebDAV/Nextcloud URL: + `backup.nextcloud.baseUrl` in + `apps/api/src/settings/instance-settings.service.ts` (validated as a URL, + no host restriction), consumed via + `apps/api/src/backup/backup-target.service.ts:3,65` and + `apps/api/src/admin/backup-admin.service.ts:27,112,146`. +- A second remote path is the rsync mirror (`apps/backup/src/mirror.ts`, + ADR 0015, issue #84). +- No allowlist and no deploy-level kill switch for either path. + +## Acceptance criteria + +- [ ] A deploy-level allowlist (env, not a runtime setting) constrains + permissible backup destination hosts; a value outside it is rejected + with a clear admin-visible error. +- [ ] An empty allowlist disables **all** remote targets — WebDAV and rsync + mirror — and the admin UI reflects that they are unavailable, not + merely unconfigured. +- [ ] Tests: destination outside the allowlist rejected; empty allowlist + leaves only the local target; existing configured destination inside + the allowlist unaffected. +- [ ] `docs/architecture/operations.md` and the hardening guide (#227) + document the "local only" reference configuration. + +## Out of scope + +Application-side backup encryption (deliberately excluded, ADR 0019/0026) +and changes to the restore flow. + +--- + +#### #193 — [VS-NfD] Implement pond purge + +**Plan reference:** `20-massnahmenplan.md` → Phase 2 +**ADR:** n/a +**Effort:** M (3 AT) +**Depends on:** — +**Labels:** `vs-nfd` `effort:M` `area:storage` + +## Context + +Deletion must actually delete. A trashed pond that stays in the database +forever is an unbounded residue of classified content and unanswerable in +a "deletion and destruction" chapter of the operations manual. + +## Current state + +- `Pond.deletedAt` / `deletedBy` implement a pond-level trash + (`apps/api/prisma/schema.prisma:175,186`, ADR 0013). +- Purge exists **only for pages**: `apps/api/src/trash/trash.service.ts` + (`purgeNow:96`, `purgeDuePages:104`, `purgePage:121`) and the endpoint + `apps/api/src/trash/trash.controller.ts:26`. No equivalent for ponds. + +## Acceptance criteria + +- [ ] Retention-driven and manual pond purge exist and remove, in one + transaction-safe sequence: pages, revisions, `page_updates`, + `page_content_cache` rows (including the search vector), + attachments on disk, labels, links, comments, favorites, watches, and + pond-level grants. +- [ ] Quota counters are corrected; the operation is idempotent and + resumable (a purge that races another is a no-op, as for pages). +- [ ] Audit event recorded for both manual and retention purge. +- [ ] DB test proves nothing referencing the pond survives, and a follow-up + search for content of the purged pond returns nothing. +- [ ] `docs/architecture/operations.md` documents retention behaviour; + operations manual (#229) covers it under deletion and destruction. + +## Out of scope + +Purge of user accounts (already covered by existing pseudonymization) and +the orphan-file sweep (#194). + +--- + +#### #194 — [VS-NfD] Implement the orphan-file sweep and resolve `Attachment.deletedAt` + +**Plan reference:** `20-massnahmenplan.md` → Phase 2 +**ADR:** n/a +**Effort:** M (2 AT) +**Depends on:** — +**Labels:** `vs-nfd` `effort:M` `area:storage` + +## Context + +Files whose page no longer embeds them are never reclaimed, so content can +survive its page indefinitely on disk. An unused nullable column that looks +like a soft-delete marker is itself a finding — a reader cannot tell +whether deletion is soft or hard. + +## Current state + +- `apps/api/prisma/schema.prisma:530–538` documents both facts explicitly: + the `pageId` link is "not touched when an image is later removed from its + page's content — an orphan-file sweep to reclaim those is a separate + future maintenance job (operations.md), not this one", and + "`deletedAt` stays unused for now — purge hard-deletes attachments". + +## Acceptance criteria + +- [ ] A scheduled sweep identifies attachments no longer referenced by any + live page document and removes row plus file, correcting quota usage. +- [ ] A grace period protects the paste-then-insert window (an upload whose + page does not exist yet) — proven by a test. +- [ ] `Attachment.deletedAt` is either used by the sweep with documented + semantics **or** removed by migration; the schema comment matches the + outcome. +- [ ] Test: file removed from page content is reclaimed after the grace + period; a freshly uploaded, not-yet-embedded file is not. +- [ ] `docs/architecture/operations.md` documents the job; #229 covers it. + +## Out of scope + +Deduplication by content hash (see #199) and pond purge (#193). + +--- + +#### #195 — [VS-NfD] Remove trashed content from the search index instead of filtering at query time + +**Plan reference:** `20-massnahmenplan.md` → Phase 2 +**ADR:** n/a +**Effort:** M (2 AT) +**Depends on:** — +**Labels:** `vs-nfd` `effort:M` `area:storage` + +## Context + +The full-text index holds plaintext of trashed pages and ponds; only the +query hides them. Any future query path that forgets the filter leaks +content, and the index is a content copy that a deletion concept has to +account for. + +## Current state + +- `apps/api/src/search/postgres-search.provider.ts:41` — the weighted + `tsvector` lives on `page_content_cache.search_vector`. +- `:142–143` — the search query joins + `pages p ON … p.deleted_at IS NULL` and + `ponds po ON … po.deleted_at IS NULL`: query-side filtering. +- `:73` shows the vector can be nulled per page + (`UPDATE page_content_cache SET search_vector = NULL WHERE page_id = …`). + +## Acceptance criteria + +- [ ] Trashing a page or pond clears the search vector of the affected + pages; restoring rebuilds it. +- [ ] The query-side `deleted_at IS NULL` guards **stay** (defence in + depth) and a test asserts both layers independently. +- [ ] Test: a trashed page's unique term is absent from the index rows + themselves, not merely from results; restore makes it findable again. +- [ ] A one-off backfill clears vectors of already-trashed content. +- [ ] `docs/architecture/security.md` records that the index holds no + trashed content (the file has no search section today — add one, or + extend §"Content & upload security"). + +## Out of scope + +Encrypting or removing the plaintext cache for live pages, and any change +of search engine. + +--- + +#### #196 — [VS-NfD] Add a retention job for `audit_log` + +**Plan reference:** `20-massnahmenplan.md` → Phase 2 +**ADR:** n/a +**Effort:** S (1 AT) +**Depends on:** — +**Labels:** `vs-nfd` `effort:S` `area:storage` + +## Context + +An audit trail without a retention rule grows without bound and conflicts +with data-protection requirements the operator has to answer for. A +configurable period is also what the IT-Grundschutz mapping (#230) needs +to reference. + +## Current state + +- `audit_log` is the `AuditEntry` model + (`apps/api/prisma/schema.prisma:74–91`, issue #86), written by + `apps/api/src/audit/audit.service.ts` in addition to the `audit: …` + stdout line. +- No retention or pruning job for the table exists. + +## Acceptance criteria + +- [ ] Retention period is configurable (instance setting or env, matching + the pattern used for backup retention) with a documented default. +- [ ] A scheduled job deletes entries past the period; the deletion itself + is logged (count, cutoff) so the gap is explainable. +- [ ] Test: entries older than the cutoff are removed, newer ones stay. +- [ ] The read-access trail (#224) is explicitly **not** covered by this + job — it gets its own period. +- [ ] A logging section of `docs/architecture/security.md` and #229 + document the period. **Note:** the file has **no** logging section + today, although `apps/api/prisma/schema.prisma:69` already cites + "security.md §Logging" — creating it is part of this issue. + +## Out of scope + +Export of audit entries to a SIEM (#201) and the read-access trail (M6). + +--- + +#### #197 — [VS-NfD] Add security response headers and an explicitly restrictive CORS policy + +**Plan reference:** `20-massnahmenplan.md` → Phase 2 +**ADR:** n/a +**Effort:** S (1 AT) +**Depends on:** — +**Labels:** `vs-nfd` `effort:S` `area:ops` + +## Context + +Response headers are the cheapest verifiable hardening evidence there is, +and their absence is the first thing an automated assessment reports. CORS +must be a stated decision, not an implicit default. + +## Current state + +- `helmet` is not a dependency of `apps/api` and appears nowhere in + `apps/api/src`; no CORS configuration was found in the api bootstrap. +- The web tier already ships a strict CSP (`default-src 'self'`, + `script-src 'self'`) — the api's own responses are the gap. + +## Acceptance criteria + +- [ ] The api sends HSTS, `X-Content-Type-Options`, `Referrer-Policy`, + `X-Frame-Options`/frame-ancestors, and a `Permissions-Policy`, each + value chosen deliberately. +- [ ] CORS is configured explicitly and restrictively (`APP_BASE_URL` + origin only, credentials rules stated); a cross-origin request from + another origin is rejected by test. +- [ ] The plugin sandbox's framing requirements (ADR 0008) are verified not + to break — covered by an existing or new plugin e2e assertion. +- [ ] A test asserts the header set on a representative api response, so + regressions are caught. +- [ ] `docs/architecture/security.md` lists the headers and their reasons. + +## Out of scope + +Changing the web tier's CSP, and certificate/TLS termination (operator's +reverse proxy). + +--- + +#### #198 — [VS-NfD] Verify that no real `.env` is shipped and document the example as authoritative + +**Plan reference:** `20-massnahmenplan.md` → Phase 2 +**ADR:** n/a +**Effort:** S (0,5 AT) +**Depends on:** — +**Labels:** `vs-nfd` `effort:S` `area:supply-chain` + +## Context + +The plan suspected a real `.env` in the repository. Verification shows it +is **not** tracked — the remaining work is to make that verifiable and keep +it that way, which is what an assessor actually asks for. + +## Current state + +- `deploy/compose/.env` exists in the working tree but is **not** tracked: + `.gitignore:5–7` excludes `.env` and `.env.*` while allowing + `.env.example`; `git ls-files deploy/compose/` lists only + `.env.example`, `Caddyfile`, `compose.dev.yml`, `docker-compose.yml`. +- `deploy/compose/.env.example` is present and maintained. + +## Acceptance criteria + +- [ ] A CI check fails if any `.env` (other than `.env.example`) is ever + tracked, and if a tracked file matches obvious secret patterns. +- [ ] `.env.example` documents every variable the compose files reference, + including the ones added by #188, #190, #191 and #192. +- [ ] The git history is checked once for previously committed secrets, and + the result recorded in the residual-risk list (#231) — either "none + found" or the concrete finding. +- [ ] `docs/self-hosting/README.md` states that `.env.example` is the + reference and real values never enter the repository. + +## Out of scope + +Introducing a secret manager, and rotating existing secrets. + +--- + +### M2 — `VS-NfD: hardening & supply chain` + +#### #199 — [VS-NfD] Add SHA-256 integrity hashes for attachments + +**Plan reference:** `20-massnahmenplan.md` → Phase 2 (pulled from roadmap) +**ADR:** n/a +**Effort:** M (2–3 AT) +**Depends on:** — +**Labels:** `vs-nfd` `effort:M` `area:storage` + +## Context + +Integrity is the one security base function §52 VSA leaves to the +application in the sense of _detecting_ manipulation of its own payloads — +a checksum is not a protection mechanism the platform can supply, because +only the application knows what the file should be. Side benefits: +orphan sweep, dedup, backup verification. + +## Current state + +- The `Attachment` model (`apps/api/prisma/schema.prisma:530–559`) carries + no checksum column; the only hashes in the schema are credential/token + hashes (`token_hash`, session id, `credential`). +- Files are written by `apps/api/src/files/files.service.ts`. + +## Acceptance criteria + +- [ ] A `sha256` column is added by migration; the hash is computed during + upload (streaming, not by re-reading the file) and stored. +- [ ] Download verifies the hash and fails closed with a distinguishable + error when it does not match; the mismatch is audited. +- [ ] A backfill migration or job hashes existing attachments and reports + progress; unreadable files are reported, not silently skipped. +- [ ] Tests: upload stores the correct hash; tampering with the file on + disk makes download fail; backfill is idempotent. +- [ ] `docs/architecture/security.md` §"Content & upload security" and #229 + document the behaviour, including what an operator does when + verification fails. + +## Out of scope + +Signatures (needs a signing identity — see ADR 0025), content +encryption (ADR 0019), and dedup by hash. + +--- + +#### #200 — [VS-NfD] Add a hard `plugins.enabled = false` switch + +**Plan reference:** `20-massnahmenplan.md` → Phase 2 (pulled from roadmap) +**ADR:** ADR 0025 +**Effort:** M (2 AT) +**Depends on:** — +**Labels:** `vs-nfd` `effort:M` `area:supply-chain` + +## Context + +"Code execution inside the VS zone" is the question a plugin architecture +attracts. A single, verifiable off-switch answers it completely for the +offer stage — much cheaper than the trust machinery in #232, and it is what +the reference configuration will use. + +## Current state + +- `instance-settings.service.ts` provides master switches for the public + API (`api.enabled`, default false) and MCP (`mcp.enabled`, default + false) — the enforcement pattern to copy is + `apps/api/src/public-api/public-api.guard.ts:64` and + `apps/api/src/mcp/mcp.controller.ts:50,84` (404 while disabled). +- **No equivalent exists for plugins.** Plugin state is per pond plus the + installed set; there is no instance-level kill switch. + +## Acceptance criteria + +- [ ] `plugins.enabled` (default documented; **off** in the VS-NfD + reference config) makes every plugin surface answer 404: manifest and + asset routes, the frame route + (`/api/v1/plugins///frame`), install/uninstall, and the + pond-level toggles. +- [ ] With the switch off, existing plugin blocks in documents render their + declared `fallback` instead of an error, and the editor offers no + plugin blocks. +- [ ] Cache note respected: the settings cache is in-process, so the + documented procedure includes an api restart (or the setting is read + uncached) — verified by test or documented explicitly. +- [ ] Tests: every plugin route 404s while off; a page containing a plugin + block still renders; the switch is visible in the admin UI. +- [ ] Hardening guide (#227) lists the setting; `docs/architecture/plugin-architecture.md` + records the switch. + +## Out of scope + +Allowlisting or hash-pinning individual plugins (#232), and removing the +plugin architecture. + +--- + +#### #201 — [VS-NfD] Define a stable event catalogue for syslog/SIEM export + +**Plan reference:** `20-massnahmenplan.md` → Phase 2 (pulled from roadmap) +**ADR:** n/a +**Effort:** L (3–4 AT) +**Depends on:** — +**Labels:** `vs-nfd` `effort:L` `area:ops` + +## Context + +The code part is small — structured JSON on stdout already exists. The +value is a **stable catalogue**: fixed event ids with documented semantics +and fields, so the operator can write SIEM rules that survive our updates. +Without that contract, every release silently breaks their detection. + +## Current state + +- `apps/api/src/audit/audit.service.ts` defines + `AuditEvent.action: string` — the doc comment calls it a "stable + dot-namespaced id" but nothing enforces it; it is a free-form string. +- 34 distinct actions are in use today (`auth.login_failed`, + `grant.created`, `plugin.installed`, `settings.changed`, …) across 37 + call sites in `apps/api/src`. +- The service comment states the deliberate boundary: "Content activity + (pages, files, exports, labels) intentionally stays log-only — the trail + answers 'who changed access/configuration', not 'who edited what'." + +## Acceptance criteria + +- [ ] The action set becomes a typed union (or equivalent) so an unknown id + cannot be emitted; the existing 34 ids keep their names. +- [ ] A published catalogue documents per event: id, trigger, severity, + actor semantics, target semantics, and every field — versioned, with + a stated compatibility promise (ids are never repurposed). +- [ ] Log output is structured JSON with a stable field set suitable for + forwarding; the documented forwarding path (container stdout → + operator's collector) needs no application-side syslog client. +- [ ] A test fails when an event is emitted that the catalogue does not + describe — the fence that keeps documentation and code together. +- [ ] The catalogue lives in `docs/architecture/security.md` or a dedicated + file referenced from #228 and #230. + +## Out of scope + +An application-side syslog/TLS shipper, log signing, and read events (M6). + +--- + +#### #202 — [VS-NfD] Produce an SBOM and a license report in CI + +**Plan reference:** `20-massnahmenplan.md` → Phase 2 +**ADR:** n/a +**Effort:** M (1–2 AT) +**Depends on:** — +**Labels:** `vs-nfd` `effort:M` `area:supply-chain` + +## Context + +An SBOM is the artefact a supply-chain question is answered _with_ rather +than argued about, and the license report is needed for the procurement +side of the same conversation. + +## Current state + +- `.gitea/workflows/ci.yml` runs install, build, lint, typecheck, tests and + `i18n:check`; there is no SBOM step. No reference to `sbom`, `syft` or + `cyclonedx` exists anywhere in `.gitea/` or the root `package.json`. +- Deployment is by container image (`deploy/compose/docker-compose.yml`), so + the SBOM has to cover both the pnpm workspace and the images. + +## Acceptance criteria + +- [ ] CI produces a CycloneDX SBOM per released image plus one for the + workspace, and attaches them as build artefacts of the release + workflow. +- [ ] A license report lists every dependency with its license; the job + fails on a license outside a documented allowlist. +- [ ] Runner constraints respected: the CI runner image lacks python and + node tooling outside the workspace, and bind mounts talk to the host + daemon — the chosen tool works under those conditions (documented in + the PR). +- [ ] The SBOM's provenance is documented so an assessor can regenerate it. +- [ ] #228 references where SBOMs are published per release. + +## Out of scope + +Vulnerability scanning and its triage policy, and signing the SBOM. + +--- + +#### #203 — [VS-NfD] Pin all container images by digest + +**Plan reference:** `20-massnahmenplan.md` → P1-3 (moved here per the milestone plan) +**ADR:** ADR 0024 +**Effort:** S (1 AT) +**Depends on:** — +**Labels:** `vs-nfd` `effort:S` `area:supply-chain` + +## Context + +A floating tag means the deployed artefact is not the reviewed artefact. +Digest pinning is the precondition for both the registry mirror and the +offline update path (M5), which is why it lands here first. + +## Current state + +`deploy/compose/docker-compose.yml` pins tags, not digests: + +- `postgres:17.5-alpine` (`:186`) +- `pandoc/core:3.6` (`:206`) +- `gotenberg/gotenberg:8` (`:221`) — a floating **major** tag, the loosest + of the four +- `caddy:2.10-alpine` (`:237`) + Own images are `${IMAGE_PREFIX:-dorfteich}-{web,api,collab,backup}:${TAG:-latest}` + (`:20,36,105,140`), pinned per release by the deploy workflow. + +## Acceptance criteria + +- [ ] Every third-party image is referenced as `name:tag@sha256:…`; the + tag stays for readability, the digest decides. +- [ ] A documented, repeatable procedure updates digests (which command, + how the new digest is verified) and a CI check fails on any + third-party image reference without a digest. +- [ ] All stage composes (test/int/prod) are updated — note that CD does + **not** sync stage composes, so the rollout step is part of this + issue's definition of done. +- [ ] `deploy/stages.md` and #229 document the update procedure. + +## Out of scope + +Building our own base images, and mirroring them (#218). + +--- + +### M3 — `VS-NfD: classification metadata` + +#### #204 — [VS-NfD] Add `classification` as a page field with migration and instance default + +**Plan reference:** `20-massnahmenplan.md` → P1-2 +**ADR:** ADR 0022 +**Effort:** M (2 AT) +**Depends on:** — +**Labels:** `vs-nfd` `vs-nfd:blocker` `effort:M` `area:storage` + +## Context + +Marking classified content is the one VS-NfD requirement that genuinely +belongs _in_ the application: only it knows which page carries which +level. Separating the levels stays outside (one instance per level, +ADR 0022) — this field is the marking, not a protection mechanism. + +## Current state + +- `model Page` (`apps/api/prisma/schema.prisma:263–300`) has no + classification field. Metadata is `title`, `slug`, `sortKey`, `parentId`, + timestamps and relations. +- `Label` (`:423–437`) is pond-scoped (`pondId`), hierarchical and + user-editable — the reason ADR 0022 rejects labels as the carrier. +- `instance_settings` (`:17–23`) is the established place for an + instance-wide default (pattern: `upload.svgPolicy`, `api.enabled`). + +## Acceptance criteria + +- [ ] Enum field on `Page` with an explicit "unclassified" default; + migration backfills existing pages to it. +- [ ] Instance setting supplies the default for newly created pages; + documented and admin-visible. +- [ ] The value is part of the page API representation and of the page + metadata the frontend already loads (no extra request per page). +- [ ] Permission-relevant behaviour is unchanged by this issue — + asserted by a test, because ADR 0022 makes the ACL claim explicitly + _not_ a protection mechanism. +- [ ] `docs/architecture/data-model.md` documents the field; ADR 0022 is + referenced from it. + +## Out of scope + +Inheritance (#205), any output marking (#206–#212), and read auditing (M6). + +--- + +#### #205 — [VS-NfD] Inherit classification in the page tree; require a dedicated right to downgrade + +**Plan reference:** `20-massnahmenplan.md` → P1-2 +**ADR:** ADR 0022 +**Effort:** M (3 AT) +**Depends on:** #204 +**Labels:** `vs-nfd` `vs-nfd:blocker` `effort:M` `area:auth` + +## Context + +A subpage of a classified page must not silently be unclassified — that is +how classified content escapes marking in practice. Downgrading is the +sensitive direction and needs its own right plus an audit record. + +## Current state + +- Pages nest via `parentId` with a max depth of 6 enforced in the service, + cycles rejected at write time (`apps/api/prisma/schema.prisma:255–266`); + trashed pages keep `parentId` and purge promotes children explicitly. +- Permission decisions run centrally through `apps/api/src/permissions/` + (deny-wins, default-closed) — the place to add a capability rather than + an ad-hoc check. + +## Acceptance criteria + +- [ ] A new page inherits the effective classification of its parent; a + page moved under a higher-classified parent is raised. +- [ ] Raising is allowed to any writer; **lowering** requires a dedicated + capability expressed in the central permission model, never an ad-hoc + check. +- [ ] Every raise and lower is audited with old value, new value, actor and + page. +- [ ] Move operations cannot lower a page's classification as a side effect + (proven by test), including the purge-promotes-children path. +- [ ] Tests cover: inherit on create, raise on move, lower denied without + the capability, lower audited with the capability. +- [ ] `docs/architecture/permissions.md` documents the capability. + +## Out of scope + +Output marking, and a UI for bulk re-classification. + +--- + +#### #206 — [VS-NfD] Show the classification in the web view header and footer + +**Plan reference:** `20-massnahmenplan.md` → P1-2 (output channels) +**ADR:** ADR 0022 +**Effort:** S (1 AT) +**Depends on:** #204 +**Labels:** `vs-nfd` `vs-nfd:blocker` `effort:S` `area:export` + +## Context + +The screen is the most-used output channel and sets the visual convention +the other channels copy. + +## Current state + +Reading and editing views render page metadata (title, pond) without any +classification element; no marking component exists. + +## Acceptance criteria + +- [ ] Reading view, editor and the public page view show the marking at + the top and bottom of the page content, using the wording fixed in + ADR 0022. +- [ ] Marking is present in de and en (`pnpm i18n:check` clean) and is + **not** a decorative element only: it is announced to assistive + technology, and it survives the theming cascade in light and dark + mode with contrast per ADR 0017. +- [ ] Unclassified pages show no marking (no visual noise) — a deliberate, + documented decision. +- [ ] e2e coverage in the a11y pack for a classified page in both themes. +- [ ] Screenshot in #228 as evidence of the convention. + +## Out of scope + +Print (#207), PDF (#208) and every other channel. + +--- + +#### #207 — [VS-NfD] Add print CSS with a per-page classification header and footer + +**Plan reference:** `20-massnahmenplan.md` → P1-2 (output channels) +**ADR:** ADR 0022 +**Effort:** S (1 AT) +**Depends on:** #206 +**Labels:** `vs-nfd` `vs-nfd:blocker` `effort:S` `area:export` + +## Context + +Printing from the browser is the output channel most likely to produce +paper that has to carry a marking on **every sheet** — and it is currently +unstyled entirely. + +## Current state + +- No `@media print` block exists anywhere in `apps/web/src` — verified by + search. Browser printing therefore reproduces the screen layout including + navigation chrome. + +## Acceptance criteria + +- [ ] A print stylesheet sets page size and margins, suppresses navigation + and interactive chrome, and handles break behaviour for headings, + tables, code blocks and plugin blocks. +- [ ] The classification appears in a running header **and** footer on + **every** printed page (`@page` margin boxes or an equivalent), not + once at the top. +- [ ] Verified on a multi-page document as PDF-from-browser in Chromium and + one Gecko-based browser; the check is written down so it can be + repeated. +- [ ] Unclassified pages print without a marking. +- [ ] Documented in #228 alongside the other channels. + +## Out of scope + +Server-side PDF (#208) and print styles as a general design feature beyond +what the marking needs. + +--- + +#### #208 — [VS-NfD] Put the classification into the Gotenberg PDF header/footer template + +**Plan reference:** `20-massnahmenplan.md` → P1-2 (output channels) +**ADR:** ADR 0022 +**Effort:** S (1 AT) +**Depends on:** #204 +**Labels:** `vs-nfd` `vs-nfd:blocker` `effort:S` `area:export` + +## Context + +Server-side PDF is the export an authority is most likely to file or +forward, so it must be marked per page rather than once at the top. + +## Current state + +- `apps/api/src/import-export/pdf-html.ts` builds the export HTML: a + document-level `
` with pond name and title + (`:76–79`) plus print CSS for page size and breaks (`:30–31`, `:69–71`). + This header appears **once**, not per page. +- Page numbers already come from Gotenberg's footer (`:31`), so the + per-page mechanism exists and is the place to extend. +- Renderer: `apps/api/src/import-export/gotenberg.renderer.ts`. + +## Acceptance criteria + +- [ ] Classification is rendered in Gotenberg's header and footer template + so it appears on every page of the PDF, next to the existing page + numbers. +- [ ] The document-level header keeps working; unclassified pages produce + an unchanged PDF (asserted against the existing PDF fidelity + snapshots). +- [ ] Fidelity test extended per the repo's fixture-first process + (`fixtures/README.md`): fixture first, snapshot regenerated only with + the pinned tool version, committed together. +- [ ] Documented in #228. + +## Out of scope + +DOCX/ODT (#209) and browser print (#207). + +--- + +#### #209 — [VS-NfD] Give pandoc a reference document with classification header and footer + +**Plan reference:** `20-massnahmenplan.md` → P1-2 (output channels) +**ADR:** ADR 0022 +**Effort:** M (2–3 AT) +**Depends on:** #204 +**Labels:** `vs-nfd` `vs-nfd:blocker` `effort:M` `area:export` + +## Context + +DOCX and ODT are editable formats an authority will circulate; the marking +has to be part of the document's own header/footer definition, not text in +the body that a user can delete without noticing. + +## Current state + +- `apps/api/src/import-export/pandoc.converter.ts` drives pandoc; there is + **no** `--reference-doc` / `referenceDoc` usage — verified by search, so + the output uses pandoc's defaults with no header or footer. +- The container is pinned to `pandoc/core:3.6` + (`deploy/compose/docker-compose.yml:206`), which is also the version the + fidelity snapshots are generated with. + +## Acceptance criteria + +- [ ] A reference DOCX and a reference ODT ship in the repository with + header/footer fields carrying the classification; the converter + passes them for the respective target format. +- [ ] The marking is placed in the document's header/footer definition so + it repeats on every page in Word and LibreOffice — verified by + opening the output in both. +- [ ] Unclassified pages produce output without a marking. +- [ ] Fixture-first fidelity coverage per `fixtures/README.md`, snapshots + regenerated only with `pandoc/core:3.6`. +- [ ] How the reference documents are maintained (they are binary) is + documented next to them. + +## Out of scope + +Styling the exports beyond what the marking needs, and other formats. + +--- + +#### #210 — [VS-NfD] Mark the Markdown ZIP export with frontmatter and a visible imprint + +**Plan reference:** `20-massnahmenplan.md` → P1-2 (output channels) +**ADR:** ADR 0022 +**Effort:** S (1 AT) +**Depends on:** #204 +**Labels:** `vs-nfd` `vs-nfd:blocker` `effort:S` `area:export` + +## Context + +The ZIP export is the bulk egress path: many pages at once, as plain files +that get copied onward. It needs both a machine-readable marker and a +human-visible one. + +## Current state + +- `apps/api/src/import-export/export.service.ts` builds the pond ZIP + (`Content-Disposition` at `:68`); page selection at `:95–108` filters by + read permission. +- `apps/api/src/import-export/export-markdown.ts` emits the Markdown; it + writes **no** YAML frontmatter — frontmatter handling exists only on the + Obsidian _import_ side (`obsidian-vault.ts:203,528`). +- Verified: labels do not appear in any export (see §0.2), so no existing + metadata block can carry the marking. + +## Acceptance criteria + +- [ ] Each exported Markdown file carries the classification in YAML + frontmatter **and** as a visible line at the top and bottom of the + file. +- [ ] The ZIP contains a manifest listing every file with its + classification, and the highest classification contained is stated + once at archive level. +- [ ] Unclassified pages get no marking; existing round-trip and fidelity + tests still pass (frontmatter must not confuse our own importer — + asserted by a round-trip test). +- [ ] Documented in #228. + +## Out of scope + +The Obsidian vault export variant's own frontmatter modes, beyond keeping +them working. + +--- + +#### #211 — [VS-NfD] Pass the classification through feeds, public API, search results and the no-JS shell + +**Plan reference:** `20-massnahmenplan.md` → P1-2 (output channels) +**ADR:** ADR 0022 +**Effort:** M (2–3 AT) +**Depends on:** #204 +**Labels:** `vs-nfd` `vs-nfd:blocker` `effort:M` `area:export` + +## Context + +These four channels emit content without going through the SPA, so each can +leak unmarked classified content. The plan groups them under one effort +figure, so they stay one issue. + +_Kept as one issue because the plan gives the four channels a single effort +figure (2–3 AT) and they share one mechanism: the serializer that renders a +page representation outside the SPA._ + +## Current state + +- Atom feeds: `apps/api/src/public/feed.service.ts`, routes in + `apps/api/src/public/public.controller.ts:28,43`. +- No-JS shell: `apps/api/src/public/html-shell.ts` (server-rendered HTML + for crawlers and no-script clients). +- Public REST API: `apps/api/src/public-api/`, gated by `api.enabled` plus + pond `apiEnabled`. +- Search results: `apps/api/src/search/postgres-search.provider.ts` + (snippets contain page text). + +## Acceptance criteria + +- [ ] Feed entries carry the classification in a documented element, and + the feed document states the highest classification it contains. +- [ ] Public API page representations include the classification field; + the API documentation (`docs/self-hosting/public-api.md`) is updated. +- [ ] The no-JS shell renders the marking in the same places as the SPA + (top and bottom) — note the shell is a separate render path from the + TipTap view, so it needs its own assertion. +- [ ] Search results show the classification per hit, and a snippet of a + classified page is never shown unmarked. +- [ ] One test per channel; the public API test runs with the instance + switch on. +- [ ] Documented in #228. + +## Out of scope + +Whether these channels should be available at all in the reference config +(#227 turns them off), and MCP (no content egress beyond the public API's +model). + +--- + +#### #212 — [VS-NfD] Mark attachment downloads by filename prefix and companion file + +**Plan reference:** `20-massnahmenplan.md` → P1-2 (output channels) +**ADR:** ADR 0022 +**Effort:** M (1–2 AT) +**Depends on:** #204 +**Labels:** `vs-nfd` `vs-nfd:blocker` `effort:M` `area:export` + +## Context + +An attachment leaves the application as an opaque binary — we cannot write +into arbitrary file formats, so the marking has to live in the name and in +an accompanying file. This is the channel where an honest limitation must +be documented rather than papered over. + +## Current state + +- Download routes: `apps/api/src/files/files.controller.ts:51,71`; + `Content-Disposition` is set for the pond ZIP + (`import-export/export.service.ts:68`) and the single-page Markdown + download (`apps/api/src/pages/pages.controller.ts:122`). +- Attachments inherit no classification today (the field arrives with + #204 on `Page`, and an attachment links to a page). + +## Acceptance criteria + +- [ ] A download of an attachment belonging to a classified page carries a + documented filename prefix, and a companion text file (or the + containing archive) states the classification. +- [ ] The classification an attachment inherits is defined unambiguously + for the case where its `pageId` link is unset (paste-then-insert) — + fail closed, and documented. +- [ ] Tests: prefixed filename for a classified page's attachment; + unchanged filename for an unclassified one; unset `pageId` behaves as + documented. +- [ ] The residual risk "the file's own content carries no marking" is + recorded in #231, not hidden. + +## Out of scope + +Writing markings into file formats (PDF/Office attachments), and blocking +downloads (#213 covers the upload side). + +--- + +#### #213 — [VS-NfD] Warn or block when attaching files to classified pages + +**Plan reference:** `20-massnahmenplan.md` → P1-2 +**ADR:** ADR 0022 +**Effort:** S (1 AT) +**Depends on:** #204 +**Labels:** `vs-nfd` `effort:S` `area:storage` + +## Context + +Uploading to a classified page is the moment a user needs to be told what +they are doing — the file inherits a classification it cannot itself +carry (#212). + +## Current state + +- Upload path `apps/api/src/files/files.service.ts` with pond and page + scoping; no classification-aware behaviour exists (the field arrives with + #204). + +## Acceptance criteria + +- [ ] Uploading to a classified page shows a clear warning naming the + consequence, in de and en. +- [ ] An instance setting can turn the warning into a hard block, with a + documented default. +- [ ] Tests: warning shown for a classified page; block enforced + server-side (not only in the UI) when enabled. +- [ ] Hardening guide (#227) lists the setting. + +## Out of scope + +Scanning file contents, and MIME/extension policy (already +`upload.allowedExtensions` / `upload.svgPolicy`). + +--- + +### M4 — `VS-NfD: external authentication` + +#### #214 — [VS-NfD] Implement OIDC Authorization Code with PKCE, Keycloak as reference IdP + +**Plan reference:** `20-massnahmenplan.md` → P1-1 +**ADR:** ADR 0021 +**Effort:** L (5–6 AT) +**Depends on:** #188 +**Labels:** `vs-nfd` `vs-nfd:blocker` `effort:L` `area:auth` + +## Context + +Authentication is a security base function §52 VSA assigns to the +platform. Delegating it to the operator's IdP is the single most important +step in keeping Dorfteich out of the certification obligation under §51 +VSA. + +## Current state + +- The data model has the slot: `model UserIdentity` + (`apps/api/prisma/schema.prisma:562–577`) — "`provider` is 'password' + today and 'oidc:' later", `@@unique([provider, subject])`, + `credential` holds the Argon2id hash for password identities. +- **No OIDC implementation exists**: a search for `oidc` across + `apps/api/src` and `packages/shared/src` returns nothing outside that + comment. ADR 0007 declares the readiness, not the feature. + +## Acceptance criteria + +- [ ] Authorization Code flow with PKCE, state and nonce validation, + discovery-based configuration, and JWKS-based token validation using + the vetted library from #188 — no hand-rolled JWT verification. +- [ ] Identity linking follows the existing model: + `provider = "oidc:"`, `subject` from the token; an existing + local user is linked by a documented, deliberate rule (not silently + by e-mail). +- [ ] Login, logout (including IdP-initiated single logout or a documented + decision against it) and session creation reuse the existing session + service — no parallel session mechanism. +- [ ] Verified against a Keycloak instance; the setup used is documented so + the test is repeatable. +- [ ] Tests: successful login creates/links the identity; invalid state, + nonce, signature, issuer and audience each rejected; expired token + rejected. +- [ ] `docs/architecture/security.md` and #227 document configuration; + ADR 0021 records the decisions. + +## Out of scope + +SAML and LDAP, claim-to-role mapping (#217), and disabling local +authentication (#216). + +--- + +#### #215 — [VS-NfD] Support a trusted reverse-proxy header or mTLS client certificate as an alternative path + +**Plan reference:** `20-massnahmenplan.md` → P1-1 +**ADR:** ADR 0021 +**Effort:** M (2 AT) +**Depends on:** #214 +**Labels:** `vs-nfd` `effort:M` `area:auth` + +## Context + +Some authority environments terminate authentication at the perimeter and +expect the application to trust it. Supporting that avoids forcing an IdP +into an architecture that already solved authentication — but a trusted +header is a loaded gun if it is trusted unconditionally. + +## Current state + +- Authentication is cookie/session based + (`apps/api/src/auth/auth.guard.ts`, `sessions.service.ts`) plus PATs; no + header- or certificate-based identity path exists. + +## Acceptance criteria + +- [ ] Header-based identity is **off by default** and requires both an + explicit switch and an allowlist of trusted peer addresses; a request + arriving from an untrusted peer with the header is rejected and + audited. +- [ ] The configured header name and the identity mapping are explicit + configuration, never guessed. +- [ ] mTLS variant: the certificate subject/attribute used as identity is + configurable, with the same trust-boundary rules. +- [ ] Tests: header from untrusted peer ignored and audited; header from + trusted peer authenticates; header ignored entirely while the switch + is off; spoofed header alongside a session cookie does not escalate. +- [ ] `docs/architecture/security.md` documents the trust boundary + unambiguously — this is the section an assessor will read closest. + +## Out of scope + +Terminating TLS in the application, and certificate lifecycle management. + +--- + +#### #216 — [VS-NfD] Add a hard `auth.local.enabled = false` switch covering every local credential flow + +**Plan reference:** `20-massnahmenplan.md` → P1-1 +**ADR:** ADR 0021 +**Effort:** M (2 AT) +**Depends on:** #214 +**Labels:** `vs-nfd` `vs-nfd:blocker` `effort:M` `area:auth` + +## Context + +Delegating authentication only counts if the local path is actually closed +— including the flows people forget: password reset, self-service signup, +personal access tokens and feed tokens. A half-closed local path is worse +than none, because the operating concept then describes something untrue. + +## Current state + +- Local passwords: `UserIdentity.credential` (Argon2id), + `apps/api/src/auth/`; token flows in + `apps/api/src/auth/auth-tokens.service.ts`, + `apps/api/src/public-api/api-tokens.service.ts` (PATs) and + `apps/api/src/public/feed-tokens.service.ts`. +- Switch precedent to follow: `api.enabled` / `mcp.enabled` in + `apps/api/src/settings/instance-settings.service.ts`, enforced as 404. +- No switch for local authentication exists. + +## Acceptance criteria + +- [ ] With the switch off: password login, signup, password reset, + e-mail-verification-as-login and any other credential-issuing flow + are unreachable (404/403 consistently with the project's + 404/403 policy) — enumerated in the test, not assumed. +- [ ] PAT and feed-token issuance behaviour with the switch off is a + **stated decision** (blocked, or allowed only for IdP-authenticated + users), tested either way. +- [ ] The first-run setup wizard's local admin creation is addressed + explicitly — bootstrapping must remain possible without reopening + the local path in normal operation. +- [ ] A route-enumeration test proves no authentication route is + accidentally left open (extend the existing enumeration fence). +- [ ] The switch is deploy-level, not merely a runtime setting a + compromised Site-Admin could flip back — or, if runtime, that + residual risk is documented in #231. +- [ ] #227 makes "local auth off" part of the reference configuration. + +## Out of scope + +Migrating existing users to the IdP, and deleting stored password hashes. + +--- + +#### #217 — [VS-NfD] Map IdP groups and roles onto the permission model + +**Plan reference:** `20-massnahmenplan.md` → P1-1 +**ADR:** ADR 0021 +**Effort:** M (2–3 AT) +**Depends on:** #214 +**Labels:** `vs-nfd` `effort:M` `area:auth` + +## Context + +Without claim mapping, every authority deployment administers permissions +twice — and the second copy drifts. Drifted permissions on classified +content is exactly the finding to avoid. + +## Current state + +- Permissions are grants evaluated centrally + (`apps/api/src/permissions/`, deny-wins, default-closed, ADR-documented + route-enumeration test). Grants are created through the API — the project + rule is that raw grant rows bypass the `PondPermissionCache`. +- Nothing consumes IdP claims (no OIDC exists yet, #214). + +## Acceptance criteria + +- [ ] A declarative, admin-visible mapping turns claims into pond roles + and the site-admin flag; the mapping is instance configuration, not + code. +- [ ] Mapped grants are applied through the same service path as manual + grants, so the permission cache stays correct (no raw row writes). +- [ ] Removal of a claim revokes the corresponding grant on next login, and + live collab sessions are terminated by the existing revocation path + (`pg_notify` access listener) — asserted by test. +- [ ] Manually created grants are distinguishable from mapped ones, and a + documented rule says which wins. +- [ ] Every mapping-driven change is audited. +- [ ] `docs/architecture/permissions.md` documents the mapping. + +## Out of scope + +SCIM provisioning, and just-in-time user creation policy beyond what #214 +defines. + +--- + +### M5 — `VS-NfD: offline/airgap deployment` + +#### #218 — [VS-NfD] Document the mirror procedure into an internal registry + +**Plan reference:** `20-massnahmenplan.md` → P1-3 +**ADR:** ADR 0024 +**Effort:** S (1 AT) +**Depends on:** #203 +**Labels:** `vs-nfd` `effort:S` `area:supply-chain` + +## Context + +An authority pulls images from its own registry, not from Docker Hub. The +procedure must be written so their operations team can execute it without +us. + +## Current state + +- Third-party images come from public registries by tag + (`deploy/compose/docker-compose.yml:186,206,221,237`); own images from + the project registry via `IMAGE_PREFIX`/`TAG`. +- `deploy/stages.md` documents stage deployment, not mirroring. + +## Acceptance criteria + +- [ ] A step-by-step procedure mirrors every required image (ours and + third-party) into an internal registry, by digest, including how the + digest is verified after the copy. +- [ ] Compose files take the registry prefix from configuration so no image + reference needs editing per site. +- [ ] The complete image list is generated, not hand-maintained, so it + cannot drift. +- [ ] Executed once end-to-end and the run recorded as evidence. +- [ ] Documented in `deploy/stages.md` and the operations manual (#229). + +## Out of scope + +Operating a registry for the customer, and the isolated test run (#220). + +--- + +#### #219 — [VS-NfD] Make the build reproducible without network access + +**Plan reference:** `20-massnahmenplan.md` → P1-3 +**ADR:** ADR 0024 +**Effort:** M (2–3 AT) +**Depends on:** #203 +**Labels:** `vs-nfd` `effort:M` `area:supply-chain` + +## Context + +"Builds fine offline" is a claim; a documented offline build is evidence. +The plan explicitly allows the cheaper answer — prebuilt images only — as +long as it is a stated decision. + +## Current state + +- pnpm workspace with a committed lockfile; CI installs with + `pnpm install --frozen-lockfile` (`.gitea/workflows/ci.yml`). +- Images are built in CI with network access; there is no offline store or + vendored dependency set. +- Note for whoever implements: a new workspace dependency also requires + touching the api Dockerfile (`COPY packages/` + build). + +## Acceptance criteria + +- [ ] Either a pnpm offline store / vendored dependency set makes + `pnpm install` and `pnpm build` succeed with networking disabled, + **or** the decision "prebuilt images only, no customer-side build" is + documented with its consequences (no local patching). +- [ ] Whichever path: reproduced twice from a clean checkout with identical + results, and the procedure written down. +- [ ] Toolchain versions (node, pnpm, base images) are pinned and stated. +- [ ] Documented in #229; the decision recorded in ADR 0024. + +## Out of scope + +Bit-for-bit reproducible builds as a formal property, and mirroring +(#218). + +--- + +#### #220 — [VS-NfD] Run and document a deployment in a network-isolated environment + +**Plan reference:** `20-massnahmenplan.md` → P1-3 +**ADR:** ADR 0024 +**Effort:** M (2 AT) +**Depends on:** #218, #219 +**Labels:** `vs-nfd` `effort:M` `area:supply-chain` + +## Context + +This is the issue that turns the airgap story from plausible into +verified — the plan's reason for pulling P1-3 forward. It also answers the +plan's open question "what breaks without internet access", which is not +answerable by reading code. + +## Current state + +- Existing strengths to confirm rather than build: no telemetry, no update + checks, no CDNs, self-hosted fonts (ADR 0016), CSP `default-src 'self'`, + Postgres full-text search instead of an external engine, drawio vendored + (§0.2). +- Whether an egress-blocked deployment is fully functional has never been + tested — the open question in `20-massnahmenplan.md`. + +## Acceptance criteria + +- [ ] A full deployment runs with egress blocked: install, first-run setup, + login, editing with live collaboration, search, upload, all export + formats (PDF via Gotenberg, DOCX/ODT via pandoc), backup and restore. +- [ ] Every outbound connection attempt is captured and listed; each is + either eliminated or documented as required with its purpose. +- [ ] Behaviour of outbound e-mail (SMTP) without egress is stated + explicitly — it is the one connection an authority may or may not + permit. +- [ ] A written protocol (date, environment, versions by digest, results, + deviations) exists as assessor-facing evidence. +- [ ] Findings feed back into #221 and #229. + +## Out of scope + +Fixing whatever the run uncovers — that becomes its own issue, referenced +from here. + +--- + +#### #221 — [VS-NfD] Define the offline update path including migrations + +**Plan reference:** `20-massnahmenplan.md` → P1-3 +**ADR:** ADR 0024 +**Effort:** M (2–3 AT) +**Depends on:** #218, #220 +**Labels:** `vs-nfd` `effort:M` `area:supply-chain` + +## Context + +An installation that cannot be updated safely will not be updated, and an +unpatched instance in a VS zone is the outcome nobody wants. Migrations are +the risky part. + +## Current state + +- Stages apply migrations on api start (`MIGRATE_ON_START`); there is no + separate migration step and no documented rollback for a failed + migration. +- Production deployment pins `TAG` in the stage `.env` and pulls + (`deploy/stages.md`); rollback today means deploying the previous tag. +- Constraint to respect: `prisma migrate reset` is not an available tool + here — `migrate deploy` is the path. + +## Acceptance criteria + +- [ ] A documented procedure covers: obtain the update bundle, verify it + (digests), back up, apply, verify health, and roll back. +- [ ] Migration behaviour is explicit: which migrations are irreversible, + what a rollback means for the database, and when a restore is the + only way back. +- [ ] Rehearsed once in the isolated environment from #220, including one + deliberate failed-update rollback. +- [ ] Version skew during the update (api/collab/web) is described: + whether a rolling update is supported or downtime is required. +- [ ] Documented in the operations manual (#229) and + `docs/operations/restore-runbook.md`. + +## Out of scope + +Automating updates, and long-term support/backport policy. + +--- + +### M6 — `VS-NfD: read-access audit trail` + +#### #222 — [VS-NfD] Instrument read paths for classified content + +**Plan reference:** `20-massnahmenplan.md` → Phase 3, Variante A +**ADR:** ADR 0023 +**Effort:** L (4 AT) +**Depends on:** #204, #205 +**Labels:** `vs-nfd` `effort:L` `area:storage` + +## Context + +Platform logging cannot answer _which classified page_ was read — proxy +logs know URLs, not classifications. That is the gap this milestone closes, +and only for classified content, which is what keeps the purpose limitation +defensible. + +## Current state + +- The audit trail is deliberately write-only in scope: + `apps/api/src/audit/audit.service.ts` — "Content activity (pages, files, + exports, labels) intentionally stays log-only — the trail answers 'who + changed access/configuration', not 'who edited what'." No read events + exist (the single `action: 'read'` occurrence, + `apps/api/src/mcp/mcp.service.ts:243`, is a permission-check parameter, + not an audit action). +- Read paths to instrument: SPA page fetch, public API GET + (`apps/api/src/public-api/`), attachment download + (`apps/api/src/files/files.controller.ts:51,71`), exports + (`apps/api/src/import-export/`), no-JS shell + (`apps/api/src/public/html-shell.ts`), and the collab WS join. +- **Constraint for the WS join:** authorization there is token-only + (`apps/collab/src/server.ts:108–125` — signature plus + `claims.pageId === documentName`); the collab server has no permission + context and therefore cannot know a page's classification. The api is the + natural emission point, and it is a good one: collab tokens live **60 + seconds** (`apps/api/src/pages/pages.service.ts:57,384`), so a live + session re-requests one every minute — that gives per-minute granularity + for free, which the dedup window in #223 then collapses. Decide and + document. + +## Acceptance criteria + +- [ ] Every listed read path emits an event for pages with + `classification = VS_NFD`, and none for unclassified pages. +- [ ] Each event carries: timestamp, actor (or documented anonymous + marker), page, channel, and the classification at read time. +- [ ] The collab WS join is covered by the decided mechanism, with the + reasoning recorded in ADR 0023. +- [ ] Events survive an in-request failure of the trail without breaking + the read? — **no**: for classified content, a failed write must be a + hard failure or an explicit, documented degradation. State which, and + test it. (This is the deliberate difference from `AuditService`, + which swallows failures.) +- [ ] One test per channel proving both the event and its absence for + unclassified pages. +- [ ] The logging section of `docs/architecture/security.md` created by + #196 documents the channel list. + +## Out of scope + +Auditing reads of unclassified content (Variant B, rejected in +ADR 0023), and the dedup window (#223). + +--- + +#### #223 — [VS-NfD] Add a dedup window so Yjs sync does not flood the trail + +**Plan reference:** `20-massnahmenplan.md` → Phase 3, Variante A +**ADR:** ADR 0023 +**Effort:** M (2 AT) +**Depends on:** #222 +**Labels:** `vs-nfd` `effort:M` `area:storage` + +## Context + +A live editing session produces continuous traffic; one event per message +is both useless as evidence and a performance problem. One session plus one +page within N minutes is one read. + +## Current state + +- The live document is `pages.ydoc_state` plus a `page_updates` log; collab + persistence is debounced (~2 s) and sessions are long-lived + (`apps/collab/src/persistence.ts`, session registry in + `apps/collab/src/session-registry.ts`). + +## Acceptance criteria + +- [ ] Deduplication key (session + page + channel) and window length are + configurable, with a documented default. +- [ ] The **first** access in a window is always recorded, and the record + states that it represents a window, not a single request — so the + evidence is not misread. +- [ ] Reconnects within a window do not create a second event; a new + session does, even for the same user. +- [ ] Load evidence: a realistic editing session produces a bounded number + of events (measured figure recorded in the PR). +- [ ] The window is documented in ADR 0023 and #228, because it defines + what the trail can and cannot prove. + +## Out of scope + +Buffered writing for a high-volume all-reads variant (Variant B). + +--- + +#### #224 — [VS-NfD] Store read events in their own table with retention and partitioning + +**Plan reference:** `20-massnahmenplan.md` → Phase 3, Variante A +**ADR:** ADR 0023 +**Effort:** M (2 AT) +**Depends on:** #222 +**Labels:** `vs-nfd` `effort:M` `area:storage` + +## Context + +Read events have a different volume profile, a different retention period +and a different legal basis than `audit_log`. Mixing them would force one +policy onto both. + +## Current state + +- `audit_log` (`apps/api/prisma/schema.prisma:74–91`) holds auth and admin + events and has no retention job yet (#196) and no partitioning. + +## Acceptance criteria + +- [ ] Separate table with an index set matched to the expected queries + ("who read page X", "what did user Y read", both within a period). +- [ ] Time-based partitioning, with the partition-maintenance job included + and tested — not left as an operational chore. +- [ ] Own configurable retention period, independent of #196, with a + documented default and the deletion itself logged. +- [ ] A Site-Admin query path exists (or its deliberate absence is + documented) — evidence nobody can read is not evidence. +- [ ] Growth measured and stated (rows and bytes per 1000 reads) so an + operator can size storage. +- [ ] `docs/architecture/data-model.md` documents the table. + +## Out of scope + +SIEM forwarding of read events beyond the catalogue from #201, and +tamper-proofing. + +--- + +#### #225 — [VS-NfD] Make the read trail switchable and document its purpose limitation + +**Plan reference:** `20-massnahmenplan.md` → Phase 3, Variante A +**ADR:** ADR 0023 +**Effort:** M (1–2 AT) +**Depends on:** #222, #224 +**Labels:** `vs-nfd` `effort:M` `area:docs` + +## Context + +Read logging is employee monitoring in the eyes of a works council. A hard +switch plus a written purpose limitation is what makes it adoptable — the +plan names this as an explicit benefit of Variant A. + +## Current state + +Not applicable — the feature arrives with #222. + +## Acceptance criteria + +- [ ] An instance switch enables/disables the trail; off means no event is + written anywhere (including stdout), verified by test. +- [ ] Default is documented and deliberate. +- [ ] A written purpose limitation states: what is recorded, why, who may + read it, for how long, and what it may **not** be used for. +- [ ] The absence of events while switched off is itself explainable (a + startup log line stating the trail is off), so a gap is never + ambiguous. +- [ ] Text ships as part of the security documentation (#228) and is + referenced from the hardening guide (#227). + +## Out of scope + +Four-eyes access control on the trail, and works-council templates. + +--- + +### M7 — `VS-NfD: compliance documentation` + +#### #226 — [VS-NfD] Write the §52 VSA delimitation statement + +**Plan reference:** `20-massnahmenplan.md` → Phase 5 +**ADR:** ADR 0019 +**Effort:** L (3 AT) +**Depends on:** — +**Labels:** `vs-nfd` `effort:L` `area:docs` + +## Context + +The most important single document of the whole undertaking: it states +which security base functions Dorfteich does **not** provide and to whom +they fall. It is what keeps the application outside the certification +obligation under §51 VSA, and it does not depend on any implementation — +so it starts first. + +## Current state + +- ADR 0019 (this entwurf) is its draft; existing material to draw on: + `docs/architecture/security.md`, `permissions.md`, ADR 0007 + (auth/sessions), ADR 0015 (backup), ADR 0008 (plugin sandbox). + +## Acceptance criteria + +- [ ] Each base function (encryption, media protection, network + termination, authentication, and integrity as far as it applies) is + listed with: what the application does, what it deliberately does + not, and which party provides it. +- [ ] Every claim is traceable to code or configuration — no aspirational + statements. +- [ ] The deliberate non-features are argued as _architecture_, not + omission (the plan's explicit instruction: "no encryption in the code + is correct architecture, not a missing feature"). +- [ ] Delta list: where the current state does not yet match the statement, + it points at the issue that closes the gap. +- [ ] **Endpoint-side content copies are named as an operator duty**: every + opened page is mirrored into the browser's IndexedDB + (`apps/web/src/editor/use-collab-provider.ts:76`, database + `dorfteich-page-`), cleared on leave once synced but + deliberately kept for unsynced offline edits. + Confidentiality of that copy is endpoint media protection, i.e. the + platform's function — but it must be stated, not omitted + (`10-ist-aufnahme.md` → I-25). +- [ ] Reviewed against ADR 0019 for consistency; divergences resolve in + the ADR, not in the statement. + +## Out of scope + +Legal review by counsel, and the IT-Grundschutz mapping (#230). + +--- + +#### #227 — [VS-NfD] Write the hardening guide with a "VS-NfD operation" reference configuration + +**Plan reference:** `20-massnahmenplan.md` → Phase 5 +**ADR:** ADR 0019 +**Effort:** L (3 AT) +**Depends on:** #191, #192, #200, #216 +**Labels:** `vs-nfd` `effort:L` `area:docs` + +## Context + +One named configuration an operator can adopt wholesale is worth more than +a list of options. It is also the artefact that makes the switches built in +M1/M2/M4 auditable. + +## Current state + +- `docs/self-hosting/README.md` covers ordinary self-hosting; no hardened + profile exists. +- Existing switches that belong in the profile: `api.enabled` (default + off), `mcp.enabled` (default off), `upload.svgPolicy`, + `upload.allowedExtensions`, backup settings — all in + `apps/api/src/settings/instance-settings.service.ts`. + +## Acceptance criteria + +- [ ] A complete reference configuration for VS-NfD operation: local auth + off, public API off, MCP off, feeds off, plugins off, backup local + only — each with the exact setting name and value. +- [ ] Every entry states **why**, so an operator can deviate knowingly. +- [ ] Settings added by M1/M2/M4 are included; the guide is updated in the + same PR as each new switch (stated as a rule, not a hope). +- [ ] A verification script or checklist lets an operator confirm the + profile is active on a running instance. +- [ ] Cross-referenced from #226 and #230. + +## Out of scope + +Hardening the operator's platform (OS, network, reverse proxy). + +--- + +#### #228 — [VS-NfD] Write the security documentation (architecture, data flows, network plan, ports, trust boundaries) + +**Plan reference:** `20-massnahmenplan.md` → Phase 5 +**ADR:** ADR 0019 +**Effort:** L (4 AT) +**Depends on:** — +**Labels:** `vs-nfd` `effort:L` `area:docs` + +## Context + +This is the document an assessor reads first and returns to. It has to be +accurate to the digit on ports, services and trust boundaries. + +## Current state + +- Substantial material exists and needs consolidating rather than + inventing: `docs/architecture/security.md`, `deployment.md`, + `data-model.md`, `permissions.md`, `realtime-collaboration.md`, + `plugin-architecture.md`, plus `deploy/compose/docker-compose.yml` as the + authoritative service and port list (web, api, collab, backup, db, + gotenberg, pandoc, caddy). + +## Acceptance criteria + +- [ ] Component diagram with every service, its purpose and its + privileges. +- [ ] Data-flow diagrams for: authentication, editing (api ↔ collab ↔ + Postgres `LISTEN/NOTIFY`), export (pandoc/Gotenberg sidecars), + backup, and each read channel from #222. +- [ ] Network plan with all ports and protocols, internal versus + externally exposed, matching the compose files exactly. +- [ ] Trust boundaries named explicitly, including the plugin sandbox + (ADR 0008) and the reverse-proxy boundary from #215. +- [ ] Every content copy is listed — database, uploads, `page_updates`, + `page_content_cache` including the search vector, versions, backups, + export artefacts — because the deletion concept in #229 depends on + this list being complete. +- [ ] Diagrams are maintainable as text (the repo already renders + Mermaid), not binary images. + +## Out of scope + +Penetration test and threat model as separate deliverables. + +--- + +#### #229 — [VS-NfD] Write the operations manual (installation, update, backup/restore, deletion, role separation) + +**Plan reference:** `20-massnahmenplan.md` → Phase 5 +**ADR:** ADR 0019 +**Effort:** L (4–5 AT) +**Depends on:** #193, #194, #221 +**Labels:** `vs-nfd` `effort:L` `area:docs` + +## Context + +The operator has to run this without us, including on the day something +fails. Deletion and destruction is the chapter with the most VS-specific +weight. + +## Current state + +- Partial material: `docs/operations/restore-runbook.md`, + `deploy/stages.md`, `docs/architecture/operations.md`, + `deploy/backup-basel.md`, `deploy/monitoring.md`. +- Gaps that must be closed by their issues before this can be truthful: + pond purge (#193), orphan-file sweep (#194), offline update (#221). + +## Acceptance criteria + +- [ ] Installation including the airgap variant (referencing #218–#221). +- [ ] Update and rollback, with the migration caveats from #221. +- [ ] Backup and restore, including a rehearsed restore and the + restriction on targets from #192. +- [ ] Deletion and destruction: per content type, what deletion does, which + copies it reaches (using the list from #228), how long residues + persist, and how an instance is decommissioned. +- [ ] Role separation: which tasks need Site-Admin, which need platform + access, and what a Site-Admin can **not** do. +- [ ] Every procedure has been executed at least once by its author, and + says so. + +## Out of scope + +Customer-specific operating concepts, and 24/7 support processes. + +--- + +#### #230 — [VS-NfD] Produce the IT-Grundschutz mapping for APP.3.1 and CON.11.1 + +**Plan reference:** `20-massnahmenplan.md` → Phase 5 +**ADR:** ADR 0019 +**Effort:** L (3–4 AT) +**Depends on:** #226, #227, #228 +**Labels:** `vs-nfd` `effort:L` `area:docs` + +## Context + +The authority's own documentation obligation runs along these building +blocks. Supplying the mapping ourselves saves them the translation work and +prevents them from guessing wrong about us. + +## Current state + +No IT-Grundschutz material exists in the repository. + +## Acceptance criteria + +- [ ] Every requirement of APP.3.1 (web applications) and CON.11.1 + (Verschlusssachen handling) is classified as **product**, + **operator**, or **not applicable**, with a one-line justification. +- [ ] Product requirements point to code, configuration or test evidence; + operator requirements say what we hand over to enable it. +- [ ] "Not applicable" is argued, never asserted. +- [ ] Open requirements point at the issue that closes them, so the + document doubles as a gap list. +- [ ] The building-block versions used are stated (they get revised). + +## Out of scope + +Requirements from other building blocks, and the authority's +Sicherheitskonzept. + +--- + +#### #231 — [VS-NfD] Maintain the residual-risk list + +**Plan reference:** `20-massnahmenplan.md` → Phase 5 +**ADR:** ADR 0019 +**Effort:** S (1 AT) +**Depends on:** #226 +**Labels:** `vs-nfd` `effort:S` `area:docs` + +## Context + +Naming what is deliberately left open is a credibility instrument. An +assessor who finds an undocumented gap distrusts the whole submission; one +who finds it already listed does not. + +## Current state + +- Deliberately unscheduled items are recorded in project documentation + today (external search engine, signup admin approval, plugin network + allowlist); items already identified in this entwurf that belong on the + list: attachment content carries no internal marking (#212), plugin + hash pinning deferred (#232), and whether the local-auth switch is + runtime-flippable (#216). +- From `10-ist-aufnahme.md`: the IndexedDB copy on endpoints (I-25, + `apps/web/src/editor/use-collab-provider.ts:76` — survives a browser + crash and for unsynced offline edits), page titles in digest mails + (I-23), and the `page_links.target_slug` residue if #235 decides to keep + it (I-24). + +## Acceptance criteria + +- [ ] Each entry states: the risk, why it is accepted, its compensating + control, and who decided. +- [ ] Entries from #212, #216, #232 and the git-history check in #198 are + present. +- [ ] Entries from `10-ist-aufnahme.md` I-23, I-24 and I-25 are present. +- [ ] The list is referenced from #226 and #230, and updated in the same PR + whenever an issue closes with a knowingly open remainder. + +## Out of scope + +Formal risk scoring, and the customer's own risk acceptance. + +--- + +### M8 — `VS-NfD: backlog` + +#### #232 — [VS-NfD] Add a plugin allowlist with SHA-256 hash pinning + +**Plan reference:** `20-massnahmenplan.md` → Phase 4 +**ADR:** ADR 0025 +**Effort:** L (8–10 AT) +**Depends on:** #200 +**Labels:** `vs-nfd` `effort:L` `area:supply-chain` + +## Context + +Hash pinning is the right answer to plugin trust; it is not the most urgent +one, because #200's hard off-switch already closes the risk for the offer +stage. Real code signing is unavailable without a legal entity to hold a +signing identity (ADR 0025). + +## Current state + +- Plugins declare a `manifest.json` + (e.g. `packages/plugins/drawio/manifest.json`: `id`, `version`, + `apiVersion`, `kind`, `extensionPoints`, `permissions`, `fallback`) — + there is no hash or signature field. +- Bundles are shipped as zip artefacts + (`packages/plugins/drawio/dist/drawio-1.0.0.zip`); loading is gated by + the sandbox (ADR 0008) and per-pond enablement, not by identity. +- `instance_settings` is the place for the allowlist + (`apps/api/prisma/schema.prisma:17–23`). + +## Acceptance criteria + +- [ ] The manifest carries a SHA-256 over the bundle; the hash is verified + on install **and** on every load, failing closed with a clear error. +- [ ] An allowlist in `instance_settings` names permitted plugin ids with + their pinned hashes; a plugin outside it does not load even if + installed. +- [ ] Admin UI to review the allowlist and the hash actually seen versus + the one pinned. +- [ ] Every rejection is audited (catalogue event per #201). +- [ ] Tests: tampered bundle rejected; unpinned plugin rejected; + version bump requires an explicit re-pin. +- [ ] `docs/architecture/plugin-architecture.md` and #227 document the + model. + +## Out of scope + +Code signing with a certificate, a plugin marketplace, and network +allowlisting for plugins (deliberately unscheduled). + +--- + +### Nachtrag aus der Ist-Aufnahme (nicht im Maßnahmenplan) + +Vier Befunde, die `10-ist-aufnahme.md` zusätzlich zum Plan gefunden hat. +Drei betreffen unbefristete Inhaltskopien, einer die Reproduzierbarkeit. + +#### #233 — [VS-NfD] Prune conversion job payloads for every job kind + +**Plan reference:** n/a — `docs/vs-nfd/10-ist-aufnahme.md` → I-22 +**ADR:** n/a +**Effort:** M (2 AT) +**Depends on:** — +**Milestone:** `M24 — VS-NfD: security quick wins` +**Labels:** `vs-nfd` `effort:M` `area:storage` + +## Context + +The raw bytes of every import and export survive indefinitely in the +database. A deleted classified page therefore lives on inside its last +export — the kind of residue a deletion concept cannot leave unexplained. + +## Current state + +- `ConversionJob.input` / `.result` are the raw document bytes + (`apps/api/prisma/schema.prisma:746–784`). The schema calls them + "transient, not the durable copy an Attachment is" and refers to + "a later maintenance job" for pruning. +- `expiresAt` is documented as set only for data-export jobs (#68) and + "Null for every other job kind, whose result never expires". +- Of the five registered scheduled jobs — `version-thinning`, + `page-compaction`, `trash-purge`, `data-export-purge`, + `notification-digest` — only `data-export-purge` touches conversion job + rows (`apps/api/src/import-export/import-export.module.ts:62`). + +## Acceptance criteria + +- [ ] Payloads of **all** job kinds are pruned after a configurable period + with a documented default; the row may survive for status/audit + purposes, the bytes must not. +- [ ] A backfill clears payloads of already-finished jobs. +- [ ] Pruning does not break an in-flight job (`lockedAt` recovery path + respected) — asserted by test. +- [ ] Test: finished export job's `result` is null after the period; a + pending job is untouched. +- [ ] `docs/architecture/operations.md` documents the job; #229 lists it + under deletion and destruction. + +## Out of scope + +Changing where conversion happens, and the data-export purge that already +works. + +--- + +#### #234 — [VS-NfD] Add retention for `mail_outbox` + +**Plan reference:** n/a — `10-ist-aufnahme.md` → I-23 +**ADR:** n/a +**Effort:** S (1 AT) +**Depends on:** — +**Milestone:** `M24 — VS-NfD: security quick wins` +**Labels:** `vs-nfd` `effort:S` `area:storage` + +## Context + +Sent mails are kept forever, and digest mails contain page titles plus who +edited them. For a classified page the title alone can be protected +information, so this is an unbounded copy of content-adjacent data. + +## Current state + +- `MailOutbox` stores `text_body` and `html_body` permanently + (`apps/api/prisma/schema.prisma:683–697`); no pruning job is registered. +- Digest mails include page titles and actor names: + `apps/api/src/notifications/digest.service.ts:16,125,146` + (`- ${page.pageTitle}: … (${actorNames})`). +- Transactional mails carry no content — greeting, i18n body, link only + (`apps/api/src/mail/mail-templates.ts:25–44`). + +## Acceptance criteria + +- [ ] Sent and permanently failed entries are deleted after a configurable + period with a documented default; the retry logic for pending + entries is unaffected. +- [ ] Test: sent entry past the period removed, pending entry kept, + failed-and-retryable entry kept. +- [ ] `docs/architecture/security.md` (privacy section) and #229 name the + period. +- [ ] Whether digest mails should carry page titles at all for classified + pages is decided and recorded — either suppressed or accepted in + #231. + +## Out of scope + +Changing the mail templates' content beyond that decision, and mail +delivery logging. + +--- + +#### #235 — [VS-NfD] Decide the fate of `page_links` rows pointing at purged pages + +**Plan reference:** n/a — `10-ist-aufnahme.md` → I-24 +**ADR:** n/a +**Effort:** S (0,5 AT) +**Depends on:** — +**Milestone:** `M24 — VS-NfD: security quick wins` +**Milestone note:** cheapest issue in the set; deliberately a decision, not +necessarily a change. +**Labels:** `vs-nfd` `effort:S` `area:storage` + +## Context + +After a page is purged, other pages' link rows keep its slug. A slug +carries the page title, and for a classified page that can itself be +protected information. + +## Current state + +- `PageLink.fromPage` is `onDelete: Cascade`, `toPage` is + `onDelete: SetNull` (`apps/api/prisma/schema.prisma:441–461`). Purging a + page nulls `to_page_id` but leaves `target_slug` in place — by design, + because that is what makes a "phantom" link resolve again if a page with + that slug reappears. + +## Acceptance criteria + +- [ ] A decision is made and documented: either purge-time deletion of + rows whose `target_slug` matches the purged page (losing phantom-link + re-resolution), or keeping them as an accepted residue. +- [ ] If kept: recorded in #231 with its reasoning, and named in the + deletion chapter of #229 so an operator can answer for it. +- [ ] If deleted: a test proves no row referencing the purged slug + survives, and the phantom-link behaviour change is noted in the + release notes. + +## Out of scope + +Redesigning the wikilink index. + +--- + +#### #236 — [VS-NfD] Pin the Node version + +**Plan reference:** n/a — `10-ist-aufnahme.md` → I-26 +**ADR:** ADR 0024 +**Effort:** S (0,5 AT) +**Depends on:** — +**Milestone:** `M25 — VS-NfD: hardening & supply chain` +**Labels:** `vs-nfd` `effort:S` `area:supply-chain` + +## Context + +A reproducible offline build cannot rest on "any Node ≥ 22". This is a +precondition for the reproducibility claim in #219, which is why it lands +before the offline milestone. + +## Current state + +- `package.json:7–10`: `"engines": { "node": ">=22" }` — a lower bound, not + a pin. `"packageManager": "pnpm@11.9.0"` is exact, so the pattern for + pinning is already established in the same file. +- Dockerfiles and CI (`.gitea/workflows/ci.yml`, `actions/setup-node@v4`) + each select a version independently. + +## Acceptance criteria + +- [ ] One authoritative Node version is declared and consumed by CI, the + Dockerfiles and local development; a drift between them fails CI. +- [ ] The update procedure for that version is documented (it will need + raising for security fixes). +- [ ] The version is stated in #228 alongside the other toolchain + versions. + +## Out of scope + +Changing the Node major, and pinning image digests (#203). + +--- + +## 5. ADRs + +Nummerierung fortlaufend nach 0018; Format wie `0018-color-theming.md` +(`# ADR NNNN: …`, Status/Date-Bullets, `## Context`, `## Decision`, +`## Consequences`, abschließend die umsetzenden Issues). + +Status-Vorschlag: **`proposed`** beim Anlegen, `accepted` sobald Stefan die +Richtung bestätigt. Abweichung von den bestehenden ADRs (alle `accepted`), +weil diese acht Entscheidungen ein Vorhaben beschreiben, das noch nicht +begonnen hat — Rückfrage 6.4. + +--- + +### ADR 0019 — `docs/architecture/adr/0019-no-security-base-functions.md` + +```markdown +# ADR 0019: No security base functions in the application (§52 VSA) + +- Status: proposed +- Date: 2026-07-29 + +## Context + +Dorfteich is to be operable inside an IT environment of a German federal +authority that is approved under the Verschlusssachenanweisung (VSA), for +content classified VS-NfD. **No BSI certification of Dorfteich itself is +sought.** + +§51 VSA makes products that provide a _Sicherheitsgrundfunktion_ subject to +certification. §52 VSA enumerates those base functions: encryption, media +protection (Datenträgerschutz), network termination (Netzabschluss), and +authentication. A product that implements one of them itself moves into the +certification obligation — an outcome that would end this undertaking on +cost grounds alone. + +Today Dorfteich sits close to the right side of that line, partly by +accident and partly by design: there is no content encryption, no backup +encryption, no own MFA, and no cryptographic primitive of our own beyond +signing short-lived collaboration tokens and hashing credentials. What is +missing is the _decision_ — so that no future feature crosses the line +because nobody had written down where it runs. + +## Decision + +**Dorfteich does not provide any security base function within the meaning +of §52 VSA. Encryption, media protection, network termination and +authentication belong to the operator's platform.** + +Concretely: + +1. **No encryption of content**, neither in the database nor on the file + system. Confidentiality of stored data is provided by the platform + (full-disk / volume encryption). +2. **No backup encryption in the application.** Media protection is the + platform's function; the application restricts _where_ backups may go + (ADR 0026) and nothing more. +3. **No own MFA, no own password policy engine.** Authentication is + delegated to the operator's identity provider (ADR 0021). Local + passwords remain available for non-VS deployments and are hard-switchable + off. +4. **No new cryptographic primitives.** Existing crypto is limited to + credential hashing (Argon2id), token hashing (SHA-256) and signing + short-lived tokens, and it uses vetted libraries rather than + hand-written constructions (ADR 0020). +5. **No TLS termination, no network segmentation** in the application. +6. **No application-side separation of classification levels.** Levels are + separated by operating one instance per level; the application only + _marks_ content (ADR 0022). + +The application's contribution to security is a different set of +properties, and these it does own: a central, default-closed permission +model; complete absence of outbound connections; verifiable marking of +classified content in every output channel; and an audit trail. + +## Consequences + +- Deliberate non-features must be argued as architecture, not apologised + for as gaps. "No encryption in the code" is the correct division of + labour under §52 VSA. +- Every feature proposal is measured against this ADR. Any change that + would make the application the bearer of a base function needs to amend + this ADR first — which is the point of writing it down. +- The operator carries obligations that must be handed over explicitly and + in writing. This ADR is therefore the **draft of the delimitation + statement** (Abgrenzungserklärung) that #226 turns into a + reviewer-facing document; the two must not diverge. +- Anything the platform cannot supply because it lacks application + knowledge stays with us. Two cases exist today: marking of classified + content (only the application knows the classification, ADR 0022) and + integrity of the application's own payloads (#199). +- Residual risks arising from delegation are listed in #231 rather than + silently accepted. + +## Implementing issues + +#226 (delimitation statement), #227 (hardening guide), #228 (security +documentation), #229 (operations manual), #230 (IT-Grundschutz mapping), +#231 (residual-risk list). +``` + +--- + +### ADR 0020 — `docs/architecture/adr/0020-token-crypto-key-separation.md` + +```markdown +# ADR 0020: Token crypto — HKDF key separation and a vetted JWT library + +- Status: proposed +- Date: 2026-07-29 + +## Context + +`COLLAB_TOKEN_SECRET` currently signs two unrelated kinds of token: +short-lived collaboration tokens (issue #34) and long-lived unsubscribe +tokens in outgoing mail. A single secret across purposes means a +compromise in one path is transferable to the other. + +`packages/shared/src/token-crypto.ts` implements the compact HS256 JWT by +hand on `node:crypto`. The reason is documented in the file and is a good +one: the identical code has to run in the CommonJS api and the ESM collab +server without module-interop or dependency-version drift. The +implementation is careful — HS256 only, constant-time comparison before +any untrusted field is read. It is nevertheless hand-written crypto in the +trust boundary, which is a finding in any assessment regardless of its +quality. + +ADR 0019 states the application implements no security base function. +Token signing is not one — but it is crypto we do perform, so it has to be +minimal, purpose-bound and delegated to a vetted implementation. + +## Decision + +1. **Purpose-bound subkeys via HKDF.** The configured secret becomes a root + key from which each purpose derives its own subkey (collaboration + tokens, unsubscribe tokens, any future purpose). No code path signs with + the root key. +2. **`jose` replaces the homegrown JWT.** It is maintained, audited, + works in both module systems, and is dependency-free — which matters for + the supply-chain argument. HS256 stays the only accepted algorithm, as + an explicit allowlist rather than an implicit default. +3. **Purpose separation is structural, not textual.** The existing + `PURPOSE` string prefix in `unsubscribe-token.ts` is superseded by key + separation; a token signed for one purpose cannot verify under another + because the key differs. +4. **Long-lived tokens get a documented dual-verify window.** Unsubscribe + links live in mail that has already been sent, so both derivations are + accepted for a stated period with a stated expiry date. The window is a + documented fact, not an accident. + +## Consequences + +- The cross-runtime property that motivated the hand-written code must be + proven by a test, not assumed — otherwise the reason for the original + decision is lost silently. +- One new runtime dependency. Accepted: `jose` has no transitive + dependencies, so the supply-chain delta is one package. +- Rotating the root key invalidates all derived subkeys at once, which is + the desired behaviour and needs documenting in the operations manual. +- The key hierarchy becomes part of the security documentation (#228) and + the delimitation statement's crypto section (#226). + +## Implementing issues + +#188. +``` + +--- + +### ADR 0021 — `docs/architecture/adr/0021-external-authentication.md` + +```markdown +# ADR 0021: External authentication via OIDC; local passwords optional + +- Status: proposed +- Date: 2026-07-29 + +## Context + +ADR 0007 established sessions and identities with OIDC in mind: +`UserIdentity.provider` is documented as `"password"` today and +`"oidc:"` later, with `@@unique([provider, subject])` already in +place. No OIDC code exists — the readiness is structural only. + +Authentication is a security base function under §52 VSA (ADR 0019), so it +belongs to the operator's platform. An authority environment additionally +brings its own account lifecycle: joiners, movers and leavers are managed +in the IdP, and a second account store inside the application would drift +from it. + +Some environments terminate authentication at the perimeter instead and +expect the application to trust a header or a client certificate. + +## Decision + +1. **OIDC Authorization Code with PKCE is the primary path**, configured by + discovery, validated against JWKS. Keycloak is the reference IdP we + verify against; nothing in the implementation is Keycloak-specific. +2. **Identities use the existing slot**: `provider = "oidc:"`, + `subject` from the token. Linking an OIDC identity to an existing local + user follows an explicit, documented rule — never silently by e-mail + address, which would be an account-takeover path. +3. **Local authentication is switchable off in full**, via + `auth.local.enabled = false`. "In full" means every credential-issuing + flow: password login, self-service signup, password reset, + verification-as-login, and the token flows (PAT, feed tokens). A + half-closed local path makes the operating concept untrue, which is + worse than not closing it. +4. **Proxy header and mTLS are a supported alternative path, off by + default.** When enabled they require an allowlist of trusted peers; a + request carrying the header from an untrusted peer is rejected and + audited. The trust boundary is stated explicitly in the security + documentation. +5. **Claims map onto the existing permission model** declaratively, and + mapped grants are written through the same service path as manual ones + so the permission cache stays correct. The application gains no second + authorization model. +6. **No MFA, no password policy engine of our own** (ADR 0019). Both are + the IdP's. + +## Consequences + +- Bootstrapping needs a documented answer: the first-run wizard creates a + local admin, so either it stays exempt with a stated compensating + control, or setup itself runs against the IdP. The choice is recorded in + #216. +- Whether the switch is deploy-level or runtime matters: a runtime setting + can be flipped back by a compromised Site-Admin. If it stays runtime, + that residual risk goes into #231. +- Existing password hashes remain in the database after the switch. Their + deletion is out of scope and, being Argon2id, they are not a + confidentiality problem — but the fact is documented. +- Session handling is unchanged: OIDC produces a session through the same + service, so there is exactly one session mechanism (see #190 for its + bounds). +- SAML and LDAP stay out. OIDC plus proxy/mTLS covers the environments we + target; adding SAML would be a new decision. + +## Implementing issues + +#214 (OIDC + PKCE), #215 (proxy header / mTLS), #216 +(`auth.local.enabled`), #217 (claim mapping). Depends on #188 for the +vetted JWT implementation. +``` + +--- + +### ADR 0022 — `docs/architecture/adr/0022-page-classification.md` + +```markdown +# ADR 0022: Classification as first-class page metadata + +- Status: proposed +- Date: 2026-07-29 + +## Context + +VS-NfD content must be marked, in every output that leaves the system. +Dorfteich has no classification concept today: `model Page` carries title, +slug, tree position and timestamps, and nothing else that could express a +protection level. + +The obvious shortcut is to reuse labels. Verification shows why that +fails: + +- `Label` is **pond-scoped** (`pondId`), so the same classification would + be a different object in every pond, with no instance-wide meaning. +- Labels are **user-editable** by any editor; a marking must not be + removable as a matter of routine content work. +- Labels do **not inherit** down the page tree, so a subpage of classified + content would silently be unmarked. +- Labels **never leave the application**: `export.service.ts` loads + `labelIds` only to feed `permissions.filterPages`, and no export path + writes them out. A carrier that does not reach the output channels cannot + serve as a marking. + +The second question is architectural: should the application separate +classification _levels_? It must not (ADR 0019, and the plan's Phase 0 +guardrails). Separation is a platform property. + +## Decision + +1. **A dedicated enum field on `Page`**, with an instance-wide default from + `instance_settings`. Not labels, for the four reasons above. +2. **Separation of levels happens outside the application: one instance per + classification level.** The application marks; it does not isolate. + This is the central operational decision of the whole undertaking and + belongs here rather than in a manual, because it defines what the + feature is _not_. +3. **The application-side ACL is order, not a protection mechanism.** + Permissions keep working as they do (central, default-closed, + deny-wins), and the classification field does not change them. Anyone + reading the code must not mistake the field for an isolation boundary — + the test in #204 pins that. +4. **Classification inherits down the page tree.** A new or moved page + takes at least its parent's level. Raising is ordinary editorial work; + **lowering requires a dedicated capability** in the central permission + model and is audited with old value, new value, actor and page. +5. **Every output channel carries the marking**, and each is an + independently closable issue: web view, browser print, server-side PDF, + DOCX/ODT, Markdown ZIP, feeds, public API, search results, no-JS shell, + attachment download. A channel that cannot carry it internally + (arbitrary binary attachments) is marked externally — filename prefix + plus companion file — and the remaining gap is a documented residual + risk, not a silent one. +6. **Unclassified content shows no marking.** Marking everything trains + users to ignore markings. + +## Consequences + +- Ten issues, because there are ten output paths; that is the honest cost + of "in every output". +- The no-JS shell and the SPA are separate render paths, so each needs its + own assertion. Likewise the TipTap NodeView path and the server-side + `docToHtml` path differ structurally. +- The field is a precondition for the read-access audit trail (ADR 0023), + which is scoped to classified content only. +- Attachments inherit their page's classification. The case where the + page link is not yet set (paste-then-insert) fails closed. +- Because levels are separated by instance, a page can never "move + between levels" inside one deployment — export/import across instances is + the path, and its marking is covered by the export channels. + +## Implementing issues + +#204 (field + default), #205 (inheritance + downgrade right), #206 (web), +#207 (print), #208 (PDF), #209 (DOCX/ODT), #210 (Markdown ZIP), #211 +(feeds/API/search/no-JS), #212 (attachments), #213 (upload warning). +``` + +--- + +### ADR 0023 — `docs/architecture/adr/0023-read-access-audit-trail.md` + +```markdown +# ADR 0023: Read-access audit trail limited to classified content + +- Status: proposed +- Date: 2026-07-29 + +## Context + +The existing audit trail (`audit_log`, issue #86) is deliberately scoped to +"who changed access or configuration", and its own service comment states +that content activity stays log-only. There is no record of _reads_. + +For "operable in an approved environment", read logging is not a mandatory +product feature — evidence collection can be a platform function. In +practice platform logging cannot answer the question that matters: a proxy +log knows URLs, not classifications, so it cannot say which _classified_ +page was read. Leistungsbeschreibungen tend to list this as a must. + +Two variants were considered. Variant B logs all reads (18–20 AT) and +brings volume, latency and retention problems, plus the requirement that no +event may be lost. Variant A logs reads of classified pages only (8–10 AT). + +A live editing session is the volume hazard: Yjs sync means continuous +traffic per open document. + +## Decision + +**Variant A: read events are recorded only for pages with +`classification = VS_NFD`.** Requires ADR 0022. + +1. **All read channels are instrumented**, or the feature is worthless: + SPA page fetch, public API GET, attachment download, export, no-JS + shell, collab WS join. +2. **A dedup window** (session + page + channel within N minutes = one + event) keeps Yjs sync from flooding the trail. The recorded event states + that it represents a window, so the evidence is not overread. +3. **Its own table**, with time partitioning and its own retention period — + independent of `audit_log`, because volume, purpose and legal basis all + differ. +4. **Failure is not silent.** `AuditService` swallows write failures by + design; for classified reads a lost event is a gap in evidence, so the + behaviour is either hard failure or an explicitly documented + degradation. Which one is decided in #222 and stated in the security + documentation. +5. **Switchable, with a written purpose limitation.** Off means nothing is + written anywhere; a startup log line states the trail is off so a gap is + never ambiguous. +6. **Variant B is rejected**, and the rejection is recorded rather than + left open: unbounded volume, the no-loss requirement, and a purpose + limitation that is much harder to defend. + +## Consequences + +- The scope limit is the feature's strongest argument in the works-council + discussion at the customer: only classified content is observed. +- Reads of unclassified content are not evidenced. Deliberate, and it goes + into the residual-risk list. +- The collab WS join is the awkward channel: authorization there is + token-only (signature plus `pageId` match) and the collab server has no + permission context. Either the event carries what the token asserts, or + the api emits it at token issuance. #222 decides and documents; the + choice affects what the trail can prove about live sessions. +- Retention and partition maintenance are operational obligations that + must ship with the feature, not after it. +- Classification at read time is stored with the event: a later + reclassification must not rewrite history. + +## Implementing issues + +#222 (instrumentation), #223 (dedup window), #224 (table, retention, +partitioning), #225 (switch + purpose limitation). Depends on #204/#205. +``` + +--- + +### ADR 0024 — `docs/architecture/adr/0024-reproducible-offline-deployment.md` + +```markdown +# ADR 0024: Reproducible offline deployment + +- Status: proposed +- Date: 2026-07-29 + +## Context + +A VS zone has no internet egress. "Should work offline" is the answer that +loses a first meeting; "tested, here is the procedure" is the one that +wins it — which is why the plan pulled this out of the roadmap into +Phase 1. + +The current state is favourable but unverified. There is no telemetry, no +update check, no CDN; fonts are self-hosted (ADR 0016); CSP is +`default-src 'self'`; search is Postgres rather than an external engine; +the drawio plugin is vendored rather than loaded from a remote editor. What +is missing is evidence, plus two real gaps: images are referenced by tag +(including the floating `gotenberg/gotenberg:8`), and there is no +documented mirror or update path. + +## Decision + +1. **All third-party images are pinned by digest** (`name:tag@sha256:…`). + The tag stays for human readability; the digest decides what runs. A CI + check rejects any un-digested third-party reference. +2. **The image list is generated, not hand-maintained**, so a mirror + procedure cannot silently miss a service. +3. **An internal registry is the supported source.** Compose takes the + registry prefix from configuration; no site edits image references. +4. **Reproducibility without network is a stated choice between two + paths**: an offline pnpm store enabling `install` + `build` with + networking disabled, **or** prebuilt images only with no customer-side + build. Either is acceptable; leaving it unstated is not, because it + determines whether the customer can patch locally. +5. **The airgap claim is proven by a documented run** in a network-isolated + environment, covering every function including the export sidecars, and + listing every outbound connection attempt observed. This run is the + artefact, and it also answers the plan's open question about what breaks + offline. +6. **The offline update path is part of the decision, not an afterthought**: + bundle, verify by digest, back up, apply, verify, roll back — with the + irreversibility of migrations stated explicitly. + +## Consequences + +- Digest pinning creates recurring maintenance: security updates now + require an explicit, reviewable change. That visibility is the point. +- Digest pinning must precede the mirror and update work, so it sits in the + `hardening & supply chain` milestone rather than this one. +- The isolated test run will surface findings; each becomes its own issue + referenced from #220 rather than expanding that issue's scope. +- Outbound SMTP is the one connection an authority may or may not permit; + the deployment must be functional without it, and the consequences of + disabling it (no notifications, no verification mail — which interacts + with `auth.local.enabled = false`) are documented. +- CD does not sync stage composes, so digest changes need an explicit + rollout step on the stage hosts. + +## Implementing issues + +#203 (digest pinning), #218 (registry mirror), #219 (network-free build), +#220 (isolated test run), #221 (offline update path), #236 (pinned Node +version — added from the Ist-Aufnahme, I-26). +``` + +--- + +### ADR 0025 — `docs/architecture/adr/0025-plugin-trust-model.md` + +```markdown +# ADR 0025: Plugin trust model + +- Status: proposed +- Date: 2026-07-29 + +## Context + +Dorfteich has a plugin architecture with a sandbox (ADR 0008): plugins +declare a manifest, run isolated, and hold declared permissions. In a VS +zone the question this attracts is blunt — can code execute inside the +protected area, and who vouches for it? + +Two facts shape the answer. First, the manifest has no integrity or +identity field: nothing binds a bundle to what was reviewed. Second, real +code signing needs a signing identity, and without a legal entity behind +the project there is none to be had — a self-generated key that we also +distribute proves nothing. + +There is also an asymmetry in cost: a hard off-switch is ~2 AT and closes +the risk completely for a deployment that does not need plugins; a trust +model is 8–10 AT and only _manages_ the risk. + +## Decision + +1. **Short term: hard, verifiable off-switch.** `plugins.enabled = false` + makes every plugin surface answer 404 — manifests, assets, the frame + route, install/uninstall, and the per-pond toggles — following the + established pattern of `api.enabled` and `mcp.enabled`. Off is part of + the VS-NfD reference configuration. +2. **Documents stay readable with plugins off.** An existing plugin block + renders its declared `fallback`, never an error. Disabling a feature must + not damage content. +3. **Medium term: hash pinning, not code signing.** A SHA-256 over the + bundle in the manifest, an allowlist of id + pinned hash in + `instance_settings`, verification on install and on every load, failing + closed. A version bump requires an explicit re-pin. +4. **Signing is deliberately rejected for now**, with its reason on the + record: no signing identity is available. Should a legal entity exist + later, signing becomes an amendment to this ADR, not a new discovery. +5. **The sandbox remains the containment mechanism.** Hash pinning answers + "is this the reviewed code", not "what may it do". Both are needed and + neither substitutes for the other. +6. **Network allowlisting for plugins stays unscheduled**, consistent with + the existing project decision; in the VS-NfD profile plugins are off, so + it is not the binding constraint. + +## Consequences + +- The offer stage can answer the code-execution question with a switch and + a test, without waiting for #232. +- Vendored third-party plugin code (drawio 30.3.6 under + `packages/plugins/drawio/vendor/`) is part of our supply chain and + appears in the SBOM (#202). It loads no external editor URL — verified — + and CSP would block it if it tried. +- Hash pinning makes plugin updates a deliberate act, which is the intended + friction. +- The plugin ecosystem stays small by construction. Accepted. + +## Implementing issues + +#200 (hard off-switch), #232 (allowlist + hash pinning). +``` + +--- + +### ADR 0026 — `docs/architecture/adr/0026-backup-target-restriction.md` + +```markdown +# ADR 0026: Backup target restriction + +- Status: proposed +- Date: 2026-07-29 + +## Context + +Backups are the largest single egress path in the system: the entire +content of the instance, in one artefact. Today the remote destination is a +freely configurable WebDAV/Nextcloud URL in `instance_settings`, validated +as a URL but not restricted to any host, plus an rsync mirror to a private +host (ADR 0015, issue #84). Anyone with Site-Admin can therefore direct a +full copy of the instance to an arbitrary server. + +The tempting answer is to encrypt backups in the application. ADR 0019 +rules that out: media protection is the platform's base function, and +implementing it here would move Dorfteich into the certification +obligation under §51 VSA. + +## Decision + +1. **A deploy-level allowlist constrains permissible backup + destinations.** Deploy-level, not a runtime setting, so a compromised + Site-Admin account cannot widen it. +2. **An empty allowlist disables every remote target** — WebDAV and rsync + mirror alike. "Local only" is the VS-NfD reference configuration. +3. **The admin UI distinguishes "unavailable" from "unconfigured"**, so an + operator is never left guessing whether a missing backup is a + misconfiguration or policy. +4. **No application-side backup encryption**, following ADR 0019. Backup + media are protected by the platform. +5. **Integrity of backup artefacts is in scope**, unlike their + confidentiality: checksums let a restore be verified, which is an + application concern because only we know what the artefact should + contain (see #199 for the same reasoning on attachments). + +## Consequences + +- Existing deployments that use a remote target must have it added to the + allowlist, or backups stop. This is a breaking change and is called out + in the release notes. +- The delimitation statement (#226) must state plainly that backups leave + the application unencrypted and that media protection is the operator's + duty. That sentence will be read closely; it is the correct one. +- Off-site backup in an airgapped deployment becomes an operator process + (media handling), not an application feature. +- Restore stays unchanged, including the maintenance-mode interlock that + closes collab sessions during a restore. + +## Implementing issues + +#192 (allowlist + deploy-level disable). Related: #199 (integrity +hashes), #229 (backup/restore chapter of the operations manual). +``` + +--- + +## 6. Rückfragen an Stefan — beantwortet 2026-07-29 + +**Stefans Entscheidungen:** + +| Frage | Entscheidung | +| --------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | +| 6.0 Freigabe Stufe 2 | **Erst die Ist-Aufnahme nachziehen** (`10-ist-aufnahme.md`), Stufe 2 danach. | +| 6.1 Meilenstein-Titel | **An das Repo-Schema anpassen**: `M24 — VS-NfD: security quick wins` … `M31 — VS-NfD: backlog`. | +| 6.2 Labels | **Neue `area:*` wie vorgegeben**, bestehende (`auth`, `docs`, …) unberührt. | +| 6.5 Granularität | **45 Issues bleiben** — keine Bündelung. | +| 6.3 / 6.4 / 6.7 | Nicht separat entschieden → die im Entwurf vorgeschlagenen Defaults gelten: `area:ops` als elftes Label für #197/#201, ADR-Status `proposed` beim Anlegen, AT-Kopfzahlen des Plans unangetastet (Positions-Summen zusätzlich ausgewiesen). | + +Damit lauten die Meilenstein-Titel für Stufe 2: + +| # | Titel | +| --- | ----------------------------------------- | +| M24 | `M24 — VS-NfD: security quick wins` | +| M25 | `M25 — VS-NfD: hardening & supply chain` | +| M26 | `M26 — VS-NfD: classification metadata` | +| M27 | `M27 — VS-NfD: external authentication` | +| M28 | `M28 — VS-NfD: offline/airgap deployment` | +| M29 | `M29 — VS-NfD: read-access audit trail` | +| M30 | `M30 — VS-NfD: compliance documentation` | +| M31 | `M31 — VS-NfD: backlog` | + +Die Zuordnungen in Abschnitt 2 und 4 gelten unverändert (M1…M8 dort sind +die Kurzreferenzen dieses Entwurfs, nicht die Gitea-Titel). + +Die ursprünglichen Fragen bleiben zur Nachvollziehbarkeit stehen: + +### 6.1 Meilenstein-Titel: Namensschema + +Die vorhandenen Meilensteine heißen `M0 — …` bis `M23 — …`. Der Auftrag +gibt für die neuen `VS-NfD: security quick wins` etc. vor — ohne Nummer. +Ergebnis wäre eine gemischte Liste. + +- **(a)** Auftrags-Titel wörtlich übernehmen (`VS-NfD: security quick +wins`). — Meine Empfehlung: Vorgabe ist Vorgabe, und das Prefix + gruppiert in Gitea ohnehin sauber. +- **(b)** An das Repo-Schema anpassen: `M24 — VS-NfD: security quick wins` + usw. + +### 6.2 Label-Dopplung + +Es gibt bereits `auth`, `docs`, `plugins`, `deployment`, `backend`, +`frontend`, `collab`, `qa`, `blocked`. Der Auftrag verlangt zusätzlich +`area:auth`, `area:docs`, `area:export`, `area:storage`, +`area:supply-chain` — teils redundant zu den bestehenden. + +- **(a)** Neue `area:*`-Labels wie vorgegeben anlegen, bestehende + unberührt lassen (VS-NfD-Issues nutzen nur `area:*`). — Empfehlung: + hält den VS-NfD-Bestand in einem konsistenten Schema. +- **(b)** Bestehende Labels weiterverwenden, wo sie passen + (`auth`, `docs`), nur die fehlenden neu (`area:export`, `area:storage`, + `area:supply-chain`). + +### 6.3 Zwei Issues ohne passendes `area:`-Label + +#197 (Security-Header/CORS) und #201 (SIEM-Ereigniskatalog) passen in +keine der fünf vorgegebenen Areas. Vorschlag: ein zusätzliches +`area:ops`. Alternativ bleiben sie provisorisch bei `area:auth` bzw. +`area:docs` (so stehen sie derzeit im Entwurf). + +### 6.4 ADR-Status `proposed` oder `accepted` + +Alle bestehenden ADRs stehen auf `accepted`, weil sie umgesetzte +Entscheidungen dokumentieren. Diese acht beschreiben ein Vorhaben, das +noch nicht begonnen hat. Vorschlag: `proposed` beim Anlegen, Umstellung +auf `accepted` sobald du die Richtung bestätigst — insbesondere ADR 0019, +das die Grundlage aller anderen ist. + +### 6.5 45 Issues statt ~30 — kürzen? + +Die Zahl folgt aus den Auftragsregeln (sieben Ausgabekanal-Issues, sechs +Doku-Issues). Kürzungsoptionen, falls dir das zu granular ist: + +- M3: die sechs kleinen Ausgabekanäle (#206–#210, #212) zu zwei Issues + bündeln („Client-seitige Kanäle", „Server-seitige Exporte") → −4 +- M7: die sechs Doku-Issues zu drei bündeln → −3 +- M1: #196 + #198 (je ≤1 AT) an verwandte Issues anhängen → −2 + +Mein Rat: so lassen. Jedes Issue ist unabhängig abschließbar, und bei +einem Vorhaben über 4–5 Monate ist Granularität hilfreicher als eine +kurze Liste. Kürzen ist später schwerer als Zusammenfassen beim Abarbeiten. + +### 6.6 Fehlende Ist-Aufnahme nachziehen? + +`10-ist-aufnahme.md` existiert nicht (§0.1). Die Belegkette für Prüfer +hat damit eine Lücke — die Fundorte sind verifiziert, aber nur in diesem +Entwurf und in den Issues, nicht im dafür vorgesehenen Dokument. Soll ich +die Ist-Aufnahme in einer eigenen Session aus +`00-analyse-auftrag.md` erzeugen? Die hier verifizierten Fundorte fließen +direkt ein, der Aufwand liegt damit deutlich unter einer Erstanalyse. + +### 6.7 Aufwands-Deltas + +§0.3: Die Phasen-Kopfzahlen im Plan liegen unter der Summe ihrer eigenen +Positionen (Phase 2: +4,5 AT, Phase 5: +3 AT, P1-1: +1 AT, Phase 3: ++1 AT). Ich habe nicht neu geschätzt. Soll der Plan in Stufe 2 auf die +Positions-Summen korrigiert werden (Gesamtsumme dann ~80–101 AT statt +75–96 AT), oder bleiben die Kopfzahlen als bewusst gerundete Planwerte +stehen? + +--- + +## 7. Was Stufe 2 tut (nach Freigabe) + +1. Acht ADR-Dateien nach `docs/architecture/adr/0019…0026-*.md` schreiben + (Volltexte oben, Status `proposed`). +2. Elf Labels anlegen (zehn aus dem Auftrag + `area:ops`). +3. Acht Meilensteine `M24 — VS-NfD: …` bis `M31 — VS-NfD: backlog` mit den + Beschreibungstexten aus Abschnitt 2 anlegen. +4. 49 Issues über die Gitea-API als `fable-5` anlegen — sequenziell, mit + Nummernprotokoll, damit `Depends on` korrekt nachgezogen werden kann. + Falle beachten: Issue-POST-Antworten können Steuerzeichen enthalten + (Parser-Fehler ≠ Anlage-Fehler; erst die Liste prüfen, nie blind + erneut posten), und Nicht-ASCII-Kombinationen können 422 auslösen → + Bodys per `printf > datei` und `-d @datei`. +5. Issue-Nummern in `20-massnahmenplan.md` hinter jede Checkbox + zurückschreiben (`· #142`) und je Phase den Meilenstein nennen; die vier + Nachtrags-Issues (#233–#236) als eigenen Abschnitt „Ergänzungen aus der + Ist-Aufnahme" im Plan führen, damit der Plan Index bleibt. +6. Provisorische Nummern in ADRs und Issue-Bodys auf die echten + korrigieren. +7. `git status` prüfen: außer `docs/` nichts geändert. + +## 8. Verifikation dieses Entwurfs + +- [x] Jede Checkbox-Zeile des Plans hat genau ein Issue — Ausnahmen + begründet: #188 (zwei Zeilen, vom Plan selbst gebündelt), #211 + (vier Kanäle, eine Aufwandsangabe im Plan), die fünf „Zu klärenden + Punkte" (vier in §0.2 geklärt, einer in #220 aufgehoben). +- [x] Jeder Befund der Ist-Aufnahme, der nicht im Plan steht, hat ein + Issue oder ein Akzeptanzkriterium: I-22 → #233, I-23 → #234, + I-24 → #235, I-26 → #236, I-25 → #226 + #231. +- [x] Jedes Issue hat Meilenstein, Effort-Label, Area-Label und + Akzeptanzkriterien — Area bei #197/#201 vorbehaltlich 6.3. +- [x] Jedes Issue mit Architekturbezug verweist auf ein ADR; jedes ADR + listet seine umsetzenden Issues. +- [x] Alle zitierten Pfade in dieser Session am Code geprüft (Abschnitt + §0.1/§0.2); Zeilennummern nur, wo verifiziert. +- [ ] Summe der AT je Meilenstein stimmt mit der Auftragstabelle — **nein**, + Abweichungen dokumentiert in §0.3 und Abschnitt 2, Rückfrage 6.7. +- [x] `git status`: nur `docs/vs-nfd/` betroffen (drei verschobene + Auftragsdateien + dieser Entwurf).