From da5fd7c77006e23e2018cc02dfc5e0d8583d9405 Mon Sep 17 00:00:00 2001 From: Claude Fable 5 Date: Fri, 31 Jul 2026 18:24:04 +0200 Subject: [PATCH] #243: VS_NFD_MODE and the machine-readable hardening-profile catalog MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The deployment declares through VS_NFD_MODE (off | marked | hidden | enforced, default off) how the application treats configuration that violates the VS-NfD reference profile — deploy-level like BACKUP_ALLOWED_TARGETS, so a compromised Site Admin cannot widen it. The catalog in shared (vs-nfd-profile.ts) is the single source of truth: every profile-relevant setting with a decidable compliant value, judgement calls in an explicit advisory list, and a fence test parsing the hardening guide's reference tables so neither can drift (pattern #201). The api evaluates the catalog against the typed settings registry and validated env and exposes mode + verdict on GET /admin/system/vs-nfd-profile; the admin settings view shows the card whenever the mode is not off. Display only — the treatments land with #244–#246 (ADR 0027, proposed). Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_01AUtYMxwTCMHG9mVHnwbFg8 --- apps/api/src/admin/system-admin.controller.ts | 14 +- apps/api/src/settings/settings.module.ts | 5 +- .../settings/vs-nfd-profile-catalogue.test.ts | 54 +++++ .../settings/vs-nfd-profile.e2e.db.test.ts | 114 +++++++++ .../src/settings/vs-nfd-profile.service.ts | 50 ++++ apps/web/src/pages/AdminSettingsPage.tsx | 47 +++- deploy/compose/.env.example | 11 + deploy/compose/docker-compose.yml | 3 + .../adr/0027-vs-nfd-hardening-profile-mode.md | 66 +++++ docs/vs-nfd/50-haertungsleitfaden.md | 23 +- packages/shared/i18n/de/settings.json | 17 +- packages/shared/i18n/en/settings.json | 17 +- packages/shared/src/env.ts | 9 + packages/shared/src/index.ts | 1 + packages/shared/src/vs-nfd-profile.test.ts | 59 +++++ packages/shared/src/vs-nfd-profile.ts | 225 ++++++++++++++++++ 16 files changed, 698 insertions(+), 17 deletions(-) create mode 100644 apps/api/src/settings/vs-nfd-profile-catalogue.test.ts create mode 100644 apps/api/src/settings/vs-nfd-profile.e2e.db.test.ts create mode 100644 apps/api/src/settings/vs-nfd-profile.service.ts create mode 100644 docs/architecture/adr/0027-vs-nfd-hardening-profile-mode.md create mode 100644 packages/shared/src/vs-nfd-profile.test.ts create mode 100644 packages/shared/src/vs-nfd-profile.ts diff --git a/apps/api/src/admin/system-admin.controller.ts b/apps/api/src/admin/system-admin.controller.ts index 362d92a..051030a 100644 --- a/apps/api/src/admin/system-admin.controller.ts +++ b/apps/api/src/admin/system-admin.controller.ts @@ -10,10 +10,12 @@ import { type StorageOverviewView, type SystemBackupView, type SystemJobView, + type VsNfdProfileView, } from '@dorfteich/shared'; import { AuthedRequest } from '../auth/auth.guard'; import { ZodValidationPipe } from '../common/zod-validation.pipe'; +import { VsNfdProfileService } from '../settings/vs-nfd-profile.service'; import { SiteAdminGuard } from './site-admin.guard'; import { SystemAdminService } from './system-admin.service'; @@ -21,7 +23,17 @@ import { SystemAdminService } from './system-admin.service'; @Controller('admin/system') @UseGuards(SiteAdminGuard) export class SystemAdminController { - constructor(private readonly system: SystemAdminService) {} + constructor( + private readonly system: SystemAdminService, + private readonly vsNfdProfile: VsNfdProfileService, + ) {} + + /** Active VS-NfD mode + catalog verdict for the running configuration + * (issue #243, ADR 0027). Exposure only — the treatments are #244–#246. */ + @Get('vs-nfd-profile') + vsNfd(): Promise { + return this.vsNfdProfile.evaluate(); + } @Get('jobs') async jobs(): Promise { diff --git a/apps/api/src/settings/settings.module.ts b/apps/api/src/settings/settings.module.ts index 805938f..edb37b0 100644 --- a/apps/api/src/settings/settings.module.ts +++ b/apps/api/src/settings/settings.module.ts @@ -1,11 +1,12 @@ import { Global, Module } from '@nestjs/common'; import { InstanceSettingsService } from './instance-settings.service'; +import { VsNfdProfileService } from './vs-nfd-profile.service'; /** Global: instance configuration is read across many feature modules. */ @Global() @Module({ - providers: [InstanceSettingsService], - exports: [InstanceSettingsService], + providers: [InstanceSettingsService, VsNfdProfileService], + exports: [InstanceSettingsService, VsNfdProfileService], }) export class SettingsModule {} diff --git a/apps/api/src/settings/vs-nfd-profile-catalogue.test.ts b/apps/api/src/settings/vs-nfd-profile-catalogue.test.ts new file mode 100644 index 0000000..3f38a4a --- /dev/null +++ b/apps/api/src/settings/vs-nfd-profile-catalogue.test.ts @@ -0,0 +1,54 @@ +import { readFileSync } from 'node:fs'; +import { join } from 'node:path'; + +import { VS_NFD_PROFILE, VS_NFD_PROFILE_ADVISORY } from '@dorfteich/shared'; +import { describe, expect, it } from 'vitest'; + +/** + * The fence that keeps the machine-readable VS-NfD catalog (issue #243) + * and the hardening guide (issue #227) together — same pattern as the + * audit-catalogue fence (#201): every switch the guide's reference tables + * name must be triaged into the catalog (decidable compliant value) or + * the explicit advisory list (judgement call), and neither may name a + * switch the guide does not know. A new switch line in the guide without + * a triage decision fails this test. + */ +// __dirname, not import.meta: the api package compiles CJS. +const doc = readFileSync( + join(__dirname, '../../../../docs/vs-nfd/50-haertungsleitfaden.md'), + 'utf8', +); + +/** All code spans in the first cell of each table row between two headings. */ +function guideKeys(fromHeading: string, toHeading: string): string[] { + const from = doc.indexOf(fromHeading); + const to = doc.indexOf(toHeading); + expect(from).toBeGreaterThan(-1); + expect(to).toBeGreaterThan(from); + const keys: string[] = []; + for (const line of doc.slice(from, to).split('\n')) { + if (!line.startsWith('|')) continue; + const firstCell = line.split('|')[1] ?? ''; + for (const match of firstCell.matchAll(/`([^`]+)`/g)) { + keys.push(match[1]!); + } + } + return keys.sort(); +} + +function triagedKeys(scope: 'instance' | 'deploy'): string[] { + return [ + ...VS_NFD_PROFILE.filter((e) => e.scope === scope).map((e) => e.key), + ...VS_NFD_PROFILE_ADVISORY.filter((e) => e.scope === scope).map((e) => e.key), + ].sort(); +} + +describe('VS-NfD catalog fence (issue #243)', () => { + it('triages every instance setting of hardening-guide §1.1', () => { + expect(triagedKeys('instance')).toEqual(guideKeys('### 1.1', '### 1.2')); + }); + + it('triages every deploy variable of hardening-guide §1.2', () => { + expect(triagedKeys('deploy')).toEqual(guideKeys('### 1.2', '### 1.3')); + }); +}); diff --git a/apps/api/src/settings/vs-nfd-profile.e2e.db.test.ts b/apps/api/src/settings/vs-nfd-profile.e2e.db.test.ts new file mode 100644 index 0000000..2ac9f89 --- /dev/null +++ b/apps/api/src/settings/vs-nfd-profile.e2e.db.test.ts @@ -0,0 +1,114 @@ +import { INestApplication } from '@nestjs/common'; +import { type VsNfdProfileView } from '@dorfteich/shared'; +import { PrismaClient } from '@prisma/client'; +import request from 'supertest'; +import { afterAll, beforeAll, describe, expect, it } from 'vitest'; + +import { createTestApp, sessionCookieOf } from '../testing/test-app'; +import { createTestPrisma, hasTestDb, uniqueSuffix } from '../testing/test-db'; +import { UsersService } from '../users/users.service'; + +import { InstanceSettingsService } from './instance-settings.service'; + +/** + * The VS-NfD profile endpoint (issue #243): active mode from the env, + * catalog verdict from the running configuration — Site-Admin only. The + * mode is deploy-level, so it is fixed per app boot (env override BEFORE + * createTestApp, pattern local-auth-switch.e2e.db.test.ts). + */ +describe.skipIf(!hasTestDb)('VS-NfD profile endpoint (e2e, issue #243)', () => { + let app: INestApplication; + let prisma: PrismaClient; + const suffix = uniqueSuffix(); + const password = 'profilkatalog ist wachsam 1'; + const ids: Record = {}; + const cookies: Record = {}; + + const api = () => request(app.getHttpServer()); + + async function makeUser(handle: string, siteAdmin: boolean): Promise { + const users = app.get(UsersService); + const username = `nfd-${handle}-${suffix}`; + const user = await users.createUser({ + username, + email: `${username}@example.org`, + displayName: `Nfd ${handle}`, + password, + locale: 'en', + }); + await users.markEmailVerified(user.id); + if (siteAdmin) + await prisma.user.update({ where: { id: user.id }, data: { isSiteAdmin: true } }); + ids[handle] = user.id; + cookies[handle] = sessionCookieOf( + await api() + .post('/api/v1/auth/login') + .send({ usernameOrEmail: username, password }) + .expect(200), + ); + } + + beforeAll(async () => { + process.env.VS_NFD_MODE = 'marked'; + prisma = createTestPrisma(); + await prisma.rateLimit.deleteMany({}); + app = await createTestApp(); + await makeUser('admin', true); + await makeUser('user', false); + }); + + afterAll(async () => { + delete process.env.VS_NFD_MODE; + await prisma.instanceSetting.deleteMany({ + where: { key: { in: ['auth.registrationMode', 'legal.imprint'] } }, + }); + await prisma.user.deleteMany({ where: { username: { contains: suffix } } }); + await prisma.$disconnect(); + await app.close(); + }); + + it('is Site-Admin only', async () => { + await api().get('/api/v1/admin/system/vs-nfd-profile').expect(401); + await api().get('/api/v1/admin/system/vs-nfd-profile').set('Cookie', cookies.user!).expect(403); + }); + + it('reports the env mode and a truthful per-entry verdict', async () => { + const res = await api() + .get('/api/v1/admin/system/vs-nfd-profile') + .set('Cookie', cookies.admin!) + .expect(200); + const view = res.body as VsNfdProfileView; + expect(view.mode).toBe('marked'); + // Fresh instance: registration open, feeds/plugins on, read trail off, + // legal texts empty — all violations by design of the defaults. + const byKey = new Map(view.entries.map((e) => [e.key, e])); + expect(byKey.get('auth.registrationMode')!.compliant).toBe(false); + expect(byKey.get('api.enabled')!.compliant).toBe(true); + expect(byKey.get('feeds.enabled')!.compliant).toBe(false); + expect(byKey.get('readTrail.enabled')!.compliant).toBe(false); + expect(byKey.get('legal.imprint')!.compliant).toBe(false); + // Deploy scope: the test env keeps the permissive defaults. + expect(byKey.get('AUTH_LOCAL_ENABLED')!.compliant).toBe(false); + expect(view.violations).toBe(view.entries.filter((e) => !e.compliant).length); + expect(view.violations).toBeGreaterThan(0); + }); + + it('verdict follows a settings change', async () => { + const settings = app.get(InstanceSettingsService); + const before = ( + await api() + .get('/api/v1/admin/system/vs-nfd-profile') + .set('Cookie', cookies.admin!) + .expect(200) + ).body as VsNfdProfileView; + await settings.set('auth.registrationMode', 'closed', ids.admin!); + const after = ( + await api() + .get('/api/v1/admin/system/vs-nfd-profile') + .set('Cookie', cookies.admin!) + .expect(200) + ).body as VsNfdProfileView; + expect(after.entries.find((e) => e.key === 'auth.registrationMode')!.compliant).toBe(true); + expect(after.violations).toBe(before.violations - 1); + }); +}); diff --git a/apps/api/src/settings/vs-nfd-profile.service.ts b/apps/api/src/settings/vs-nfd-profile.service.ts new file mode 100644 index 0000000..b6d6a91 --- /dev/null +++ b/apps/api/src/settings/vs-nfd-profile.service.ts @@ -0,0 +1,50 @@ +import { Injectable } from '@nestjs/common'; +import { + VS_NFD_PROFILE, + describeCompliance, + isVsNfdCompliant, + type ApiEnv, + type VsNfdProfileEntry, + type VsNfdProfileView, +} from '@dorfteich/shared'; + +import { AppConfig } from '../config/app-config.service'; +import { InstanceSettingsService, type InstanceSettings } from './instance-settings.service'; + +/** + * Evaluates the running configuration against the VS-NfD reference + * profile (issue #243, ADR 0027). Instance entries read the settings + * registry, deploy entries the validated env — both through their normal + * typed paths, so the verdict always describes what the application + * actually does, not what a file claims. The three treatment modes + * (#244–#246) build on this evaluation; here it is exposure only. + */ +@Injectable() +export class VsNfdProfileService { + constructor( + private readonly settings: InstanceSettingsService, + private readonly config: AppConfig, + ) {} + + private valueOf(entry: VsNfdProfileEntry, settings: InstanceSettings): unknown { + return entry.scope === 'instance' + ? settings[entry.key as keyof InstanceSettings] + : this.config.env[entry.key as keyof ApiEnv]; + } + + async evaluate(): Promise { + const settings = await this.settings.getAll(); + const entries = VS_NFD_PROFILE.map((entry) => ({ + scope: entry.scope, + key: entry.key, + compliant: isVsNfdCompliant(entry, this.valueOf(entry, settings)), + compliantValue: describeCompliance(entry.compliance), + hardeningRef: entry.hardeningRef, + })); + return { + mode: this.config.env.VS_NFD_MODE, + entries, + violations: entries.filter((entry) => !entry.compliant).length, + }; + } +} diff --git a/apps/web/src/pages/AdminSettingsPage.tsx b/apps/web/src/pages/AdminSettingsPage.tsx index 62a0eb0..ed7b893 100644 --- a/apps/web/src/pages/AdminSettingsPage.tsx +++ b/apps/web/src/pages/AdminSettingsPage.tsx @@ -1,5 +1,5 @@ import { useQuery, useQueryClient } from '@tanstack/react-query'; -import { docToHtml, markdownToDoc } from '@dorfteich/shared'; +import { docToHtml, markdownToDoc, type VsNfdProfileView } from '@dorfteich/shared'; import { useState } from 'react'; import { useForm } from 'react-hook-form'; import { useTranslation } from 'react-i18next'; @@ -71,6 +71,7 @@ export function AdminSettingsPage(): React.JSX.Element { {t('system:settingsLink')} →

+

{t('settings:admin.general')}

@@ -152,6 +153,50 @@ export function AdminSettingsPage(): React.JSX.Element { ); } +/** + * VS-NfD hardening-profile card (issue #243, ADR 0027): active mode and + * the catalog verdict for the running configuration. Renders nothing in + * mode `off` — outside a VS context the profile is not a topic. Display + * only; the mode treatments land with #244–#246. Text carries the whole + * meaning (never colour alone, ADR 0017). + */ +function VsNfdProfileSection(): React.JSX.Element | null { + const { t } = useTranslation('settings'); + const profile = useQuery({ + queryKey: ['admin', 'vs-nfd-profile'], + queryFn: () => apiGet('/admin/system/vs-nfd-profile'), + }); + + if (!profile.data || profile.data.mode === 'off') return null; + const violations = profile.data.entries.filter((entry) => !entry.compliant); + + return ( +
+

{t('admin.vsNfd.title')}

+

{t('admin.vsNfd.intro')}

+

+ {t('admin.vsNfd.modeLabel')}: {t(`admin.vsNfd.modes.${profile.data.mode}`)} +

+ {violations.length === 0 ? ( +

{t('admin.vsNfd.compliant')}

+ ) : ( + <> +

{t('admin.vsNfd.violations', { count: violations.length })}

+
    + {violations.map((entry) => ( +
  • + {entry.key} —{' '} + {t('admin.vsNfd.referenceValue', { value: entry.compliantValue })} ( + {t('admin.vsNfd.guideRef', { section: entry.hardeningRef })}) +
  • + ))} +
+ + )} +
+ ); +} + /** * Upload allowlist + SVG policy (issue #61). The allowlist is an array in the * api but edited here as a comma-separated field; images are always allowed diff --git a/deploy/compose/.env.example b/deploy/compose/.env.example index 82eb6ee..9e0d836 100644 --- a/deploy/compose/.env.example +++ b/deploy/compose/.env.example @@ -105,6 +105,17 @@ SMTP_FROM=Dorfteich # mirror stop. Deploy-level on purpose: Site-Admins cannot widen it. #BACKUP_ALLOWED_TARGETS=cloud.example.org,172.30.1.10 +# VS-NfD hardening-profile mode (issue #243, ADR 0027): how the application +# treats configuration that violates the reference profile of +# docs/vs-nfd/50-haertungsleitfaden.md. off (default) = VS-NfD is not a +# topic, no marking anywhere; marked = violations are marked in the admin +# UI; hidden = violating options disappear (the hiding is marked); +# enforced = violating writes are rejected server-side. The treatments +# roll out with #244–#246; until then every non-off mode shows the profile +# card in the admin settings. Deploy-level on purpose: Site-Admins cannot +# widen it. +#VS_NFD_MODE=marked + # Optional rsync mirror of the backup sets to a private host (issue #84): # rsync-over-ssh target plus the private key file INSIDE the container — # put the key on the secrets volume (docker compose cp), never in the repo. diff --git a/deploy/compose/docker-compose.yml b/deploy/compose/docker-compose.yml index 1315d2d..39f6395 100644 --- a/deploy/compose/docker-compose.yml +++ b/deploy/compose/docker-compose.yml @@ -58,6 +58,9 @@ services: # Must match the backup service's value — the api validates admin # backup settings against the same allowlist (issue #192). BACKUP_ALLOWED_TARGETS: ${BACKUP_ALLOWED_TARGETS:-} + # VS-NfD hardening-profile mode (issue #243, ADR 0027): + # off | marked | hidden | enforced. Empty = off — no marking anywhere. + VS_NFD_MODE: ${VS_NFD_MODE:-} # SMTP relay. Empty (= unset in .env) is fine: the setup wizard writes # the relay to the secret store on the `secrets` volume (issue #80); # values set here in the stage .env always win over the store. diff --git a/docs/architecture/adr/0027-vs-nfd-hardening-profile-mode.md b/docs/architecture/adr/0027-vs-nfd-hardening-profile-mode.md new file mode 100644 index 0000000..af91d10 --- /dev/null +++ b/docs/architecture/adr/0027-vs-nfd-hardening-profile-mode.md @@ -0,0 +1,66 @@ +# ADR 0027: VS-NfD hardening-profile mode and configuration catalog + +- Status: proposed +- Date: 2026-07-31 + +## Context + +The hardening guide (issue #227, `docs/vs-nfd/50-haertungsleitfaden.md`) +names a reference configuration for VS-NfD operation, but it is prose: the +application cannot answer whether the running configuration matches it, +and an operator flipping a switch in the admin UI gets no hint that the +choice leaves the profile. Requested by the operator (2026-07-30) as a +follow-up to the M24/M25 switches: the application itself should know +which options violate the profile — while deployments outside any VS +context stay entirely unaffected. + +## Decision + +1. **A deploy-level mode, not a runtime setting.** `VS_NFD_MODE` + (`off | marked | hidden | enforced`) is an environment variable, + validated in the shared env schema and passed through compose — for the + same reason as `BACKUP_ALLOWED_TARGETS` (#192, ADR 0026): a + compromised Site-Admin account must not be able to widen it. The modes + escalate: `marked` shows violations at the point of decision (#244), + `hidden` removes violating options and marks the hiding (#245), + `enforced` additionally rejects violating writes server-side (#246) — + hiding alone is UI cosmetics a scripted client bypasses. +2. **`off` is the default.** Outside a VS context the profile is not a + topic: no marking, no card, no behavioural difference. Existing + deployments upgrade without noticing the feature exists. +3. **The machine-readable catalog is the single source of truth** + (`packages/shared/src/vs-nfd-profile.ts`): every profile-relevant + setting with a decidable compliant value (a tiny predicate model — + equals, upper bound, non-empty) and its hardening-guide section. + Judgement-call entries ("only what the service needs") live in an + explicit advisory list. A fence test (pattern of the audit-catalogue + fence #201) parses the guide's reference tables and fails when a + switch is neither in the catalog nor in the advisory list — the guide + keeps its binding maintenance rule, the catalog can never silently lag. +4. **Pond-level opt-ins are deliberately absent** (`apiEnabled`, + `mcpEnabled`): their instance master switches govern; a pond opt-in + under a compliant master switch cannot violate the profile on its own. + The evaluation and enforcement paths are scope-generic, so pond + entries can be added if that judgement changes. +5. **Exposure first** (#243): the api evaluates the catalog against the + typed settings registry and the validated env and reports mode + + verdict on `GET /admin/system/vs-nfd-profile`; the admin settings view + shows the card whenever the mode is not `off`. The three treatments + build on exactly this evaluation. + +## Consequences + +- The guide gains a second consumer: every new switch line must be + triaged (catalog or advisory) or CI fails — a deliberate speed bump, + same trade-off as the audit-catalogue fence. +- Deploy-level means changing the mode requires a deployment change and + an api restart; that is the point (role separation, operations + handbook §6). +- The verdict describes decidable entries only; advisory entries remain + an assessor's reading of the guide. The card says what deviates, the + guide says why it matters. + +## Implementing issues + +#243 (mode, catalog, fence, exposure), #244 (`marked`), #245 (`hidden`), +#246 (`enforced`). diff --git a/docs/vs-nfd/50-haertungsleitfaden.md b/docs/vs-nfd/50-haertungsleitfaden.md index 662c1bb..19904da 100644 --- a/docs/vs-nfd/50-haertungsleitfaden.md +++ b/docs/vs-nfd/50-haertungsleitfaden.md @@ -47,17 +47,18 @@ Settings-Cache ist in-process (operations.md). ### 1.2 Deploy-Konfiguration (`.env` / Compose — nur Plattformzugriff, bewusst nicht per Admin-UI) -| Variable | Referenzwert | Warum | -| --------------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | -| `BACKUP_ALLOWED_TARGETS` | leer lassen **oder** exakt der eine freigegebene Spiegel-Host | leere Allowlist schaltet ALLE Fernziele hart ab (ADR 0026, #192) — „Backup nur lokal" ist damit deploy-seitig erzwungen und vom Site-Admin nicht aufweichbar (Rollentrennung, Betriebshandbuch §6). | -| `SESSION_ABSOLUTE_HOURS` | `12` (Default 168) | eine Sitzung überdauert keinen Arbeitstag; Neuanmeldung am nächsten Tag ist der Preis. | -| `SESSION_IDLE_HOURS` | `2` (Default 72) | unbeaufsichtigte, noch angemeldete Arbeitsplätze fallen schnell zurück auf die Anmeldemaske. | -| `SMTP_HOST` etc. | **unkonfiguriert lassen** (oder internes Relay) | ohne SMTP verlassen keinerlei Inhaltstitel die Instanz per Mail (Digest-Restrisiko I-23 entfällt vollständig). Konsequenz ehrlich benannt: dann gibt es keine Verifikations- und Passwort-Reset-Mails — Kontenpflege läuft über den Site-Admin. Wer Mail braucht, nutzt ein internes Relay und akzeptiert I-23 (Restrisikoliste). | -| `WEB_PORT`/`API_PORT`/`COLLAB_PORT` | Defaults (127.0.0.1-gebunden) | Anwendungscontainer sind nie direkt exponiert; einzige Eintrittsstelle ist der Reverse Proxy (Sicherheitsdokumentation §2). | -| `LOG_LEVEL` | `info` | Audit-Zeilen (`audit: `-Präfix) müssen den Collector erreichen; `debug` nur zur Störungssuche. | -| `OIDC_ISSUER`, `OIDC_CLIENT_ID`, `OIDC_CLIENT_SECRET`, `OIDC_SCOPES`, `OIDC_PROVIDER_LABEL` | IdP der Behörde konfigurieren | Fremdauthentisierung (#214, ADR 0021): Authorization Code + PKCE gegen den IdP der Umgebung; deploy-seitig, weil die Authentisierungshoheit Plattformsache ist. Erst-Login legt Konten just-in-time an; bestehende lokale Konten werden NIE stillschweigend per E-Mail übernommen (expliziter Link-Flow). Konfigurationsdetails: security.md §External authentication. | -| `AUTH_PROXY_HEADER`, `AUTH_PROXY_TRUSTED_PEERS`, `AUTH_PROXY_MAP`, `AUTH_PROXY_MODE`, `AUTH_PROXY_DN_ATTRIBUTE` | nur bei Perimeter-Authentisierung setzen | Alternativpfad (#215): Identität aus dem Proxy-Header, gültig NUR vom TCP-Peer der Allowlist; fremder Peer mit Header wird abgewiesen und auditiert (`auth.proxy_rejected`). Der Proxy MUSS den Header aus eingehendem Verkehr strippen. mTLS-Variante über weitergereichten Zertifikats-DN (`mtls-dn`). Ohne Perimeter-Auth: unkonfiguriert lassen (Header wirkungslos). | -| `AUTH_LOCAL_ENABLED` | `false` (Default `true`) | **der** harte Schalter aus ADR 0021 (#216, deploy-seitige Realisierung von `auth.local.enabled`): `false` schaltet JEDEN lokalen Credential-Flow auf 404 (Login, Signup, Verifikation, Passwort vergessen/zuruecksetzen/aendern) — Anmeldung ausschliesslich ueber OIDC (#214) bzw. Perimeter-Auth (#215). Deploy-seitig, damit ein kompromittierter Site-Admin ihn nicht zurueckdrehen kann. Reihenfolge: erst Setup/Bootstrap (SETUP_ADMIN_*), dann auf `false`. PAT-/Feed-Token-Ausgabe bleibt fuer IdP-authentisierte Nutzer verfuegbar (eigene Schalter `api.enabled`/`feeds.enabled`; bewusste Entscheidung #216). | +| Variable | Referenzwert | Warum | +| --------------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | +| `BACKUP_ALLOWED_TARGETS` | leer lassen **oder** exakt der eine freigegebene Spiegel-Host | leere Allowlist schaltet ALLE Fernziele hart ab (ADR 0026, #192) — „Backup nur lokal" ist damit deploy-seitig erzwungen und vom Site-Admin nicht aufweichbar (Rollentrennung, Betriebshandbuch §6). | +| `SESSION_ABSOLUTE_HOURS` | `12` (Default 168) | eine Sitzung überdauert keinen Arbeitstag; Neuanmeldung am nächsten Tag ist der Preis. | +| `SESSION_IDLE_HOURS` | `2` (Default 72) | unbeaufsichtigte, noch angemeldete Arbeitsplätze fallen schnell zurück auf die Anmeldemaske. | +| `SMTP_HOST` etc. | **unkonfiguriert lassen** (oder internes Relay) | ohne SMTP verlassen keinerlei Inhaltstitel die Instanz per Mail (Digest-Restrisiko I-23 entfällt vollständig). Konsequenz ehrlich benannt: dann gibt es keine Verifikations- und Passwort-Reset-Mails — Kontenpflege läuft über den Site-Admin. Wer Mail braucht, nutzt ein internes Relay und akzeptiert I-23 (Restrisikoliste). | +| `WEB_PORT`/`API_PORT`/`COLLAB_PORT` | Defaults (127.0.0.1-gebunden) | Anwendungscontainer sind nie direkt exponiert; einzige Eintrittsstelle ist der Reverse Proxy (Sicherheitsdokumentation §2). | +| `LOG_LEVEL` | `info` | Audit-Zeilen (`audit: `-Präfix) müssen den Collector erreichen; `debug` nur zur Störungssuche. | +| `OIDC_ISSUER`, `OIDC_CLIENT_ID`, `OIDC_CLIENT_SECRET`, `OIDC_SCOPES`, `OIDC_PROVIDER_LABEL` | IdP der Behörde konfigurieren | Fremdauthentisierung (#214, ADR 0021): Authorization Code + PKCE gegen den IdP der Umgebung; deploy-seitig, weil die Authentisierungshoheit Plattformsache ist. Erst-Login legt Konten just-in-time an; bestehende lokale Konten werden NIE stillschweigend per E-Mail übernommen (expliziter Link-Flow). Konfigurationsdetails: security.md §External authentication. | +| `AUTH_PROXY_HEADER`, `AUTH_PROXY_TRUSTED_PEERS`, `AUTH_PROXY_MAP`, `AUTH_PROXY_MODE`, `AUTH_PROXY_DN_ATTRIBUTE` | nur bei Perimeter-Authentisierung setzen | Alternativpfad (#215): Identität aus dem Proxy-Header, gültig NUR vom TCP-Peer der Allowlist; fremder Peer mit Header wird abgewiesen und auditiert (`auth.proxy_rejected`). Der Proxy MUSS den Header aus eingehendem Verkehr strippen. mTLS-Variante über weitergereichten Zertifikats-DN (`mtls-dn`). Ohne Perimeter-Auth: unkonfiguriert lassen (Header wirkungslos). | +| `AUTH_LOCAL_ENABLED` | `false` (Default `true`) | **der** harte Schalter aus ADR 0021 (#216, deploy-seitige Realisierung von `auth.local.enabled`): `false` schaltet JEDEN lokalen Credential-Flow auf 404 (Login, Signup, Verifikation, Passwort vergessen/zuruecksetzen/aendern) — Anmeldung ausschliesslich ueber OIDC (#214) bzw. Perimeter-Auth (#215). Deploy-seitig, damit ein kompromittierter Site-Admin ihn nicht zurueckdrehen kann. Reihenfolge: erst Setup/Bootstrap (SETUP_ADMIN_*), dann auf `false`. PAT-/Feed-Token-Ausgabe bleibt fuer IdP-authentisierte Nutzer verfuegbar (eigene Schalter `api.enabled`/`feeds.enabled`; bewusste Entscheidung #216). | +| `VS_NFD_MODE` | `marked` (Default `off`; Ziel `enforced`) | Härtungsprofil-Modus (#243, ADR 0027): die Anwendung kennt diese Referenzkonfiguration als maschinenlesbaren Katalog und bewertet die laufende Konfiguration dagegen (Site-Admin → Einstellungen). `marked` markiert Abweichungen, `hidden` blendet abweichende Optionen aus, `enforced` weist abweichende Schreibzugriffe serverseitig ab — die drei Behandlungsstufen rollen mit #244–#246 aus; bis dahin ist `marked` der empfohlene Wert (reine Anzeige). Deploy-seitig wie `BACKUP_ALLOWED_TARGETS`: ein kompromittierter Site-Admin kann den Modus nicht aufweichen. Außerhalb von VS-Kontexten bleibt der Default `off` — keinerlei Markierung. | ### 1.3 Noch nicht verfügbar (Regel: landet hier im selben PR) diff --git a/packages/shared/i18n/de/settings.json b/packages/shared/i18n/de/settings.json index 630d92e..a10e27b 100644 --- a/packages/shared/i18n/de/settings.json +++ b/packages/shared/i18n/de/settings.json @@ -56,7 +56,22 @@ "uploadPolicy": "Datei-Uploads auf eingestufte Seiten", "uploadPolicyHelp": "Anhänge erben die Einstufung der Seite, tragen selbst aber keine Kennzeichnung im Inhalt. „Blockieren\" lehnt Uploads auf eingestufte Seiten serverseitig ab.", "uploadPolicyWarn": "Warnen — Upload mit deutlichem Hinweis erlauben", - "uploadPolicyBlock": "Blockieren — Uploads auf eingestufte Seiten ablehnen" + "uploadPolicyBlock": "Blockieren — Uploads auf eingestufte Seiten ablehnen", + "vsNfd": { + "title": "VS-NfD-Profil", + "intro": "Das Deployment vergleicht die laufende Konfiguration mit der Referenzkonfiguration des Härtungsleitfadens (Deploy-Variable VS_NFD_MODE).", + "modeLabel": "Aktiver Modus", + "modes": { + "marked": "markiert — Abweichungen werden angezeigt", + "hidden": "ausgeblendet — abweichende Optionen werden nicht angeboten", + "enforced": "erzwungen — abweichende Konfiguration wird serverseitig abgewiesen" + }, + "compliant": "Die laufende Konfiguration entspricht der Referenzkonfiguration.", + "violations_one": "{{count}} Einstellung weicht von der Referenzkonfiguration ab:", + "violations_other": "{{count}} Einstellungen weichen von der Referenzkonfiguration ab:", + "referenceValue": "Referenzwert: {{value}}", + "guideRef": "Härtungsleitfaden §{{section}}" + } }, "landing": { "title": "Startseite", diff --git a/packages/shared/i18n/en/settings.json b/packages/shared/i18n/en/settings.json index b84f7ea..d223c41 100644 --- a/packages/shared/i18n/en/settings.json +++ b/packages/shared/i18n/en/settings.json @@ -56,7 +56,22 @@ "uploadPolicy": "File uploads to classified pages", "uploadPolicyHelp": "Attachments inherit the page’s classification but carry no marking in their content. “Block” rejects uploads to classified pages server-side.", "uploadPolicyWarn": "Warn — allow the upload with a clear notice", - "uploadPolicyBlock": "Block — reject uploads to classified pages" + "uploadPolicyBlock": "Block — reject uploads to classified pages", + "vsNfd": { + "title": "VS-NfD profile", + "intro": "The deployment compares the running configuration against the hardening guide's reference configuration (deploy variable VS_NFD_MODE).", + "modeLabel": "Active mode", + "modes": { + "marked": "marked — deviations are shown", + "hidden": "hidden — deviating options are not offered", + "enforced": "enforced — deviating configuration is rejected server-side" + }, + "compliant": "The running configuration matches the reference configuration.", + "violations_one": "{{count}} setting deviates from the reference configuration:", + "violations_other": "{{count}} settings deviate from the reference configuration:", + "referenceValue": "Reference value: {{value}}", + "guideRef": "Hardening guide §{{section}}" + } }, "landing": { "title": "Landing page", diff --git a/packages/shared/src/env.ts b/packages/shared/src/env.ts index 7daaf3f..ec2ffd7 100644 --- a/packages/shared/src/env.ts +++ b/packages/shared/src/env.ts @@ -1,6 +1,7 @@ import { z } from 'zod'; import { parseBackupTargetAllowlist } from './backup-target-policy'; +import { vsNfdModeSchema } from './vs-nfd-profile'; /** * Environment schemas live here so api, collab, and tooling validate their @@ -208,6 +209,14 @@ export const apiEnvSchema = z.object({ * proxy forwards it) and the identity is the configured attribute. */ AUTH_PROXY_MODE: z.enum(['plain', 'mtls-dn']).default('plain'), AUTH_PROXY_DN_ATTRIBUTE: z.string().min(1).default('CN'), + /** + * How the UI and API treat configuration options violating the VS-NfD + * reference profile (issue #243, ADR 0027): off | marked | hidden | + * enforced. Default `off` — outside a VS context the profile is not a + * topic and nothing is marked. Deploy-level like BACKUP_ALLOWED_TARGETS: + * a compromised Site Admin must not be able to widen the mode. + */ + VS_NFD_MODE: vsNfdModeSchema.default('off'), }); export type ApiEnv = z.infer; diff --git a/packages/shared/src/index.ts b/packages/shared/src/index.ts index e7c2c81..6bc9efa 100644 --- a/packages/shared/src/index.ts +++ b/packages/shared/src/index.ts @@ -36,4 +36,5 @@ export * from './quotas'; export * from './text-diff'; export * from './theme'; export * from './tree'; +export * from './vs-nfd-profile'; export * from './watches'; diff --git a/packages/shared/src/vs-nfd-profile.test.ts b/packages/shared/src/vs-nfd-profile.test.ts new file mode 100644 index 0000000..95b402c --- /dev/null +++ b/packages/shared/src/vs-nfd-profile.test.ts @@ -0,0 +1,59 @@ +import { describe, expect, it } from 'vitest'; + +import { + VS_NFD_PROFILE, + VS_NFD_PROFILE_ADVISORY, + describeCompliance, + isVsNfdCompliant, + vsNfdModeSchema, +} from './vs-nfd-profile'; + +describe('vsNfdModeSchema (issue #243)', () => { + it('accepts the four modes and rejects anything else', () => { + for (const mode of ['off', 'marked', 'hidden', 'enforced']) { + expect(vsNfdModeSchema.parse(mode)).toBe(mode); + } + expect(() => vsNfdModeSchema.parse('strict')).toThrow(); + }); +}); + +describe('isVsNfdCompliant', () => { + const entry = (compliance: (typeof VS_NFD_PROFILE)[number]['compliance']) => + ({ scope: 'instance', key: 'x', compliance, hardeningRef: '1.1' }) as const; + + it('equals compares strictly — no truthiness shortcuts', () => { + expect(isVsNfdCompliant(entry({ kind: 'equals', value: false }), false)).toBe(true); + expect(isVsNfdCompliant(entry({ kind: 'equals', value: false }), 'false')).toBe(false); + expect(isVsNfdCompliant(entry({ kind: 'equals', value: 'closed' }), 'closed')).toBe(true); + expect(isVsNfdCompliant(entry({ kind: 'equals', value: 'closed' }), 'open')).toBe(false); + }); + + it('maxNumber bounds numeric values and rejects non-numbers', () => { + expect(isVsNfdCompliant(entry({ kind: 'maxNumber', value: 12 }), 12)).toBe(true); + expect(isVsNfdCompliant(entry({ kind: 'maxNumber', value: 12 }), 13)).toBe(false); + expect(isVsNfdCompliant(entry({ kind: 'maxNumber', value: 12 }), '2')).toBe(false); + }); + + it('nonEmpty requires actual text', () => { + expect(isVsNfdCompliant(entry({ kind: 'nonEmpty' }), 'Impressum')).toBe(true); + expect(isVsNfdCompliant(entry({ kind: 'nonEmpty' }), ' ')).toBe(false); + expect(isVsNfdCompliant(entry({ kind: 'nonEmpty' }), undefined)).toBe(false); + }); +}); + +describe('catalog shape', () => { + it('lists every key at most once per scope, disjoint from the advisory list', () => { + const keyOf = (e: { scope: string; key: string }) => `${e.scope}:${e.key}`; + const catalog = VS_NFD_PROFILE.map(keyOf); + const advisory = VS_NFD_PROFILE_ADVISORY.map(keyOf); + expect(new Set(catalog).size).toBe(catalog.length); + expect(new Set(advisory).size).toBe(advisory.length); + expect(catalog.filter((key) => advisory.includes(key))).toEqual([]); + }); + + it('describes every predicate for display', () => { + for (const entry of VS_NFD_PROFILE) { + expect(describeCompliance(entry.compliance)).not.toBe(''); + } + }); +}); diff --git a/packages/shared/src/vs-nfd-profile.ts b/packages/shared/src/vs-nfd-profile.ts new file mode 100644 index 0000000..fd7d61e --- /dev/null +++ b/packages/shared/src/vs-nfd-profile.ts @@ -0,0 +1,225 @@ +import { z } from 'zod'; + +/** + * The VS-NfD hardening-profile mode and the machine-readable configuration + * catalog (issue #243, ADR 0027). + * + * The deployment declares through `VS_NFD_MODE` how the application treats + * configuration options that violate the VS-NfD reference profile + * (docs/vs-nfd/50-haertungsleitfaden.md): + * + * - `off` — VS-NfD is not a topic; no marking anywhere (the default). + * - `marked` — options stay available, violations are marked (#244). + * - `hidden` — violating options disappear, the hiding is marked (#245). + * - `enforced` — violating writes are rejected server-side (#246). + * + * Deploy-level (env) on purpose, like BACKUP_ALLOWED_TARGETS (#192): a + * compromised Site Admin must not be able to widen the mode at runtime. + * + * The catalog below is the single source of truth for "profile-relevant": + * every entry names the setting, the machine-checkable compliant value and + * the hardening-guide section it comes from. A fence test keeps catalog + * and guide from drifting (vs-nfd-profile-catalogue.test.ts, pattern + * #201): every switch listed in the guide is either here or in the + * explicit advisory list — never silently absent. + */ +export const VS_NFD_MODES = ['off', 'marked', 'hidden', 'enforced'] as const; +export type VsNfdMode = (typeof VS_NFD_MODES)[number]; +export const vsNfdModeSchema = z.enum(VS_NFD_MODES); + +/** + * The decidable compliance predicates. Deliberately tiny: a catalog entry + * must be checkable without prose interpretation, or it belongs in the + * advisory list instead. + */ +export type VsNfdCompliance = + | { kind: 'equals'; value: boolean | string } + | { kind: 'maxNumber'; value: number } + | { kind: 'nonEmpty' }; + +export interface VsNfdProfileEntry { + /** `instance` = instance_settings key; `deploy` = env variable. */ + scope: 'instance' | 'deploy'; + key: string; + compliance: VsNfdCompliance; + /** Section of docs/vs-nfd/50-haertungsleitfaden.md the value comes from. */ + hardeningRef: '1.1' | '1.2'; +} + +/** + * Profile entries with a machine-checkable compliant value. Order mirrors + * the hardening guide. Pond-level opt-ins (`apiEnabled`, `mcpEnabled`) + * are deliberately absent: their instance master switches govern, so a + * pond opt-in cannot violate the profile on its own (ADR 0027). + */ +export const VS_NFD_PROFILE: readonly VsNfdProfileEntry[] = [ + { + scope: 'instance', + key: 'auth.registrationMode', + compliance: { kind: 'equals', value: 'closed' }, + hardeningRef: '1.1', + }, + { + scope: 'instance', + key: 'api.enabled', + compliance: { kind: 'equals', value: false }, + hardeningRef: '1.1', + }, + { + scope: 'instance', + key: 'mcp.enabled', + compliance: { kind: 'equals', value: false }, + hardeningRef: '1.1', + }, + { + scope: 'instance', + key: 'feeds.enabled', + compliance: { kind: 'equals', value: false }, + hardeningRef: '1.1', + }, + { + scope: 'instance', + key: 'plugins.enabled', + compliance: { kind: 'equals', value: false }, + hardeningRef: '1.1', + }, + { + scope: 'instance', + key: 'classification.newPageDefault', + compliance: { kind: 'equals', value: 'vs_nfd' }, + hardeningRef: '1.1', + }, + { + scope: 'instance', + key: 'classification.uploadPolicy', + compliance: { kind: 'equals', value: 'block' }, + hardeningRef: '1.1', + }, + { + scope: 'instance', + key: 'upload.svgPolicy', + compliance: { kind: 'equals', value: 'reject' }, + hardeningRef: '1.1', + }, + { + scope: 'instance', + key: 'backup.nextcloud.enabled', + compliance: { kind: 'equals', value: false }, + hardeningRef: '1.1', + }, + { + scope: 'instance', + key: 'readTrail.enabled', + compliance: { kind: 'equals', value: true }, + hardeningRef: '1.1', + }, + { + scope: 'instance', + key: 'legal.imprint', + compliance: { kind: 'nonEmpty' }, + hardeningRef: '1.1', + }, + { + scope: 'instance', + key: 'legal.privacyPolicy', + compliance: { kind: 'nonEmpty' }, + hardeningRef: '1.1', + }, + { + scope: 'deploy', + key: 'SESSION_ABSOLUTE_HOURS', + compliance: { kind: 'maxNumber', value: 12 }, + hardeningRef: '1.2', + }, + { + scope: 'deploy', + key: 'SESSION_IDLE_HOURS', + compliance: { kind: 'maxNumber', value: 2 }, + hardeningRef: '1.2', + }, + { + scope: 'deploy', + key: 'LOG_LEVEL', + compliance: { kind: 'equals', value: 'info' }, + hardeningRef: '1.2', + }, + { + scope: 'deploy', + key: 'AUTH_LOCAL_ENABLED', + compliance: { kind: 'equals', value: false }, + hardeningRef: '1.2', + }, +] as const; + +/** + * Guide entries whose reference value is a judgement call ("only what the + * service needs", "configure the agency IdP", "leave unset or one host") — + * profile-relevant, but not machine-checkable. Listed explicitly so the + * fence test still notices when the guide gains a switch nobody triaged. + */ +export const VS_NFD_PROFILE_ADVISORY: readonly { scope: 'instance' | 'deploy'; key: string }[] = [ + { scope: 'instance', key: 'upload.allowedExtensions' }, + { scope: 'instance', key: 'trash.retentionDays' }, + { scope: 'instance', key: 'audit.retentionDays' }, + { scope: 'instance', key: 'conversion.payloadRetentionDays' }, + { scope: 'instance', key: 'mail.outboxRetentionDays' }, + { scope: 'instance', key: 'readTrail.dedupWindowMinutes' }, + { scope: 'instance', key: 'readTrail.retentionDays' }, + { scope: 'instance', key: 'idpMapping.rules' }, + { scope: 'deploy', key: 'VS_NFD_MODE' }, + { scope: 'deploy', key: 'BACKUP_ALLOWED_TARGETS' }, + { scope: 'deploy', key: 'SMTP_HOST' }, + { scope: 'deploy', key: 'WEB_PORT' }, + { scope: 'deploy', key: 'API_PORT' }, + { scope: 'deploy', key: 'COLLAB_PORT' }, + { scope: 'deploy', key: 'OIDC_ISSUER' }, + { scope: 'deploy', key: 'OIDC_CLIENT_ID' }, + { scope: 'deploy', key: 'OIDC_CLIENT_SECRET' }, + { scope: 'deploy', key: 'OIDC_SCOPES' }, + { scope: 'deploy', key: 'OIDC_PROVIDER_LABEL' }, + { scope: 'deploy', key: 'AUTH_PROXY_HEADER' }, + { scope: 'deploy', key: 'AUTH_PROXY_TRUSTED_PEERS' }, + { scope: 'deploy', key: 'AUTH_PROXY_MAP' }, + { scope: 'deploy', key: 'AUTH_PROXY_MODE' }, + { scope: 'deploy', key: 'AUTH_PROXY_DN_ATTRIBUTE' }, +] as const; + +/** True when `value` satisfies the entry's compliance predicate. */ +export function isVsNfdCompliant(entry: VsNfdProfileEntry, value: unknown): boolean { + switch (entry.compliance.kind) { + case 'equals': + return value === entry.compliance.value; + case 'maxNumber': + return typeof value === 'number' && value <= entry.compliance.value; + case 'nonEmpty': + return typeof value === 'string' && value.trim().length > 0; + } +} + +/** One evaluated catalog row, as the admin endpoint reports it (#243). */ +export interface VsNfdProfileEntryView { + scope: 'instance' | 'deploy'; + key: string; + compliant: boolean; + /** The compliant value, rendered for display (booleans/numbers stringified). */ + compliantValue: string; + hardeningRef: '1.1' | '1.2'; +} + +export interface VsNfdProfileView { + mode: VsNfdMode; + entries: VsNfdProfileEntryView[]; + violations: number; +} + +/** Display form of a predicate — shared so api and web render it alike. */ +export function describeCompliance(compliance: VsNfdCompliance): string { + switch (compliance.kind) { + case 'equals': + return String(compliance.value); + case 'maxNumber': + return `<= ${compliance.value}`; + case 'nonEmpty': + return 'non-empty'; + } +} -- 2.45.2