docs: VS-NfD readiness planning (ist-aufnahme, plan, ADRs 0019-0026, issue drafts) #237
82
docs/architecture/adr/0019-no-security-base-functions.md
Normal file
82
docs/architecture/adr/0019-no-security-base-functions.md
Normal 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).
|
||||||
59
docs/architecture/adr/0020-token-crypto-key-separation.md
Normal file
59
docs/architecture/adr/0020-token-crypto-key-separation.md
Normal 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.
|
||||||
71
docs/architecture/adr/0021-external-authentication.md
Normal file
71
docs/architecture/adr/0021-external-authentication.md
Normal 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.
|
||||||
78
docs/architecture/adr/0022-page-classification.md
Normal file
78
docs/architecture/adr/0022-page-classification.md
Normal 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).
|
||||||
70
docs/architecture/adr/0023-read-access-audit-trail.md
Normal file
70
docs/architecture/adr/0023-read-access-audit-trail.md
Normal 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 (18–20 AT) and
|
||||||
|
brings volume, latency and retention problems, plus the requirement that no
|
||||||
|
event may be lost. Variant A logs reads of classified pages only (8–10 AT).
|
||||||
|
|
||||||
|
A live editing session is the volume hazard: Yjs sync means continuous
|
||||||
|
traffic per open document.
|
||||||
|
|
||||||
|
## Decision
|
||||||
|
|
||||||
|
**Variant A: read events are recorded only for pages with
|
||||||
|
`classification = VS_NFD`.** Requires ADR 0022.
|
||||||
|
|
||||||
|
1. **All read channels are instrumented**, or the feature is worthless:
|
||||||
|
SPA page fetch, public API GET, attachment download, export, no-JS
|
||||||
|
shell, collab WS join.
|
||||||
|
2. **A dedup window** (session + page + channel within N minutes = one
|
||||||
|
event) keeps Yjs sync from flooding the trail. The recorded event states
|
||||||
|
that it represents a window, so the evidence is not overread.
|
||||||
|
3. **Its own table**, with time partitioning and its own retention period —
|
||||||
|
independent of `audit_log`, because volume, purpose and legal basis all
|
||||||
|
differ.
|
||||||
|
4. **Failure is not silent.** `AuditService` swallows write failures by
|
||||||
|
design; for classified reads a lost event is a gap in evidence, so the
|
||||||
|
behaviour is either hard failure or an explicitly documented
|
||||||
|
degradation. Which one is decided in #222 and stated in the security
|
||||||
|
documentation.
|
||||||
|
5. **Switchable, with a written purpose limitation.** Off means nothing is
|
||||||
|
written anywhere; a startup log line states the trail is off so a gap is
|
||||||
|
never ambiguous.
|
||||||
|
6. **Variant B is rejected**, and the rejection is recorded rather than
|
||||||
|
left open: unbounded volume, the no-loss requirement, and a purpose
|
||||||
|
limitation that is much harder to defend.
|
||||||
|
|
||||||
|
## Consequences
|
||||||
|
|
||||||
|
- The scope limit is the feature's strongest argument in the works-council
|
||||||
|
discussion at the customer: only classified content is observed.
|
||||||
|
- Reads of unclassified content are not evidenced. Deliberate, and it goes
|
||||||
|
into the residual-risk list.
|
||||||
|
- The collab WS join is the awkward channel: authorization there is
|
||||||
|
token-only (signature plus `pageId` match) and the collab server has no
|
||||||
|
permission context. Either the event carries what the token asserts, or
|
||||||
|
the api emits it at token issuance. #222 decides and documents; the
|
||||||
|
choice affects what the trail can prove about live sessions.
|
||||||
|
- Retention and partition maintenance are operational obligations that
|
||||||
|
must ship with the feature, not after it.
|
||||||
|
- Classification at read time is stored with the event: a later
|
||||||
|
reclassification must not rewrite history.
|
||||||
|
|
||||||
|
## Implementing issues
|
||||||
|
|
||||||
|
#222 (instrumentation), #223 (dedup window), #224 (table, retention,
|
||||||
|
partitioning), #225 (switch + purpose limitation). Depends on #204/#205.
|
||||||
@ -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).
|
||||||
61
docs/architecture/adr/0025-plugin-trust-model.md
Normal file
61
docs/architecture/adr/0025-plugin-trust-model.md
Normal 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 8–10 AT and only _manages_ the risk.
|
||||||
|
|
||||||
|
## Decision
|
||||||
|
|
||||||
|
1. **Short term: hard, verifiable off-switch.** `plugins.enabled = false`
|
||||||
|
makes every plugin surface answer 404 — manifests, assets, the frame
|
||||||
|
route, install/uninstall, and the per-pond toggles — following the
|
||||||
|
established pattern of `api.enabled` and `mcp.enabled`. Off is part of
|
||||||
|
the VS-NfD reference configuration.
|
||||||
|
2. **Documents stay readable with plugins off.** An existing plugin block
|
||||||
|
renders its declared `fallback`, never an error. Disabling a feature must
|
||||||
|
not damage content.
|
||||||
|
3. **Medium term: hash pinning, not code signing.** A SHA-256 over the
|
||||||
|
bundle in the manifest, an allowlist of id + pinned hash in
|
||||||
|
`instance_settings`, verification on install and on every load, failing
|
||||||
|
closed. A version bump requires an explicit re-pin.
|
||||||
|
4. **Signing is deliberately rejected for now**, with its reason on the
|
||||||
|
record: no signing identity is available. Should a legal entity exist
|
||||||
|
later, signing becomes an amendment to this ADR, not a new discovery.
|
||||||
|
5. **The sandbox remains the containment mechanism.** Hash pinning answers
|
||||||
|
"is this the reviewed code", not "what may it do". Both are needed and
|
||||||
|
neither substitutes for the other.
|
||||||
|
6. **Network allowlisting for plugins stays unscheduled**, consistent with
|
||||||
|
the existing project decision; in the VS-NfD profile plugins are off, so
|
||||||
|
it is not the binding constraint.
|
||||||
|
|
||||||
|
## Consequences
|
||||||
|
|
||||||
|
- The offer stage can answer the code-execution question with a switch and
|
||||||
|
a test, without waiting for #232.
|
||||||
|
- Vendored third-party plugin code (drawio 30.3.6 under
|
||||||
|
`packages/plugins/drawio/vendor/`) is part of our supply chain and
|
||||||
|
appears in the SBOM (#202). It loads no external editor URL — verified —
|
||||||
|
and CSP would block it if it tried.
|
||||||
|
- Hash pinning makes plugin updates a deliberate act, which is the intended
|
||||||
|
friction.
|
||||||
|
- The plugin ecosystem stays small by construction. Accepted.
|
||||||
|
|
||||||
|
## Implementing issues
|
||||||
|
|
||||||
|
#200 (hard off-switch), #232 (allowlist + hash pinning).
|
||||||
53
docs/architecture/adr/0026-backup-target-restriction.md
Normal file
53
docs/architecture/adr/0026-backup-target-restriction.md
Normal 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).
|
||||||
135
docs/vs-nfd/00-analyse-auftrag.md
Normal file
135
docs/vs-nfd/00-analyse-auftrag.md
Normal 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.
|
||||||
467
docs/vs-nfd/10-ist-aufnahme.md
Normal file
467
docs/vs-nfd/10-ist-aufnahme.md
Normal 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** 2–3 AT · **L** ≥ 4 AT.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 1. Management-Zusammenfassung
|
||||||
|
|
||||||
|
Dorfteich ist näher am Ziel als bei einem Wiki dieser Größe zu erwarten
|
||||||
|
wäre — aber aus einem Grund, der genau benannt werden muss: Es _erbringt_
|
||||||
|
kaum Sicherheitsgrundfunktionen, weil es sie schlicht nicht hat. Keine
|
||||||
|
Inhalts- oder Backup-Verschlüsselung, kein eigenes MFA, keine Krypto
|
||||||
|
jenseits von Argon2id-Credential-Hashing und kurzlebigen Token-Signaturen.
|
||||||
|
Das ist unter §52 VSA die _richtige_ Architektur, nicht eine Lücke — und
|
||||||
|
so ist es zu vertreten.
|
||||||
|
|
||||||
|
Die drei größten Brocken:
|
||||||
|
|
||||||
|
1. **Authentisierung liegt vollständig in der Anwendung.** Es gibt keine
|
||||||
|
OIDC-Anbindung (nur den vorbereiteten `UserIdentity.provider`-Slot) und
|
||||||
|
keinen Weg, die lokale Anmeldung abzuschalten. Solange das so ist,
|
||||||
|
_ist_ Dorfteich Träger einer Sicherheitsgrundfunktion.
|
||||||
|
2. **Es gibt kein Einstufungskonzept.** Seiten haben kein
|
||||||
|
Einstufungsmetadatum, und kein Ausgabekanal kennt einen Aufdruck —
|
||||||
|
Print-CSS fehlt sogar komplett. Kennzeichnung ist die eine
|
||||||
|
VS-NfD-Anforderung, die niemand außer der Anwendung erfüllen kann.
|
||||||
|
3. **Der Offline-/Airgap-Betrieb ist plausibel, aber unbelegt.** Keine
|
||||||
|
Telemetrie, keine CDNs, keine Update-Checks, Fonts self-hosted, drawio
|
||||||
|
vendored — nachweislich. Getestet wurde es nie, und alle Images hängen
|
||||||
|
an Tags statt an Digests (`gotenberg/gotenberg:8` ist ein gleitender
|
||||||
|
Major-Tag).
|
||||||
|
|
||||||
|
Nebenbefunde mit Substanz: Rohdokument-Bytes jedes Im-/Exports bleiben
|
||||||
|
unbefristet in `conversion_jobs` liegen, der Volltextindex enthält
|
||||||
|
Papierkorb-Inhalte, getrashte Teiche werden nie endgültig gelöscht, und
|
||||||
|
Seiteninhalte landen als Yjs-Kopie in der IndexedDB des Endgeräts.
|
||||||
|
|
||||||
|
**42 Befunde: 5 BLOCKIEREND, 21 ANPASSEN, 4 UNKLAR, 12 OK.**
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 2. Befundtabelle
|
||||||
|
|
||||||
|
Sortiert nach Bewertung. Die Spalte „Handlungsbedarf" nennt in Klammern
|
||||||
|
das Issue aus `31-issue-entwurf.md`, das den Befund adressiert.
|
||||||
|
|
||||||
|
### BLOCKIEREND
|
||||||
|
|
||||||
|
| Nr. | Thema | Fundort | Ist-Zustand | Handlungsbedarf | Aufw. |
|
||||||
|
| ---- | ---------------------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ----------------------------------------------------------------------------------------- | ----- |
|
||||||
|
| I-01 | Fremdauthentisierung fehlt | `apps/api/prisma/schema.prisma:562–577`; kein `oidc`-Treffer in `apps/api/src` | `UserIdentity.provider` ist als `"password"` heute / `"oidc:<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:263–301` | `Page` trägt Titel, Slug, Baumposition, Zeitstempel — kein Feld, das ein Schutzniveau ausdrücken könnte. Labels sind kein Ersatz (I-03a im Detailteil). | Enum-Feld + Vererbung + Herabstufungsrecht (#204, #205) | M |
|
||||||
|
| I-04 | Kein Einstufungsaufdruck in Ausgaben | `apps/web/src` (kein `@media print`), `apps/api/src/import-export/pdf-html.ts:76–79`, `pandoc.converter.ts`, `export-markdown.ts`, `apps/api/src/public/html-shell.ts` | Kein Kanal kennt einen Aufdruck. Print-CSS fehlt **vollständig** — Browserdruck reproduziert die Bildschirmansicht inkl. Navigation. PDF hat einen Kopf **einmalig** statt je Seite; pandoc läuft ohne Reference-Doc, also ohne Kopf-/Fußzeile. | Sieben Kanal-Issues (#206–#212) | L |
|
||||||
|
| I-05 | Airgap unverifiziert, Images nur per Tag | `deploy/compose/docker-compose.yml:186,206,221,237`; `.gitea/workflows/ci.yml` | `postgres:17.5-alpine`, `pandoc/core:3.6`, `gotenberg/gotenberg:8`, `caddy:2.10-alpine` — Tags, keine Digests; `gotenberg:8` gleitet über Minor/Patch. Kein Mirror-Verfahren, kein Offline-Update-Pfad, kein Testlauf. | Digest-Pinning, Mirror, Offline-Build, isolierter Testlauf, Update-Pfad (#203, #218–#221) | L |
|
||||||
|
|
||||||
|
### ANPASSEN
|
||||||
|
|
||||||
|
| Nr. | Thema | Fundort | Ist-Zustand | Handlungsbedarf | Aufw. |
|
||||||
|
| ---- | ---------------------------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------- | ----- |
|
||||||
|
| I-06 | Ein Secret für zwei Zwecke | `packages/shared/src/env.ts:55,142`; `apps/collab/src/index.ts:46`; `apps/api/src/pages/pages.service.ts:383`; `apps/api/src/notifications/digest.service.ts:161`; `notifications.controller.ts:51` | `COLLAB_TOKEN_SECRET` signiert Collab-Tokens **und** Unsubscribe-Tokens. Teil-Trennung existiert textuell über ein `PURPOSE`-Präfix (`unsubscribe-token.ts:14`), nicht über Schlüssel. | HKDF-Subkeys je Zweck (#188) | L |
|
||||||
|
| I-07 | Eigenbau-HMAC-JWT | `packages/shared/src/token-crypto.ts:1,37,74` | Kompakter HS256-JWT handgebaut auf `node:crypto`; nur HS256, Signatur in konstanter Zeit vor jedem Lesen ungeprüfter Felder. Sorgfältig — aber handgeschriebene Krypto in der Vertrauensgrenze. Grund ist dokumentiert: identischer Code in CommonJS-api und ESM-collab. Weder `jose` noch `jsonwebtoken` ist Dependency. | Ersatz durch `jose`, Cross-Runtime-Test als Zaun (#188) | L |
|
||||||
|
| I-08 | CSRF lässt fehlende Header durch | `apps/api/src/auth/auth.guard.ts:108–112` | `assertSameOrigin` nimmt `origin ?? referer`; fehlen **beide**, kehrt die Prüfung ohne Entscheidung zurück. Kommentar nennt die Absicht (Nicht-Browser-Clients; SameSite als eigentliche Abwehr). Fail-open bleibt es trotzdem. | Fail-closed mit dokumentierter Ausnahme für Bearer-Clients (#189) | S |
|
||||||
|
| I-09 | Session 30 Tage, hart kodiert | `apps/api/src/auth/sessions.service.ts:8,31,50`; `apps/api/src/auth/auth.guard.ts:58` | `SESSION_TTL_MS = 30 * 24 * 60 * 60 * 1000 // sliding 30 days`, bei jeder Berührung erneuert; Cookie-`maxAge` gleich. Kein Idle-Timeout — `lastSeenAt` wird geschrieben, aber nie als Grenze ausgewertet. Nicht konfigurierbar. | Absolute + Idle-Grenze konfigurierbar, Default deutlich darunter (#190) | M |
|
||||||
|
| I-10 | Feed-Token im Query-Parameter | `apps/api/src/public/public.controller.ts:28,43`; `feed.service.ts:25` | Langlebiges Lese-Credential als `?token=…` — landet in Proxy-Logs, Historie, Referrer. Speicherung ist gehasht (`feed-tokens.service.ts:73`), das Problem ist der Transport. Kein Instanzschalter für Feeds. | Token aus dem Query holen oder `feeds.enabled` (#191) | M |
|
||||||
|
| I-11 | Backup-Ziel frei wählbar | `apps/api/src/settings/instance-settings.service.ts` (`backup.nextcloud.baseUrl`); `apps/api/src/backup/backup-target.service.ts:3,65`; `apps/api/src/admin/backup-admin.service.ts:27,112,146`; `apps/backup/src/mirror.ts` | WebDAV-Ziel ist als URL validiert, aber auf keinen Host beschränkt; zweiter Remote-Pfad ist der rsync-Mirror. Keine Allowlist, kein Deploy-Kill-Switch. Wer Site-Admin hat, kann eine Vollkopie der Instanz umleiten. | Deploy-Allowlist, leere Liste = nur lokal (#192) | M |
|
||||||
|
| I-12 | Kein Pond-Purge | `apps/api/prisma/schema.prisma:175,186,277,432,553`; `apps/api/src/trash/trash.service.ts` | `Pond.deletedAt` bildet den Teich-Papierkorb; endgültiges Löschen gibt es **nur für Seiten** (`purgeNow:96`, `purgeDuePages:104`, `purgePage:121`). `Page.pond`, `Attachment.pond` und `Label.pond` haben keine `onDelete`-Aktion → Prisma-Default `Restrict` blockt das Löschen ohnehin. Getrashte Teiche bleiben unbegrenzt liegen. | Pond-Purge über alle abhängigen Tabellen (#193) | M |
|
||||||
|
| I-13 | Kein Orphan-File-Sweep | `apps/api/prisma/schema.prisma:530–538`; registrierte Jobs in `apps/api/src/*/*.module.ts` | Das Schema sagt es selbst: der `pageId`-Link wird nicht angefasst, wenn ein Bild später aus dem Inhalt entfernt wird — „an orphan-file sweep … is a separate future maintenance job"; „`deletedAt` stays unused for now". Von fünf registrierten Jobs (`version-thinning`, `page-compaction`, `trash-purge`, `data-export-purge`, `notification-digest`) ist keiner der Sweep. | Sweep implementieren, `deletedAt` nutzen oder entfernen (#194) | M |
|
||||||
|
| I-14 | Papierkorb im Suchindex | `apps/api/src/search/postgres-search.provider.ts:41,73,142–143` | Der gewichtete `tsvector` liegt auf `page_content_cache.search_vector`; getrashte Inhalte bleiben **im Index** und werden nur query-seitig ausgeblendet (`p.deleted_at IS NULL`, `po.deleted_at IS NULL`). Ein künftiger Abfragepfad ohne diesen Filter leakt Inhalt. | Vektor beim Trashen leeren, Query-Filter als zweite Ebene behalten (#195) | M |
|
||||||
|
| I-15 | Keine Retention für `audit_log` | `apps/api/prisma/schema.prisma:74–91`; `apps/api/src/audit/audit.service.ts` | Persistenter Trail (`AuditEntry`, Issue #86) wächst unbegrenzt; kein Pruning-Job registriert. | Konfigurierbare Aufbewahrung + Job (#196) | S |
|
||||||
|
| I-16 | Keine Security-Response-Header | `apps/api/src/main.ts`; `apps/api/package.json` | `helmet` ist keine Dependency und kommt in `apps/api/src` nicht vor. `app.enableCors()` wird **nicht** aufgerufen — CORS ist damit implizit restriktiv (keine CORS-Header, Browser blockt cross-origin), aber nirgends als Entscheidung festgehalten. Der Web-Tier hat eine strenge CSP; die api-Antworten selbst sind die Lücke. | HSTS, `X-Content-Type-Options`, `Referrer-Policy`, Frame-/Permissions-Policy, CORS explizit (#197) | S |
|
||||||
|
| I-17 | Kein SBOM, kein Lizenzreport | `.gitea/workflows/ci.yml`; kein `sbom`/`syft`/`cyclonedx` in `.gitea/` oder `package.json` | CI macht install, build, lint, typecheck, test, `i18n:check`. Kein Lieferketten-Artefakt. (Die Lizenzlage selbst ist unkritisch, s. I-37.) | CycloneDX je Image + Workspace, Lizenzreport als Artefakt (#202) | M |
|
||||||
|
| I-18 | Keine Attachment-Integritätshashes | `apps/api/prisma/schema.prisma:541–559`; `apps/api/src/files/file-storage.service.ts:21,25` | `Attachment` hat keine Checksumme; die einzigen Hashes im Schema sind Credential-/Token-Hashes. Dateien liegen unter `UPLOADS_DIR/<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:746–784`; `apps/api/src/import-export/import-export.module.ts:62` | `ConversionJob.input`/`result` sind die **rohen Dokumentbytes** jedes Im-/Exports. Das Schema nennt sie „transient, not the durable copy" und verweist auf „a later maintenance job". Registriert ist nur `data-export-purge`, und `expiresAt` ist laut Schema „Null for every other job kind, whose result never expires". Für `export_docx`/`import_docx` bleiben die Bytes damit unbefristet. | Pruning für alle Job-Arten; im Löschkonzept (#229) ausweisen | M |
|
||||||
|
| I-23 | `mail_outbox` ohne Retention, mit Seitentiteln | `apps/api/prisma/schema.prisma:683–697`; `apps/api/src/notifications/digest.service.ts:16,125,146` | Die Outbox speichert `textBody`/`htmlBody` dauerhaft; kein Pruning-Job. Digest-Mails enthalten **Seitentitel** und Akteursnamen (`- ${page.pageTitle}: … (${actorNames})`). Transaktionsmails selbst tragen keinen Inhalt (`mail-templates.ts:25–44`: Anrede + i18n-Text + Link). | Retention für `mail_outbox`; im Löschkonzept ausweisen | S |
|
||||||
|
| I-24 | Slug-Residuum nach Purge | `apps/api/prisma/schema.prisma:441–461` | `PageLink.fromPage` kaskadiert, `toPage` ist `onDelete: SetNull` — nach dem Purge bleibt in fremden Seiten eine Zeile mit `target_slug` der gelöschten Seite (`toPageId` genullt). Der Titel/Slug einer eingestuften Seite kann selbst eingestufte Information sein. | Bewusst entscheiden: Zeilen mitlöschen oder als Restrisiko (#231) führen | S |
|
||||||
|
| I-25 | Inhaltskopie auf dem Endgerät | `apps/web/src/editor/use-collab-provider.ts:32,76,134–141,184` | Jede geöffnete Seite wird als Yjs-Dokument in die IndexedDB gespiegelt (`dorfteich-page-<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:7–10` | `"engines": { "node": ">=22" }` ist eine Untergrenze, kein Pin; `packageManager: "pnpm@11.9.0"` ist exakt. Für einen reproduzierbaren Offline-Build fehlt die Node-Festlegung. | Node-Version pinnen und im Build-Verfahren nennen (#219) | S |
|
||||||
|
|
||||||
|
### UNKLAR
|
||||||
|
|
||||||
|
| Nr. | Thema | Fundort | Ist-Zustand | Warum unklar |
|
||||||
|
| ---- | --------------------------------------------------------- | -------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ |
|
||||||
|
| I-27 | Herkunft/Maintainer-Status der Abhängigkeiten | `pnpm-lock.yaml` (1380 aufgelöste Pakete, 366 davon prod) | Lizenzlage geprüft (I-37), Jurisdiktion nicht. | Die npm-Registry weist keine Rechtsträger oder Jurisdiktionen aus. Eine belastbare Antwort braucht Recherche je Paket; für die Kernpakete (React/Meta, TipTap/überwiegend DE-Umfeld, Yjs, NestJS, Prisma) ist sie machbar, für 366 Pakete nicht mit vertretbarem Aufwand. Vorschlag: Kernabhängigkeiten einzeln, Rest über den SBOM (#202) offenlegen. |
|
||||||
|
| I-28 | Verhalten ohne Internetzugang | s. I-05, I-31 | Kein Codepfad baut eine Verbindung nach außen auf außer Sidecars, SMTP und Backup-Ziel. | Ob eine egress-geblockte Instanz **vollständig** funktioniert, ist eine Laufzeitfrage (Container-Pulls, DNS, Zertifikatsprüfungen, SMTP-Timeouts). Nur durch den Testlauf (#220) beantwortbar. |
|
||||||
|
| I-29 | Wirksamkeit der Sicherheitsschalter bei laufendem Betrieb | `apps/api/src/settings/instance-settings.service.ts` | Der Settings-Cache der api ist in-process; ein DB-Write an `instance_settings` wirkt erst nach api-Neustart. | Für `api.enabled`/`mcp.enabled` ist das dokumentiert und betrieblich beherrschbar. Für einen künftigen `plugins.enabled` (#200) oder einen Auth-Schalter (#216) muss geklärt werden, ob „Schalter umgelegt" auch „sofort wirksam" heißt — sonst ist die Referenzkonfiguration zeitweise unwahr. |
|
||||||
|
| I-30 | Vollständigkeit der Ausgabekanal-Liste | `apps/api/src/import-export/`, `apps/api/src/public/`, `apps/api/src/public-api/`, `apps/api/src/files/` | Gefunden wurden: Web-Ansicht, Browserdruck, PDF (Gotenberg), DOCX/ODT (pandoc), Markdown-Einzeldownload, Pond-ZIP, Obsidian-Vault-Export, Atom-Feeds, Public-API, MCP, Suchergebnisse, No-JS-Shell, Anhang-Download, Datenexport (DSGVO), Backup. | Die Liste ist durch Codelesen entstanden, nicht durch eine erschöpfende Routen-Enumeration. Der vorhandene Route-Enumeration-Test des Permission-Modells wäre die Grundlage, das mechanisch zu belegen — empfohlen als Teil von #211. |
|
||||||
|
|
||||||
|
### OK — Stärken, die zu belegen sind
|
||||||
|
|
||||||
|
| Nr. | Thema | Fundort | Ist-Zustand |
|
||||||
|
| ---- | ------------------------------------ | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
||||||
|
| I-31 | Keine ausgehenden Verbindungen | `apps/api/src/health/readiness.service.ts:83,102`; `apps/api/src/import-export/gotenberg.renderer.ts:59,85`; `pandoc.converter.ts:102,121`; `apps/api/src/mail/smtp-config.service.ts:54,71`; `apps/backup/src/remote.ts`, `mirror.ts` | **Jeder** ausgehende Aufruf im Code geht an einen internen Sidecar (`PANDOC_URL`, `GOTENBERG_URL`), an SMTP oder an das konfigurierte Backup-Ziel. Keine Telemetrie, keine Update-Checks, keine Analytics, kein Crash-Reporting, keine Lizenzprüfung, keine Link-Vorschauen, kein oEmbed, keine Avatar-Dienste, keine externen Karten, Fonts self-hosted (ADR 0016). Das ist der stärkste Einzelbefund des ganzen Berichts. |
|
||||||
|
| I-32 | Passwort-Hashing | `apps/api/src/users/password.ts:1,9–25` | Argon2id über die `argon2`-Bibliothek, mit `needsRehash`-Pfad. Kein Eigenbau. |
|
||||||
|
| I-33 | Zentrales Berechtigungsmodell | `apps/api/src/permissions/`; `apps/api/src/mcp/mcp.service.ts:232–253` | Default-closed, deny-wins, zentral durchgesetzt, mit Route-Enumeration-Test. Der MCP-Endpoint **divergiert nicht**: er ruft `PermissionService.hasPondRole` / `canAccessPage`; eigenständig sind nur seine Schalter. Korrigiert eine offene Frage des Maßnahmenplans. |
|
||||||
|
| I-34 | Volltextsuche ohne externe Engine | `apps/api/src/search/postgres-search.provider.ts:40–41` (ADR 0010) | Postgres-`tsvector`. Kein Elasticsearch, kein zweiter Datenhalter, keine weitere Netzwerkgrenze. |
|
||||||
|
| I-35 | Single-Tenant | `apps/api/prisma/schema.prisma` (33 Modelle, kein `Tenant`/`Organization`) | Teiche sind Container innerhalb _einer_ Instanz. Passt zur empfohlenen Betriebsform „eine Instanz je Einstufungsniveau". |
|
||||||
|
| I-36 | Service Worker cached keine Inhalte | `apps/web/vite.config.ts:12–20` | VitePWA precacht ausschließlich Build-Assets; **kein** Runtime-Caching, `navigateFallbackDenylist` schließt `/api` und `/collab` aus. API-Antworten landen nie im SW-Cache. |
|
||||||
|
| I-37 | Lizenzlage durchweg permissiv | `pnpm licenses list --prod` | 366 Produktionspakete, 11 verschiedene Lizenzen: MIT 301, ISC 24, Apache-2.0 22, BSD-3-Clause 5, BlueOak-1.0.0 4, BSD-2-Clause 4, MIT-0 2, Python-2.0 1, CC0-1.0 1, 0BSD 1, `(MPL-2.0 OR Apache-2.0)` 1. **Kein GPL/AGPL, kein Copyleft mit Verteilungsfolgen.** Projektlizenz MIT. |
|
||||||
|
| I-38 | Log-Redaction | `apps/api/src/app.module.ts:84–93`; `apps/collab/src/logger.ts` | pino-JSON auf stdout, `LOG_LEVEL` konfigurierbar, `redact: { paths: ['req.headers.authorization','req.headers.cookie'], remove: true }`. Kein Datei- oder Syslog-Transport — Weiterleitung ist Sache der Container-Runtime, was für SIEM-Anbindung die richtige Arbeitsteilung ist. |
|
||||||
|
| I-39 | Public-API und MCP standardmäßig aus | `apps/api/src/settings/instance-settings.service.ts` (`api.enabled` / `mcp.enabled`, je `.default(false)`); `public-api.guard.ts:64`; `mcp.controller.ts:50,84` | Beide Oberflächen antworten im ausgeschalteten Zustand mit 404 (Existenz verstecken), zusätzlich muss jeder Teich einzeln zustimmen. Vorbildliches Muster für I-02 und I-19. |
|
||||||
|
| I-40 | drawio ist vendored | `packages/plugins/drawio/vendor/drawio-30.3.6/`; `packages/plugins/drawio/manifest.json` | Kein externer Editor-URL: das Manifest nennt `kind: code`, `permissions: ["blockData","ui"]`, `fallback`, `license: Apache-2.0`; `homepage` ist reines Metadatum. Der drawio-Webapp-Baum liegt im Repo. Korrigiert eine offene Frage des Maßnahmenplans (kein Ausschlusskriterium). |
|
||||||
|
| I-41 | Entzug beendet laufende Sitzungen | `apps/collab/src/index.ts:62–70`; `apps/collab/src/server.ts:108–125`; `apps/api/src/pages/pages.service.ts:57,384` | Collab-Tokens leben **60 Sekunden** (`COLLAB_TOKEN_TTL_SECONDS = 60`); die WS-Ebene autorisiert nur gegen Signatur und `claims.pageId === documentName`, ohne Permission-Kontext. Das ist tragfähig, weil die api bei jedem Token neu prüft und ein Grant-Entzug offene Verbindungen sofort per `pg_notify`-Access-Listener schließt (#39). Für den Lesetrail folgt daraus: die api ist der richtige Emissionsort, mit natürlicher Minutengranularität. |
|
||||||
|
| I-42 | Seiten-Purge ist vollständig | `apps/api/src/trash/trash.service.ts:121–144`; Kaskaden in `apps/api/prisma/schema.prisma:114,313,344,361,373,409,469,486` | `purgePage` löscht Anhänge (Datei + Quota), Content-Cache, Update-Log und Watches explizit; Kommentare, Versionen, Mentions, Pending-Contributors, Labels-Zuordnungen und Favoriten hängen an `onDelete: Cascade`. Kindseiten werden bewusst an den Großeltern-Knoten gehoben. Residuen bleiben nur laut I-24. |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 3. Detailbefunde
|
||||||
|
|
||||||
|
### 3.1 Sicherheitsgrundfunktionen
|
||||||
|
|
||||||
|
**(1) Authentisierung.** Es gibt eine eigene Benutzer- und
|
||||||
|
Credential-Haltung: `User` plus `UserIdentity` mit
|
||||||
|
`@@unique([provider, subject])`; `credential` hält den Argon2id-Hash
|
||||||
|
(`schema.prisma:562–577`). Gehasht wird mit `argon2id` über die
|
||||||
|
`argon2`-Bibliothek inklusive `needsRehash` (`users/password.ts:9–25`) —
|
||||||
|
kein Eigenbau, korrektes Verfahren. **Eine OIDC-, SAML- oder
|
||||||
|
LDAP-Anbindung existiert nicht**; ein Suchlauf über `apps/api/src` und
|
||||||
|
`packages/shared/src` findet `oidc` ausschließlich im Schema-Kommentar.
|
||||||
|
Unterstützung für Client-Zertifikate oder vertrauenswürdige
|
||||||
|
Reverse-Proxy-Header ist ebenfalls nicht vorhanden. → I-01, I-02.
|
||||||
|
|
||||||
|
**(2) Session-Verwaltung.** Eigene Implementierung, nicht Framework:
|
||||||
|
`Session` als serverseitige Tabelle, deren `id` der SHA-256-Hash des
|
||||||
|
Session-Tokens ist (`schema.prisma:580–595`, `sessions.service.ts:84`) —
|
||||||
|
ein gestohlener DB-Dump gibt keine gültigen Tokens her. Das Cookie ist
|
||||||
|
`httpOnly`, `sameSite: 'lax'`, `secure` in Produktion
|
||||||
|
(`auth.guard.ts:52–58`). Gültigkeit: gleitende 30 Tage, hart kodiert,
|
||||||
|
ohne Idle-Grenze. → I-09.
|
||||||
|
|
||||||
|
**(3) Kryptographie.** Vollständige Inventur der eigenen Krypto-Aufrufe:
|
||||||
|
|
||||||
|
| Zweck | Verfahren | Fundort |
|
||||||
|
| ------------------------ | --------------------------------- | ------------------------------------------------------- |
|
||||||
|
| Passwörter | Argon2id | `users/password.ts:9–25` |
|
||||||
|
| Session-Ids | SHA-256 über das Token | `auth/sessions.service.ts:84` |
|
||||||
|
| Auth-Tokens (Mail-Flows) | SHA-256 | `auth/auth-tokens.service.ts:55` |
|
||||||
|
| PATs | SHA-256 | `public-api/api-tokens.service.ts:149` |
|
||||||
|
| Feed-Tokens | SHA-256 | `public/feed-tokens.service.ts:73` |
|
||||||
|
| Collab-Tokens | HS256-JWT, handgebaut, TTL 60 s | `shared/token-crypto.ts:1,37,74`; `pages.service.ts:57` |
|
||||||
|
| Unsubscribe-Tokens | HMAC-SHA-256 mit `PURPOSE`-Präfix | `notifications/unsubscribe-token.ts:14,40` |
|
||||||
|
|
||||||
|
**Nirgends werden Inhalte ver- oder entschlüsselt.** Es gibt keine
|
||||||
|
Backup-Verschlüsselung und keine Verschlüsselung in DB oder Dateisystem.
|
||||||
|
Unter §52 VSA ist das die gewollte Arbeitsteilung. Die beiden
|
||||||
|
Befunde betreffen nicht das _Ob_, sondern die Hygiene: ein Secret für zwei
|
||||||
|
Zwecke (I-06) und handgeschriebenes JWT (I-07).
|
||||||
|
|
||||||
|
**(4) Zugriffskontrolle.** Zentral in `apps/api/src/permissions/`,
|
||||||
|
default-closed mit deny-wins, per Guards durchgesetzt und durch einen
|
||||||
|
Route-Enumeration-Test abgesichert. Die geprüften Umgehungskandidaten sind
|
||||||
|
sauber: der Export filtert über `permissions.filterPages`
|
||||||
|
(`export.service.ts:102–108`), die Suche joint auf lebende Seiten und
|
||||||
|
Teiche (`postgres-search.provider.ts:142–143`), der MCP-Endpoint nutzt den
|
||||||
|
zentralen Service (I-33), und die Collab-WS-Ebene ist über kurzlebige
|
||||||
|
Tokens plus Entzugs-Listener abgesichert (I-41). Public-API und MCP sind
|
||||||
|
zusätzlich zweifach gegated (Instanz + Teich) und standardmäßig aus
|
||||||
|
(I-39). Ratenbegrenzung existiert als Decorator-Mechanik
|
||||||
|
(`rate-limit/rate-limit.guard.ts:24`).
|
||||||
|
|
||||||
|
**(5) Integrität.** Keine Prüfsummen auf Anhängen (I-18), keine
|
||||||
|
Signaturen auf Plugin-Bundles (`manifest.json` hat kein Hash-Feld), kein
|
||||||
|
Manipulationsschutz auf dem `audit_log`. Prüfsummen existieren nur als
|
||||||
|
Token-Hashes, also zur Authentisierung, nicht zur Integritätssicherung von
|
||||||
|
Nutzdaten.
|
||||||
|
|
||||||
|
### 3.2 Datenhaltung
|
||||||
|
|
||||||
|
**(6) Seiteninhalte** liegen ausschließlich in Postgres, aber in
|
||||||
|
**fünf** Repräsentationen:
|
||||||
|
|
||||||
|
1. `pages.ydoc_state` (Bytes) — das lebende Yjs-Dokument
|
||||||
|
2. `page_updates` — das Update-Log; gemergt erst bei Compaction
|
||||||
|
3. `page_versions.ydoc_snapshot` — vollständige, selbstständige Snapshots
|
||||||
|
(AUTO/MANUAL/PRE_RESTORE), damit ein Restore nie vom Update-Log abhängt
|
||||||
|
(`schema.prisma:318–347`)
|
||||||
|
4. `page_content_cache` — `plain_text`, `markdown`, `html`, `outline`
|
||||||
|
(jsonb) — vier abgeleitete **Klartext**-Formen, bei jedem Save
|
||||||
|
erneuert (`schema.prisma:392–411`)
|
||||||
|
5. `page_content_cache.search_vector` — der gewichtete `tsvector`
|
||||||
|
|
||||||
|
Für ein Löschkonzept ist Punkt 4 der wichtigste: der Klartext jeder Seite
|
||||||
|
liegt vierfach vor, unabhängig vom Yjs-Zustand.
|
||||||
|
|
||||||
|
**(7) Anhänge** liegen im Dateisystem unter
|
||||||
|
`UPLOADS_DIR/<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:746–784` | **unbefristet** außer für Datenexport-Jobs (I-22) |
|
||||||
|
| `mail_outbox.text_body` / `.html_body` — Digest-Mails mit Seitentiteln | `schema.prisma:683–697` | **unbefristet** (I-23) |
|
||||||
|
| IndexedDB je geöffneter Seite auf dem Endgerät | `use-collab-provider.ts:76` | bis zum Verlassen, bei unsynchronisierten Änderungen länger (I-25) |
|
||||||
|
| `comments`, `notifications` | `schema.prisma:100–164` | an Seite/Nutzer gekoppelt, kaskadierend |
|
||||||
|
| Backups | `apps/backup/`, ADR 0015 | Retention konfigurierbar |
|
||||||
|
| PWA-Service-Worker | `vite.config.ts:12–20` | **enthält keine Inhalte** (I-36) |
|
||||||
|
|
||||||
|
Thumbnails, Vorschaubilder oder serverseitige Temp-Dateien gibt es
|
||||||
|
nicht: eine Suche nach `tmpdir`/`mkdtemp`/`/tmp` in `apps/api/src` bleibt
|
||||||
|
leer — die Konverter arbeiten über HTTP gegen die Sidecars.
|
||||||
|
|
||||||
|
**(10) Revisionshistorie:** `page_versions` mit vollständigen Snapshots,
|
||||||
|
Trigger-Enum (AUTO/MANUAL/PRE_RESTORE), `contributor_ids` als Array;
|
||||||
|
angelegt von collab bei Sitzungsende und im Bearbeitungsintervall, von der
|
||||||
|
api auf Zuruf. Ausgedünnt durch den registrierten Job
|
||||||
|
`version-thinning`.
|
||||||
|
|
||||||
|
### 3.3 Löschen und Vernichtung
|
||||||
|
|
||||||
|
**(11) Seiten** werden zweistufig gelöscht: `deletedAt` setzt den
|
||||||
|
Papierkorb (ADR 0013), der Job `trash-purge` löscht nach Retention
|
||||||
|
endgültig, zusätzlich gibt es „Purge einzeln" (`trash.controller.ts:26`).
|
||||||
|
|
||||||
|
**(12) Vom Purge erfasst** (I-42): Anhänge inklusive Datei und
|
||||||
|
Quota-Rückgabe, Content-Cache (und damit der Suchvektor), Update-Log,
|
||||||
|
Watches — explizit; Kommentare, Versionen, Mentions,
|
||||||
|
Pending-Contributors, Label-Zuordnungen und Favoriten über
|
||||||
|
`onDelete: Cascade`. Kindseiten werden bewusst an den Elternknoten der
|
||||||
|
gelöschten Seite gehoben.
|
||||||
|
|
||||||
|
**(13) Rückstandsfrei? Nein — vier konkrete Reste:**
|
||||||
|
|
||||||
|
1. `page_links`-Zeilen fremder Seiten behalten den `target_slug` der
|
||||||
|
gelöschten Seite, `to_page_id` wird genullt (I-24).
|
||||||
|
2. `conversion_jobs` behalten die Rohbytes jedes Exports dieser Seite
|
||||||
|
(I-22).
|
||||||
|
3. `mail_outbox` behält Digest-Mails mit dem Seitentitel (I-23).
|
||||||
|
4. IndexedDB-Kopien auf Endgeräten, die die Seite offline geöffnet hatten
|
||||||
|
(I-25).
|
||||||
|
|
||||||
|
Für **Teiche** gibt es überhaupt kein endgültiges Löschen (I-12) — und
|
||||||
|
die FK-Restriktionen würden es derzeit auch blockieren. Backups sind ein
|
||||||
|
fünfter, gewollter Rest mit eigener Retention.
|
||||||
|
|
||||||
|
### 3.4 Ausgehende Verbindungen
|
||||||
|
|
||||||
|
**(14)–(15)** Die vollständige Liste der Stellen, an denen die Anwendung
|
||||||
|
nach außen geht, ist in I-31 aufgeführt: zwei interne Sidecars (pandoc,
|
||||||
|
Gotenberg), SMTP, Backup-Ziel (WebDAV und/oder rsync). **Sonst nichts.**
|
||||||
|
Keine Update-Checks, keine Telemetrie, kein Analytics, kein
|
||||||
|
Crash-Reporting, keine Lizenzprüfung, keine Link-Vorschauen, kein oEmbed,
|
||||||
|
keine Avatar-Dienste, keine externen Karten, Schriften oder Skripte.
|
||||||
|
Die gepinnten Fremdmodule laden zur Laufzeit nichts nach: drawio ist
|
||||||
|
vendored (I-40), Mermaid und Excalidraw sind gebündelte
|
||||||
|
Workspace-Pakete, und die CSP `default-src 'self'` würde einen Nachlader
|
||||||
|
ohnehin blocken.
|
||||||
|
|
||||||
|
**(16) Backups:** Ziel ist frei konfigurierbar, ohne Allowlist; nicht
|
||||||
|
verschlüsselt (I-11). Letzteres ist gewollt (§52 VSA), Ersteres nicht.
|
||||||
|
|
||||||
|
**(17) Betrieb ohne Internet:** Codeseitig nichts entgegen, empirisch
|
||||||
|
unbelegt (I-28). Offene Einzelfrage mit Betriebsrelevanz: ohne
|
||||||
|
ausgehendes SMTP funktionieren Registrierung, Verifikation und
|
||||||
|
Passwort-Reset nicht — was mit `auth.local.enabled = false` (#216)
|
||||||
|
zusammenfällt und dort mitentschieden werden sollte.
|
||||||
|
|
||||||
|
### 3.5 Ausgabekanäle
|
||||||
|
|
||||||
|
**(18)** Gefundene Wege, auf denen Inhalte die Anwendung verlassen
|
||||||
|
(Vollständigkeit s. I-30): Web-Ansicht (SPA), Browserdruck, PDF über
|
||||||
|
Gotenberg, DOCX/ODT über pandoc, Markdown-Einzeldownload
|
||||||
|
(`pages.controller.ts:122`), Pond-ZIP (`export.service.ts:68`),
|
||||||
|
Obsidian-Vault-Export, Atom-Feeds, Public-API, MCP, Suchergebnisse
|
||||||
|
(Snippets), No-JS-Shell (`public/html-shell.ts`), Anhang-Download
|
||||||
|
(`files.controller.ts:51,71`), DSGVO-Datenexport, Backup.
|
||||||
|
|
||||||
|
**(19) Metadatenmodell:** `Page` trägt Titel, Slug, `parentId`, `sortKey`,
|
||||||
|
Zeitstempel, Ersteller — nichts, wo eine Einstufung hineinpasste (I-03).
|
||||||
|
Der naheliegende Ersatz „Labels" trägt nicht, und zwar aus vier
|
||||||
|
belegbaren Gründen (**I-03a**):
|
||||||
|
|
||||||
|
- `Label` ist **teich-gebunden** (`pondId`, `schema.prisma:423–437`) —
|
||||||
|
dieselbe Einstufung wäre in jedem Teich ein anderes Objekt ohne
|
||||||
|
instanzweite Bedeutung.
|
||||||
|
- Labels sind von jedem Editor **bearbeitbar**.
|
||||||
|
- Labels **vererben nicht** im Seitenbaum.
|
||||||
|
- Labels **verlassen die Anwendung nie**: `export.service.ts:98–105` lädt
|
||||||
|
`labelIds` ausschließlich für `permissions.filterPages`; kein
|
||||||
|
Export-Pfad schreibt sie in die Ausgabe. Das korrigiert eine offene
|
||||||
|
Frage des Maßnahmenplans mit einem klaren Nein.
|
||||||
|
|
||||||
|
**(20) Eingriffspunkte für einen Kopf-/Fußaufdruck** — konkret:
|
||||||
|
|
||||||
|
| Kanal | Eingriffsstelle | Zustand |
|
||||||
|
| --------------- | -------------------------------------------------------------------------------------------------------------------------- | --------------------------------------------------------------------------------------------------- |
|
||||||
|
| Web-Ansicht | Seiten-Layout der SPA | kein Aufdruck-Element |
|
||||||
|
| Browserdruck | **fehlt komplett** — kein `@media print` in `apps/web/src` | Neubau nötig, `@page`-Randboxen für Wiederholung je Blatt |
|
||||||
|
| PDF | `import-export/pdf-html.ts:76–79` (Dokumentkopf, einmalig) + Gotenberg-Footer-Template (`:31`, liefert heute Seitenzahlen) | Mechanik für „je Seite" existiert, wird für den Aufdruck nicht genutzt |
|
||||||
|
| DOCX/ODT | `import-export/pandoc.converter.ts` | **kein** `--reference-doc` — es gibt keine Kopf-/Fußzeilendefinition, in die der Aufdruck gehörte |
|
||||||
|
| Markdown-ZIP | `import-export/export-markdown.ts` | keine YAML-Frontmatter-Ausgabe (Frontmatter existiert nur im _Import_, `obsidian-vault.ts:203,528`) |
|
||||||
|
| Atom-Feeds | `public/feed.service.ts` | kein Feld |
|
||||||
|
| Public-API | `public-api/` | Seitenrepräsentation ohne Einstufung |
|
||||||
|
| Suchergebnisse | `search/postgres-search.provider.ts` | Snippets ohne Markierung |
|
||||||
|
| No-JS-Shell | `public/html-shell.ts` | eigener Renderpfad, braucht eigene Behandlung |
|
||||||
|
| Anhang-Download | `files.controller.ts:51,71` | Dateiname/Begleitdatei sind die einzigen Träger |
|
||||||
|
|
||||||
|
### 3.6 Protokollierung
|
||||||
|
|
||||||
|
**(21)** Protokolliert werden Authentisierungs- und Admin-Ereignisse
|
||||||
|
(34 Ids, s. I-20) — persistent in `audit_log` **und** als
|
||||||
|
`audit: …`-stdout-Zeile. Inhaltsaktivität bleibt bewusst log-only,
|
||||||
|
Lesezugriffe fehlen ganz (I-21).
|
||||||
|
|
||||||
|
**(22) Wohin:** pino-JSON auf stdout, kein Datei- oder Syslog-Transport
|
||||||
|
(I-38). Ein SIEM-Export ist damit über die Container-Runtime möglich und
|
||||||
|
braucht keinen anwendungsseitigen Syslog-Client — die Lücke ist nicht der
|
||||||
|
Transport, sondern der stabile Ereigniskatalog (I-20).
|
||||||
|
|
||||||
|
**(23) Aufbewahrungsdauer:** für `audit_log` **nicht** konfigurierbar und
|
||||||
|
ohne Job (I-15); für Backups konfigurierbar
|
||||||
|
(`BACKUP_RETENTION_DAYS`, `backup.localRetentionDays`,
|
||||||
|
`backup.remoteRetentionDays`); `LOG_LEVEL` konfigurierbar, Log-Retention
|
||||||
|
ist Sache der Runtime.
|
||||||
|
|
||||||
|
**(24) Unerwünschte Daten in Logs:** `authorization`- und
|
||||||
|
`cookie`-Header werden entfernt (`app.module.ts:93`); der
|
||||||
|
`AuditEvent.details`-Kommentar verlangt „never secrets, tokens, or page
|
||||||
|
content". Seitentitel und Nutzernamen erscheinen in Logs und
|
||||||
|
Digest-Mails (I-23) — für eine eingestufte Seite kann bereits der Titel
|
||||||
|
schützenswert sein. Das gehört in die Restrisikoliste, nicht in einen
|
||||||
|
Bugfix.
|
||||||
|
|
||||||
|
### 3.7 Lieferkette
|
||||||
|
|
||||||
|
**(25) Abhängigkeiten:** 1380 aufgelöste Pakete im Lockfile, davon **366
|
||||||
|
im Produktionsbaum**. Direkte Produktionsabhängigkeiten: api 31,
|
||||||
|
web 26, collab 7, shared 5, backup 5. Die Kernpakete sind
|
||||||
|
NestJS 11, Prisma 6, Postgres-Client `pg` 8, Argon2, nodemailer 9,
|
||||||
|
pino 9, Yjs 13 mit Hocuspocus 4, TipTap 3 und ProseMirror, React 19,
|
||||||
|
`@modelcontextprotocol/sdk` 1. Lizenzverteilung s. I-37 — durchweg
|
||||||
|
permissiv.
|
||||||
|
|
||||||
|
**(26) Herkunft:** nicht belastbar beantwortbar (I-27).
|
||||||
|
|
||||||
|
**(27) Pinning:** `pnpm-lock.yaml` ist eingecheckt und deckt transitiv
|
||||||
|
alle 1380 Pakete; CI installiert mit `--frozen-lockfile`. Die
|
||||||
|
Manifest-Ranges sind Caret-Ranges, was in Kombination mit dem Lockfile
|
||||||
|
korrekt ist. Ungepinnt sind: **Container-Image-Digests** (I-05) und die
|
||||||
|
**Node-Version** (I-26). `pnpm` ist exakt gepinnt. Randnotiz ohne
|
||||||
|
Sicherheitsbezug: `zod` läuft in api/shared als ^3, im Web als ^4.
|
||||||
|
|
||||||
|
**(28) Deployment:** Container-Images per Docker Compose
|
||||||
|
(`deploy/compose/docker-compose.yml`) — vier eigene Images (web, api,
|
||||||
|
collab, backup) plus `postgres:17.5-alpine`, `pandoc/core:3.6`,
|
||||||
|
`gotenberg/gotenberg:8`, `caddy:2.10-alpine` (letzteres im Profil
|
||||||
|
`caddy`). Migrationen laufen beim api-Start (`main.ts:runMigrations`,
|
||||||
|
mit Advisory-Lock und einer Warteschleife gegen laufende Restores).
|
||||||
|
Eine Offline-Installation ist plausibel und unbelegt (I-05, I-28).
|
||||||
|
|
||||||
|
### 3.8 Betriebsmodell
|
||||||
|
|
||||||
|
**(29) Mandantenfähigkeit:** Single-Tenant (I-35).
|
||||||
|
|
||||||
|
**(30) Konfiguration.** Zwei klar getrennte Ebenen:
|
||||||
|
|
||||||
|
- **Deploy-Zeit (Env):** `DATABASE_URL`, `APP_BASE_URL`, `PORT`,
|
||||||
|
`UPLOADS_DIR`, `COLLAB_TOKEN_SECRET`, `SMTP_*`, `LOG_LEVEL`,
|
||||||
|
`GOTENBERG_URL`, `PANDOC_URL`, `BACKUP_RETENTION_DAYS`,
|
||||||
|
`MIGRATE_ON_START` (`packages/shared/src/env.ts`).
|
||||||
|
- **Laufzeit (`instance_settings`):** `upload.allowedExtensions`,
|
||||||
|
`upload.svgPolicy`, `api.enabled`, `mcp.enabled`, `backup.*`
|
||||||
|
(Retention, Nextcloud-Ziel, Upload-Zeitplan), `legal.imprint`,
|
||||||
|
`legal.privacyPolicy`, `home.content`, Setup-Abschluss.
|
||||||
|
|
||||||
|
Wichtig für Sicherheitsschalter: der Settings-Cache ist in-process, eine
|
||||||
|
Änderung wirkt erst nach api-Neustart (I-29).
|
||||||
|
|
||||||
|
**(31) Hart abschaltbare Funktionen:** Public-API und MCP (instanzweit,
|
||||||
|
Default aus, plus Zustimmung je Teich, I-39); SVG-Uploads
|
||||||
|
(`upload.svgPolicy: reject`); Dateiendungen per Allowlist; Caddy per
|
||||||
|
Compose-Profil. **Nicht** abschaltbar: lokale Authentisierung (I-02),
|
||||||
|
Plugins (I-19), Feeds (I-10).
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 4. Offene Punkte
|
||||||
|
|
||||||
|
Was in dieser Analyse **nicht** geklärt werden konnte, mit Grund:
|
||||||
|
|
||||||
|
1. **Jurisdiktion und Maintainer-Status der 366 Produktionspakete**
|
||||||
|
(I-27) — die Registry weist es nicht aus. Vorschlag: Kernpakete
|
||||||
|
einzeln belegen, den Rest über den SBOM offenlegen.
|
||||||
|
2. **Funktionsfähigkeit ohne Internetzugang** (I-28) — nur durch den
|
||||||
|
Testlauf in netzisolierter Umgebung beantwortbar. Der Testlauf ist
|
||||||
|
deshalb selbst ein Arbeitspaket (#220), kein Nachweisdokument.
|
||||||
|
3. **Sofortige Wirksamkeit künftiger Sicherheitsschalter** (I-29) —
|
||||||
|
hängt davon ab, ob `plugins.enabled` und ein Auth-Schalter am
|
||||||
|
in-process-Cache vorbei gelesen werden. Zu entscheiden in #200/#216.
|
||||||
|
4. **Vollständigkeit der Ausgabekanal-Liste** (I-30) — durch Codelesen
|
||||||
|
erstellt, nicht mechanisch belegt. Empfehlung: über den vorhandenen
|
||||||
|
Route-Enumeration-Test absichern.
|
||||||
|
5. **Wirkung von `PRE_RESTORE`-Snapshots und Backups auf ein
|
||||||
|
Löschverlangen** — beides sind gewollte Kopien mit eigener Retention;
|
||||||
|
wie lange ein gelöschter eingestufter Inhalt darin fortlebt, ist eine
|
||||||
|
Frage der Retention-Konfiguration beim Betreiber, nicht des Codes. Für
|
||||||
|
das Löschkonzept (#229) muss die Zahl trotzdem benannt werden.
|
||||||
|
6. **Werden Suchsnippets aus dem Klartext-Cache oder aus dem `tsvector`
|
||||||
|
erzeugt?** Für die Kennzeichnungspflicht (#211) reicht der Befund
|
||||||
|
„Snippets enthalten Seitentext"; für die Frage, ob ein Treffer
|
||||||
|
Inhaltsfragmente an nicht-lesende Nutzer ausgeben könnte, wäre eine
|
||||||
|
genauere Betrachtung von `postgres-search.provider.ts` nötig. Die
|
||||||
|
Berechtigungsprüfung joint auf lebende, sichtbare Seiten — ein
|
||||||
|
Leck ist nicht ersichtlich, aber nicht abschließend geprüft.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 5. Verhältnis zum Maßnahmenplan
|
||||||
|
|
||||||
|
Diese Ist-Aufnahme wurde **nach** `20-massnahmenplan.md` erstellt. Sie
|
||||||
|
bestätigt dessen Befunde bis auf drei Korrekturen und liefert vier
|
||||||
|
Ergänzungen, die im Plan fehlen:
|
||||||
|
|
||||||
|
**Bestätigt:** alle Phase-1- und Phase-2-Positionen des Plans, jeweils mit
|
||||||
|
Fundort (Tabelle in Abschnitt 2).
|
||||||
|
|
||||||
|
**Korrigiert:**
|
||||||
|
|
||||||
|
1. `deploy/compose/.env` ist **nicht** im Repo (`.gitignore:5–7`,
|
||||||
|
`git ls-files`) — der Punkt bleibt sinnvoll, aber als Verifikation
|
||||||
|
statt als Leck-Behebung.
|
||||||
|
2. Das **drawio-Plugin lädt keine externe URL** (I-40) — kein
|
||||||
|
Ausschlusskriterium.
|
||||||
|
3. **Labels fließen nicht in Exporte** (I-03a) — die offene Frage des
|
||||||
|
Plans ist mit Nein beantwortet, und der MCP-Gate divergiert nicht
|
||||||
|
(I-33).
|
||||||
|
|
||||||
|
**Ergänzt (im Plan nicht enthalten):**
|
||||||
|
|
||||||
|
| Befund | Warum es zählt |
|
||||||
|
| ----------------------------------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------- |
|
||||||
|
| I-22 `conversion_jobs` halten Rohdokument-Bytes unbefristet | Direkt löschkonzeptrelevant: eine gelöschte eingestufte Seite lebt in ihrem letzten Export weiter. |
|
||||||
|
| I-23 `mail_outbox` ohne Retention, mit Seitentiteln | Zweite unbefristete Kopie inhaltsnaher Daten. |
|
||||||
|
| I-24 `page_links.target_slug` überlebt den Purge | Der Slug einer eingestuften Seite kann selbst schützenswert sein. |
|
||||||
|
| I-25 IndexedDB-Kopie auf Endgeräten | Inhalte verlassen den Server auf Endgeräte-Datenträger — muss in der Abgrenzungserklärung als Betreiberpflicht (Endgeräteverschlüsselung) auftauchen. |
|
||||||
|
| I-26 Node-Version nicht gepinnt | Blockiert die Reproduzierbarkeitsaussage in #219. |
|
||||||
|
|
||||||
|
Empfehlung: I-22 bis I-26 als fünf zusätzliche Issues in die Meilensteine
|
||||||
|
`M24 — VS-NfD: security quick wins` (I-22, I-23, I-24, I-26) bzw. in die
|
||||||
|
Dokumentation `M30` (I-25) aufnehmen. Das hebt den Umfang von 45 auf
|
||||||
|
**50 Issues**; Aufwand zusätzlich ca. **4–6 AT** (I-22 M, I-23 S, I-24 S,
|
||||||
|
I-25 in #226/#231 enthalten, I-26 S).
|
||||||
281
docs/vs-nfd/20-massnahmenplan.md
Normal file
281
docs/vs-nfd/20-massnahmenplan.md
Normal 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 30–38 AT
|
||||||
|
|
||||||
|
### P1-1 Fremdauthentisierung + lokale Auth abschaltbar · 10–12 AT
|
||||||
|
|
||||||
|
_Meilenstein: `M27 — VS-NfD: external authentication`_
|
||||||
|
|
||||||
|
- [ ] OIDC Authorization Code + PKCE gegen `UserIdentity.provider` (ADR 0007
|
||||||
|
ausbauen), Keycloak als Referenz-IdP · 5–6 AT · #214
|
||||||
|
- [ ] Alternativpfad vertrauenswürdiger Reverse-Proxy-Header bzw. mTLS-
|
||||||
|
Client-Zertifikat · 2 AT · #215
|
||||||
|
- [ ] **Harter Schalter `auth.local.enabled = false`** inkl. Reset- und
|
||||||
|
Registrierungs-Flows, PATs und Feed-Tokens · 2 AT · #216
|
||||||
|
- [ ] Gruppen-/Rollen-Mapping aus IdP-Claims auf das Permission-Modell · 2–3 AT · #217
|
||||||
|
|
||||||
|
### P1-2 Einstufung als First-Class-Metadatum · 14–18 AT
|
||||||
|
|
||||||
|
_Meilenstein: `M26 — VS-NfD: classification metadata`_
|
||||||
|
|
||||||
|
- [ ] Enum-Feld `classification` an `Page`, Migration, Default aus
|
||||||
|
Instance-Setting · 2 AT · #204
|
||||||
|
- [ ] Vererbung im Seitenbaum, Herabstufung nur mit eigenem Recht + Audit · 3 AT · #205
|
||||||
|
- [ ] Durchreichen in alle Ausgabekanäle · 8–12 AT · #206–#212
|
||||||
|
- Web-Ansicht (Kopf/Fuß) · 1 AT · #206
|
||||||
|
- **Print-CSS** (`@media print`, Kopf/Fuß je Seite) — fehlt komplett · 1 AT · #207
|
||||||
|
- PDF via gotenberg (`pdf-html.ts` Header/Footer-Template) · 1 AT · #208
|
||||||
|
- DOCX/ODT via pandoc (Reference-Doc mit Kopf-/Fußzeile) · 2–3 AT · #209
|
||||||
|
- Markdown-ZIP (Frontmatter + Aufdruck) · 1 AT · #210
|
||||||
|
- Atom-Feeds, Public-API, Suchergebnisse, No-JS-Shell · 2–3 AT · #211
|
||||||
|
- Attachment-Download (Dateiname-Präfix + Begleitdatei) · 1–2 AT · #212
|
||||||
|
- [ ] Warnung/Sperre beim Anhängen an eingestufte Seiten · 1 AT · #213
|
||||||
|
|
||||||
|
### P1-3 Verifizierter Offline-/Airgap-Pfad · 8–10 AT ⟵ neu aus Roadmap
|
||||||
|
|
||||||
|
_Meilenstein: `M28 — VS-NfD: offline/airgap deployment` — das
|
||||||
|
Digest-Pinning (#203) läuft vorgezogen in `M25`_
|
||||||
|
|
||||||
|
Hochgezogen, weil das eine Frage im **ersten** Behördengespräch ist. „Sollte
|
||||||
|
gehen" ist dort eine schlechtere Antwort als „getestet, hier ist die Anleitung".
|
||||||
|
|
||||||
|
- [ ] Alle Images auf Digest pinnen (schließt den `gotenberg:8`-Punkt ein) · 1 AT · #203
|
||||||
|
- [ ] Mirror-Verfahren in interne Registry dokumentieren · 1 AT · #218
|
||||||
|
- [ ] Build ohne Netz reproduzierbar (pnpm Offline-Store / reine
|
||||||
|
Prebuilt-Images) · 2–3 AT · #219
|
||||||
|
- [ ] Testlauf in netzisolierter Umgebung, Protokoll als Beleg · 2 AT · #220
|
||||||
|
- [ ] Offline-Update-Pfad inkl. Migrationen · 2–3 AT · #221
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Phase 2 — Billig, hohe Prüfer-Signalwirkung · Summe 22–28 AT
|
||||||
|
|
||||||
|
_Meilensteine: `M24 — VS-NfD: security quick wins`; die nachgezogenen
|
||||||
|
Punkte (#199, #200, #201, #202) in `M25 — VS-NfD: hardening & supply
|
||||||
|
chain`_
|
||||||
|
|
||||||
|
- [ ] **Schlüsseltrennung `COLLAB_TOKEN_SECRET`** per HKDF (zweckgebundene
|
||||||
|
Subkeys) — echter Fund, vor allen Features · 1–2 AT · #188
|
||||||
|
- [ ] **Eigenbau-HMAC-JWT durch `jose` ersetzen** · +2–3 AT · #188 ⟵ neu aus Roadmap
|
||||||
|
_Gebündelt mit der Zeile darüber, weil dieselbe Datei
|
||||||
|
(`packages/shared/src/token-crypto.ts`). Einzeln wären es 5–6 AT._
|
||||||
|
Achtung: Unsubscribe-Tokens leben lang in versandten Mails →
|
||||||
|
Dual-Verify-Fenster einplanen.
|
||||||
|
- [ ] **CSRF fail-closed** — fehlendes Origin _und_ Referer wird derzeit
|
||||||
|
durchgelassen · 1 AT · #189
|
||||||
|
- [ ] **Session-Timeout konfigurierbar**, Default deutlich unter 30 Tagen,
|
||||||
|
separates Idle-Timeout · 1–2 AT · #190
|
||||||
|
- [ ] **Feed-Token raus aus dem Query-Parameter**, alternativ Feeds hart
|
||||||
|
abschaltbar · 2 AT · #191
|
||||||
|
- [ ] **Backup-Ziele einschränkbar** — Allowlist, WebDAV/rsync per Deploy
|
||||||
|
vollständig deaktivierbar · 2 AT · #192
|
||||||
|
- [ ] **Pond-Purge implementieren** — getrashte Ponds bleiben ewig liegen · 3 AT · #193
|
||||||
|
- [ ] **Orphan-File-Sweep** implementieren, `Attachment.deletedAt` nutzen
|
||||||
|
oder entfernen · 2 AT · #194
|
||||||
|
- [ ] **Papierkorb aus dem Suchindex** entfernen statt query-seitig filtern · 2 AT · #195
|
||||||
|
- [ ] **Retention-Job für `audit_log`** · 1 AT · #196
|
||||||
|
- [ ] **Security-Header** (helmet), CORS explizit restriktiv · 1 AT · #197
|
||||||
|
- [ ] **SBOM in CI** (CycloneDX/syft) + Lizenzreport als Artefakt · 1–2 AT · #202
|
||||||
|
- [ ] `deploy/compose/.env` prüfen, Beispieldatei statt Realdatei · 0,5 AT · #198
|
||||||
|
- [ ] **Attachment-Integritätshashes** · +2–3 AT · #199 ⟵ neu aus Roadmap
|
||||||
|
SHA-256-Spalte, Berechnung beim Upload, Prüfung beim Download,
|
||||||
|
Backfill-Migration. Nebennutzen: Orphan-Sweep, Dedup, Backup-Verifikation.
|
||||||
|
- [ ] **Plugins hart abschaltbar** (`plugins.enabled = false`) · +2 AT · #200 ⟵ neu
|
||||||
|
Deckt das Risiko „Codeausführung in der VS-Zone" für den
|
||||||
|
Angebotsstand vollständig ab. Hash-Pinning siehe Phase 4.
|
||||||
|
- [ ] **Syslog/SIEM: Ereigniskatalog** · +3–4 AT · #201 ⟵ neu aus Roadmap
|
||||||
|
Der Code-Anteil ist klein (stdout-JSON reicht meist). Wert liegt im
|
||||||
|
**stabilen Ereigniskatalog**: feste Event-IDs, dokumentierte Semantik
|
||||||
|
und Felder, damit die Behörde SIEM-Regeln schreiben kann.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Phase 3 — Beweissicherung / Lesezugriffe · 8–20 AT ⟵ neu aus Roadmap
|
||||||
|
|
||||||
|
_Meilenstein: `M29 — VS-NfD: read-access audit trail` (Variante A)_
|
||||||
|
|
||||||
|
Der aufwändigste der nachgezogenen Punkte, und der mit dem größten
|
||||||
|
Gestaltungsspielraum. Zwei Varianten:
|
||||||
|
|
||||||
|
### Variante A (empfohlen): nur eingestufte Inhalte · 8–10 AT
|
||||||
|
|
||||||
|
Protokolliert werden Lesezugriffe **ausschließlich** auf Seiten mit
|
||||||
|
`classification = VS_NFD`. Setzt P1-2 voraus.
|
||||||
|
|
||||||
|
- [ ] Instrumentierung der Lesepfade: Seitenansicht, Public-API-GET,
|
||||||
|
Attachment-Download, Export, No-JS-Shell, Collab-WS-Join · 4 AT · #222
|
||||||
|
- [ ] Dedup-Fenster (eine Sitzung + eine Seite innerhalb N Minuten = ein
|
||||||
|
Ereignis), sonst erzeugt Yjs-Sync eine Ereignisflut · 2 AT · #223
|
||||||
|
- [ ] Getrennte Tabelle mit eigener Retention und Partitionierung · 2 AT · #224
|
||||||
|
- [ ] Abschaltbar, Zweckbindung dokumentiert · 1–2 AT · #225
|
||||||
|
|
||||||
|
Vorteil über den Aufwand hinaus: Die Zweckbindung ist sauber begründbar
|
||||||
|
(„nur eingestufte Inhalte"), was die Personalrats-Diskussion beim Kunden
|
||||||
|
erheblich entschärft.
|
||||||
|
|
||||||
|
### Variante B: alle Lesezugriffe · 18–20 AT
|
||||||
|
|
||||||
|
Zusätzlich Volumen-, Latenz- und Aufbewahrungsprobleme: gepufferte Schreibung
|
||||||
|
ohne Ereignisverlust (ein verlorenes Ereignis ist eine Lücke in der
|
||||||
|
Beweissicherung), Suchtreffer als eigene Ereignisklasse, Partitionierung
|
||||||
|
zwingend.
|
||||||
|
|
||||||
|
**Einordnung:** Für „einsetzbar in zugelassener Umgebung" ist das kein
|
||||||
|
zwingendes Produktmerkmal — Beweissicherung kann die Plattform erbringen. In
|
||||||
|
der Praxis kann Plattform-Logging aber nicht beantworten, _welche eingestufte
|
||||||
|
Seite_ gelesen wurde (Proxy-Logs kennen URLs, nicht Einstufungen). In
|
||||||
|
Leistungsbeschreibungen taucht das als Muss-Kriterium auf. Deshalb rein —
|
||||||
|
aber in Variante A.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Phase 4 — Verbleibende Roadmap
|
||||||
|
|
||||||
|
_Meilenstein: `M31 — VS-NfD: backlog`_
|
||||||
|
|
||||||
|
Nur noch ein Punkt bleibt draußen:
|
||||||
|
|
||||||
|
- **Plugin-Allowlist mit Hash-Pinning** · 8–10 AT · #232
|
||||||
|
Manifest mit SHA-256, Allowlist in `instance_settings`, Prüfung beim Laden,
|
||||||
|
Admin-UI. Bleibt zurückgestellt, weil Phase 2 mit der harten Abschaltung das
|
||||||
|
Risiko bereits schließt — und weil echte Code-Signierung ohne juristische
|
||||||
|
Person ohnehin nicht verfügbar ist. Hash-Pinning ist die richtige Antwort,
|
||||||
|
aber nicht die dringendste.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Phase 5 — Dokumentation · 15–20 AT (vorher 12–15)
|
||||||
|
|
||||||
|
_Meilenstein: `M30 — VS-NfD: compliance documentation`_
|
||||||
|
|
||||||
|
Wächst um ca. 25 %, weil jede neue Funktion Handbuch- und Härtungsabschnitte
|
||||||
|
nach sich zieht.
|
||||||
|
|
||||||
|
- [ ] **Abgrenzungserklärung §52 VSA** — welche Sicherheitsgrundfunktionen die
|
||||||
|
Anwendung _nicht_ erbringt und wem sie zufallen. Wichtigstes
|
||||||
|
Einzeldokument. · 3 AT · #226
|
||||||
|
- [ ] **Härtungsleitfaden** mit Referenzkonfiguration „VS-NfD-Betrieb":
|
||||||
|
lokale Auth aus, Public-API aus, MCP aus, Feeds aus, Plugins aus,
|
||||||
|
Backup nur lokal · 3 AT · #227
|
||||||
|
- [ ] **Sicherheitsdokumentation**: Architektur, Datenflüsse, Netzplan,
|
||||||
|
Ports/Dienste, Vertrauensgrenzen · 4 AT · #228
|
||||||
|
- [ ] **Betriebshandbuch**: Installation (inkl. Airgap), Update, Backup/Restore,
|
||||||
|
Löschung und Vernichtung, Rollentrennung · 4–5 AT · #229
|
||||||
|
- [ ] **Zuarbeit IT-Grundschutz** APP.3.1 und CON.11.1, je Anforderung
|
||||||
|
„Produkt / Betreiber / nicht anwendbar" · 3–4 AT · #230
|
||||||
|
- [ ] **Restrisikoliste** mit bewusst offenen Punkten · 1 AT · #231
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Terminwirkung
|
||||||
|
|
||||||
|
| Block | vorher | Rev. 2 |
|
||||||
|
| ----------------------- | ------------ | ---------------- |
|
||||||
|
| Phase 1 Blocker | 22–28 AT | 30–38 AT |
|
||||||
|
| Phase 2 Billigblock | 15–18 AT | 22–28 AT |
|
||||||
|
| Phase 3 Beweissicherung | — | 8–10 AT (Var. A) |
|
||||||
|
| Phase 5 Dokumentation | 12–15 AT | 15–20 AT |
|
||||||
|
| **Summe** | **49–61 AT** | **75–96 AT** |
|
||||||
|
|
||||||
|
Bei 4 produktiven Tagen pro Woche:
|
||||||
|
|
||||||
|
- **vorher:** ca. 3–3,5 Monate
|
||||||
|
- **Rev. 2 mit Variante A:** ca. **4,5–5,5 Monate**
|
||||||
|
- **Rev. 2 mit Variante B:** ca. **5,5–6,5 Monate**
|
||||||
|
- Plus Phase 4 (Hash-Pinning): weitere ~0,5 Monate
|
||||||
|
|
||||||
|
Der Zuwachs von ~26–35 AT verteilt sich zu etwa zwei Dritteln auf
|
||||||
|
Beweissicherung und Airgap-Verifikation. Beides sind Punkte, nach denen
|
||||||
|
gefragt wird — nicht Punkte, die man erklären muss.
|
||||||
|
|
||||||
|
**Empfehlung zur Reihenfolge:** Termin für den Angebotsstand nicht
|
||||||
|
verschieben. Phase 5 und der Billigblock sind nach ca. 3 Monaten fertig — das
|
||||||
|
genügt, um Gespräche zu führen. Phase 1-3 laufen dahinter weiter. Ein Angebot
|
||||||
|
mit belegter Dokumentation und laufender Umsetzung ist besser als ein
|
||||||
|
fertiges Produkt ohne Gesprächspartner.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Zu klärende Punkte aus der Ist-Aufnahme
|
||||||
|
|
||||||
|
- [x] **drawio-Plugin**: geklärt — vendored unter
|
||||||
|
`packages/plugins/drawio/vendor/`, lädt keine externe Editor-URL;
|
||||||
|
kein Ausschlusskriterium (Ist-Aufnahme I-40)
|
||||||
|
- [x] Fließen Labels heute in Exporte? **Nein** — `export.service.ts` lädt
|
||||||
|
`labelIds` nur für die Permission-Filterung (Ist-Aufnahme I-03a)
|
||||||
|
- [ ] Was bricht ohne Internetzugang? → wird durch den Testlauf #220
|
||||||
|
beantwortet (Ist-Aufnahme I-28)
|
||||||
|
- [x] MCP-Gate-Duplikat: **divergiert nicht** — nutzt den zentralen
|
||||||
|
`PermissionService`, eigenständig sind nur die Schalter
|
||||||
|
(Ist-Aufnahme I-33)
|
||||||
|
- [x] Collab-WebSocket: WS-Ebene prüft nur das Token, aber Tokens leben
|
||||||
|
60 s und Grant-Entzug schließt Verbindungen per `pg_notify`;
|
||||||
|
Randbedingung für #222 (Ist-Aufnahme I-41)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Ergänzungen aus der Ist-Aufnahme (2026-07-30)
|
||||||
|
|
||||||
|
Befunde der nachgezogenen `10-ist-aufnahme.md`, die in diesem Plan
|
||||||
|
fehlten — als Issues angelegt:
|
||||||
|
|
||||||
|
- [ ] Conversion-Job-Payloads prunen — Rohbytes jedes Im-/Exports liegen
|
||||||
|
unbefristet in `conversion_jobs` · 2 AT · #233 (M24, I-22)
|
||||||
|
- [ ] Retention für `mail_outbox` — Digest-Mails tragen Seitentitel
|
||||||
|
· 1 AT · #234 (M24, I-23)
|
||||||
|
- [ ] `page_links.target_slug`-Residuum nach Purge entscheiden
|
||||||
|
· 0,5 AT · #235 (M24, I-24)
|
||||||
|
- [ ] Node-Version pinnen — Voraussetzung für #219 · 0,5 AT · #236 (M25, I-26)
|
||||||
|
|
||||||
|
Ohne eigenes Issue: IndexedDB-Kopie auf Endgeräten (I-25) — als
|
||||||
|
Akzeptanzkriterium in #226 (Abgrenzungserklärung) und #231
|
||||||
|
(Restrisikoliste) verankert.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Vorhandene Stärken (im Angebot nach vorne stellen)
|
||||||
|
|
||||||
|
Nicht ausbauen, sondern **belegen**:
|
||||||
|
|
||||||
|
- Keine Telemetrie, keine Update-Checks, keine CDNs, kein Runtime-Nachladen,
|
||||||
|
Fonts self-hosted, CSP `default-src 'self'` — hier scheitern die meisten
|
||||||
|
Konkurrenzprodukte
|
||||||
|
- Zentrales, default-closed Berechtigungsmodell mit deny-wins und
|
||||||
|
Route-Enumeration-Test — ein prüfbares Artefakt
|
||||||
|
- Volltextsuche in Postgres statt externer Suchmaschine
|
||||||
|
- Single-Tenant — passt zur empfohlenen Betriebsform „eine Instanz pro
|
||||||
|
Einstufungsniveau"
|
||||||
|
- Public-API und MCP zur Laufzeit hart abschaltbar, Default aus
|
||||||
|
- Open Source unter MIT — Quelloffenheit ist im Prüfprozess ein Vorteil
|
||||||
|
- Keine Verschlüsselung im Code = korrekte Architektur, nicht fehlende
|
||||||
|
Funktion. So argumentieren.
|
||||||
194
docs/vs-nfd/30-issue-adr-auftrag.md
Normal file
194
docs/vs-nfd/30-issue-adr-auftrag.md
Normal 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 | 15–18 AT |
|
||||||
|
| M2 | `VS-NfD: hardening & supply chain` | P2 nachgezogen: Attachment-Hashes, Plugin-Abschaltung, Ereigniskatalog, SBOM, Image-Digests | 7–10 AT |
|
||||||
|
| M3 | `VS-NfD: classification metadata` | P1-2 vollständig | 14–18 AT |
|
||||||
|
| M4 | `VS-NfD: external authentication` | P1-1 vollständig | 10–12 AT |
|
||||||
|
| M5 | `VS-NfD: offline/airgap deployment` | P1-3 vollständig | 8–10 AT |
|
||||||
|
| M6 | `VS-NfD: read-access audit trail` | Phase 3, Variante A | 8–10 AT |
|
||||||
|
| M7 | `VS-NfD: compliance documentation` | Phase 5 | 15–20 AT |
|
||||||
|
| M8 | `VS-NfD: backlog` | Phase 4 (Plugin-Hash-Pinning) | 8–10 AT |
|
||||||
|
|
||||||
|
Abhängigkeiten in die Meilenstein-Beschreibung schreiben:
|
||||||
|
M6 setzt M3 voraus. M2 (Image-Digests) sollte vor M5 liegen. M7 läuft
|
||||||
|
parallel und beginnt früh — die Abgrenzungserklärung ist nicht von
|
||||||
|
Implementierung abhängig.
|
||||||
|
|
||||||
|
## Labels
|
||||||
|
|
||||||
|
Anlegen, falls nicht vorhanden:
|
||||||
|
`vs-nfd` · `vs-nfd:blocker` · `effort:S` · `effort:M` · `effort:L` ·
|
||||||
|
`area:auth` · `area:export` · `area:storage` · `area:supply-chain` ·
|
||||||
|
`area:docs`
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Issue-Vorlage
|
||||||
|
|
||||||
|
Ein Issue pro Checkbox-Zeile des Maßnahmenplans. Unterpunkte mit eigenem
|
||||||
|
Aufwand (z. B. die Ausgabekanäle in P1-2) werden **eigene Issues**, nicht
|
||||||
|
Checklisten in einem Sammel-Issue — sie sind unabhängig abschließbar.
|
||||||
|
|
||||||
|
```markdown
|
||||||
|
**Title:** [VS-NfD] <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
|
||||||
3192
docs/vs-nfd/31-issue-entwurf.md
Normal file
3192
docs/vs-nfd/31-issue-entwurf.md
Normal file
File diff suppressed because it is too large
Load Diff
Loading…
Reference in New Issue
Block a user