docs: VS-NfD readiness planning (ist-aufnahme, plan, ADRs 0019-0026, issue drafts)
All checks were successful
CI / Lint, typecheck, test (pull_request) Successful in 4m42s
CI / Build container images (pull_request) Successful in 1m11s
CI / Auth e2e pack (pull_request) Successful in 7m47s
CI / Import/export fidelity gate (pull_request) Successful in 55s
CD / Build and push images (push) Successful in 18s
CD / Deploy to Test (push) Successful in 14s
CD / Smoke tests against Test (push) Successful in 1m16s
CD / Promote to Int (push) Successful in 11s
CI / Lint, typecheck, test (push) Successful in 4m50s
CI / Build container images (push) Has been skipped
CI / Auth e2e pack (push) Successful in 7m38s
CI / Import/export fidelity gate (push) Successful in 56s

Add docs/vs-nfd/: the analysis brief, the as-is assessment (42 findings,
all verified against the code), the prioritized action plan rev. 2 with
issue references written back to every checkbox, the two-stage issue/ADR
brief, and the full reviewed draft used to create the forge state.

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

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

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0168Ph5uBmHm8X28CSVpbpnJ
This commit is contained in:
Claude Opus 5 2026-07-30 01:48:05 +02:00
parent b5d2a436e0
commit fd07f716f6
13 changed files with 4806 additions and 0 deletions

View File

@ -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).

View File

@ -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.

View File

@ -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:<issuer>"` later, with `@@unique([provider, subject])` already in
place. No OIDC code exists — the readiness is structural only.
Authentication is a security base function under §52 VSA (ADR 0019), so it
belongs to the operator's platform. An authority environment additionally
brings its own account lifecycle: joiners, movers and leavers are managed
in the IdP, and a second account store inside the application would drift
from it.
Some environments terminate authentication at the perimeter instead and
expect the application to trust a header or a client certificate.
## Decision
1. **OIDC Authorization Code with PKCE is the primary path**, configured by
discovery, validated against JWKS. Keycloak is the reference IdP we
verify against; nothing in the implementation is Keycloak-specific.
2. **Identities use the existing slot**: `provider = "oidc:<issuer>"`,
`subject` from the token. Linking an OIDC identity to an existing local
user follows an explicit, documented rule — never silently by e-mail
address, which would be an account-takeover path.
3. **Local authentication is switchable off in full**, via
`auth.local.enabled = false`. "In full" means every credential-issuing
flow: password login, self-service signup, password reset,
verification-as-login, and the token flows (PAT, feed tokens). A
half-closed local path makes the operating concept untrue, which is
worse than not closing it.
4. **Proxy header and mTLS are a supported alternative path, off by
default.** When enabled they require an allowlist of trusted peers; a
request carrying the header from an untrusted peer is rejected and
audited. The trust boundary is stated explicitly in the security
documentation.
5. **Claims map onto the existing permission model** declaratively, and
mapped grants are written through the same service path as manual ones
so the permission cache stays correct. The application gains no second
authorization model.
6. **No MFA, no password policy engine of our own** (ADR 0019). Both are
the IdP's.
## Consequences
- Bootstrapping needs a documented answer: the first-run wizard creates a
local admin, so either it stays exempt with a stated compensating
control, or setup itself runs against the IdP. The choice is recorded in
#216.
- Whether the switch is deploy-level or runtime matters: a runtime setting
can be flipped back by a compromised Site-Admin. If it stays runtime,
that residual risk goes into #231.
- Existing password hashes remain in the database after the switch. Their
deletion is out of scope and, being Argon2id, they are not a
confidentiality problem — but the fact is documented.
- Session handling is unchanged: OIDC produces a session through the same
service, so there is exactly one session mechanism (see #190 for its
bounds).
- SAML and LDAP stay out. OIDC plus proxy/mTLS covers the environments we
target; adding SAML would be a new decision.
## Implementing issues
#214 (OIDC + PKCE), #215 (proxy header / mTLS), #216
(`auth.local.enabled`), #217 (claim mapping). Depends on #188 for the
vetted JWT implementation.

View File

@ -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).

View File

@ -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 (1820 AT) and
brings volume, latency and retention problems, plus the requirement that no
event may be lost. Variant A logs reads of classified pages only (810 AT).
A live editing session is the volume hazard: Yjs sync means continuous
traffic per open document.
## Decision
**Variant A: read events are recorded only for pages with
`classification = VS_NFD`.** Requires ADR 0022.
1. **All read channels are instrumented**, or the feature is worthless:
SPA page fetch, public API GET, attachment download, export, no-JS
shell, collab WS join.
2. **A dedup window** (session + page + channel within N minutes = one
event) keeps Yjs sync from flooding the trail. The recorded event states
that it represents a window, so the evidence is not overread.
3. **Its own table**, with time partitioning and its own retention period —
independent of `audit_log`, because volume, purpose and legal basis all
differ.
4. **Failure is not silent.** `AuditService` swallows write failures by
design; for classified reads a lost event is a gap in evidence, so the
behaviour is either hard failure or an explicitly documented
degradation. Which one is decided in #222 and stated in the security
documentation.
5. **Switchable, with a written purpose limitation.** Off means nothing is
written anywhere; a startup log line states the trail is off so a gap is
never ambiguous.
6. **Variant B is rejected**, and the rejection is recorded rather than
left open: unbounded volume, the no-loss requirement, and a purpose
limitation that is much harder to defend.
## Consequences
- The scope limit is the feature's strongest argument in the works-council
discussion at the customer: only classified content is observed.
- Reads of unclassified content are not evidenced. Deliberate, and it goes
into the residual-risk list.
- The collab WS join is the awkward channel: authorization there is
token-only (signature plus `pageId` match) and the collab server has no
permission context. Either the event carries what the token asserts, or
the api emits it at token issuance. #222 decides and documents; the
choice affects what the trail can prove about live sessions.
- Retention and partition maintenance are operational obligations that
must ship with the feature, not after it.
- Classification at read time is stored with the event: a later
reclassification must not rewrite history.
## Implementing issues
#222 (instrumentation), #223 (dedup window), #224 (table, retention,
partitioning), #225 (switch + purpose limitation). Depends on #204/#205.

View File

@ -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).

View File

@ -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 810 AT and only _manages_ the risk.
## Decision
1. **Short term: hard, verifiable off-switch.** `plugins.enabled = false`
makes every plugin surface answer 404 — manifests, assets, the frame
route, install/uninstall, and the per-pond toggles — following the
established pattern of `api.enabled` and `mcp.enabled`. Off is part of
the VS-NfD reference configuration.
2. **Documents stay readable with plugins off.** An existing plugin block
renders its declared `fallback`, never an error. Disabling a feature must
not damage content.
3. **Medium term: hash pinning, not code signing.** A SHA-256 over the
bundle in the manifest, an allowlist of id + pinned hash in
`instance_settings`, verification on install and on every load, failing
closed. A version bump requires an explicit re-pin.
4. **Signing is deliberately rejected for now**, with its reason on the
record: no signing identity is available. Should a legal entity exist
later, signing becomes an amendment to this ADR, not a new discovery.
5. **The sandbox remains the containment mechanism.** Hash pinning answers
"is this the reviewed code", not "what may it do". Both are needed and
neither substitutes for the other.
6. **Network allowlisting for plugins stays unscheduled**, consistent with
the existing project decision; in the VS-NfD profile plugins are off, so
it is not the binding constraint.
## Consequences
- The offer stage can answer the code-execution question with a switch and
a test, without waiting for #232.
- Vendored third-party plugin code (drawio 30.3.6 under
`packages/plugins/drawio/vendor/`) is part of our supply chain and
appears in the SBOM (#202). It loads no external editor URL — verified —
and CSP would block it if it tried.
- Hash pinning makes plugin updates a deliberate act, which is the intended
friction.
- The plugin ecosystem stays small by construction. Accepted.
## Implementing issues
#200 (hard off-switch), #232 (allowlist + hash pinning).

View File

@ -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).

View File

@ -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.

View File

@ -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** 23 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:562577`; kein `oidc`-Treffer in `apps/api/src` | `UserIdentity.provider` ist als `"password"` heute / `"oidc:<issuer>"` 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:263301` | `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:7679`, `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:108112` | `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:530538`; 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,142143` | 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:7491`; `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:541559`; `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/<pondId>/<fileId>`. 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:746784`; `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:683697`; `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:2544`: 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:441461` | `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,134141,184` | Jede geöffnete Seite wird als Yjs-Dokument in die IndexedDB gespiegelt (`dorfteich-page-<pageId>`, 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:710` | `"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,925` | 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:232253` | 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:4041` (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:1220` | 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:8493`; `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:6270`; `apps/collab/src/server.ts:108125`; `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:121144`; 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:562577`). Gehasht wird mit `argon2id` über die
`argon2`-Bibliothek inklusive `needsRehash` (`users/password.ts:925`) —
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:580595`, `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:5258`). 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:925` |
| 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:102108`), die Suche joint auf lebende Seiten und
Teiche (`postgres-search.provider.ts:142143`), 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:318347`)
4. `page_content_cache``plain_text`, `markdown`, `html`, `outline`
(jsonb) — vier abgeleitete **Klartext**-Formen, bei jedem Save
erneuert (`schema.prisma:392411`)
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/<pondId>/<fileId>` (`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:746784` | **unbefristet** außer für Datenexport-Jobs (I-22) |
| `mail_outbox.text_body` / `.html_body` — Digest-Mails mit Seitentiteln | `schema.prisma:683697` | **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:100164` | an Seite/Nutzer gekoppelt, kaskadierend |
| Backups | `apps/backup/`, ADR 0015 | Retention konfigurierbar |
| PWA-Service-Worker | `vite.config.ts:1220` | **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:423437`) —
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:98105` 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:7679` (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:57`,
`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. **46 AT** (I-22 M, I-23 S, I-24 S,
I-25 in #226/#231 enthalten, I-26 S).

View File

@ -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 3038 AT
### P1-1 Fremdauthentisierung + lokale Auth abschaltbar · 1012 AT
_Meilenstein: `M27 — VS-NfD: external authentication`_
- [ ] OIDC Authorization Code + PKCE gegen `UserIdentity.provider` (ADR 0007
ausbauen), Keycloak als Referenz-IdP · 56 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 · 23 AT · #217
### P1-2 Einstufung als First-Class-Metadatum · 1418 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 · 812 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) · 23 AT · #209
- Markdown-ZIP (Frontmatter + Aufdruck) · 1 AT · #210
- Atom-Feeds, Public-API, Suchergebnisse, No-JS-Shell · 23 AT · #211
- Attachment-Download (Dateiname-Präfix + Begleitdatei) · 12 AT · #212
- [ ] Warnung/Sperre beim Anhängen an eingestufte Seiten · 1 AT · #213
### P1-3 Verifizierter Offline-/Airgap-Pfad · 810 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) · 23 AT · #219
- [ ] Testlauf in netzisolierter Umgebung, Protokoll als Beleg · 2 AT · #220
- [ ] Offline-Update-Pfad inkl. Migrationen · 23 AT · #221
---
## Phase 2 — Billig, hohe Prüfer-Signalwirkung · Summe 2228 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 · 12 AT · #188
- [ ] **Eigenbau-HMAC-JWT durch `jose` ersetzen** · +23 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 56 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 · 12 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 · 12 AT · #202
- [ ] `deploy/compose/.env` prüfen, Beispieldatei statt Realdatei · 0,5 AT · #198
- [ ] **Attachment-Integritätshashes** · +23 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** · +34 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 · 820 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 · 810 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 · 12 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 · 1820 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** · 810 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 · 1520 AT (vorher 1215)
_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 · 45 AT · #229
- [ ] **Zuarbeit IT-Grundschutz** APP.3.1 und CON.11.1, je Anforderung
„Produkt / Betreiber / nicht anwendbar" · 34 AT · #230
- [ ] **Restrisikoliste** mit bewusst offenen Punkten · 1 AT · #231
---
## Terminwirkung
| Block | vorher | Rev. 2 |
| ----------------------- | ------------ | ---------------- |
| Phase 1 Blocker | 2228 AT | 3038 AT |
| Phase 2 Billigblock | 1518 AT | 2228 AT |
| Phase 3 Beweissicherung | — | 810 AT (Var. A) |
| Phase 5 Dokumentation | 1215 AT | 1520 AT |
| **Summe** | **4961 AT** | **7596 AT** |
Bei 4 produktiven Tagen pro Woche:
- **vorher:** ca. 33,5 Monate
- **Rev. 2 mit Variante A:** ca. **4,55,5 Monate**
- **Rev. 2 mit Variante B:** ca. **5,56,5 Monate**
- Plus Phase 4 (Hash-Pinning): weitere ~0,5 Monate
Der Zuwachs von ~2635 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.

View File

@ -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 | 1518 AT |
| M2 | `VS-NfD: hardening & supply chain` | P2 nachgezogen: Attachment-Hashes, Plugin-Abschaltung, Ereigniskatalog, SBOM, Image-Digests | 710 AT |
| M3 | `VS-NfD: classification metadata` | P1-2 vollständig | 1418 AT |
| M4 | `VS-NfD: external authentication` | P1-1 vollständig | 1012 AT |
| M5 | `VS-NfD: offline/airgap deployment` | P1-3 vollständig | 810 AT |
| M6 | `VS-NfD: read-access audit trail` | Phase 3, Variante A | 810 AT |
| M7 | `VS-NfD: compliance documentation` | Phase 5 | 1520 AT |
| M8 | `VS-NfD: backlog` | Phase 4 (Plugin-Hash-Pinning) | 810 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] <imperative, concise>
**Plan reference:** `docs/vs-nfd/20-massnahmenplan.md`<P1-1 / P2-3 / >
**ADR:** <ADR-00XX or "n/a">
**Effort:** <S | M | L> (<n> 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

File diff suppressed because it is too large Load Diff