#243: VS_NFD_MODE and the machine-readable hardening-profile catalog
All checks were successful
CI / Lint, typecheck, test (pull_request) Successful in 6m12s
CI / Build container images (pull_request) Successful in 4m2s
CI / Auth e2e pack (pull_request) Successful in 8m29s
CI / Import/export fidelity gate (pull_request) Successful in 54s
CD / Build and push images (push) Successful in 31s
CD / Deploy to Test (push) Successful in 14s
CD / Smoke tests against Test (push) Successful in 1m29s
CD / Promote to Int (push) Successful in 14s
CI / Build container images (push) Has been skipped
CI / Lint, typecheck, test (push) Successful in 6m30s
CI / Auth e2e pack (push) Successful in 8m6s
CI / Import/export fidelity gate (push) Successful in 57s

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 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01AUtYMxwTCMHG9mVHnwbFg8
This commit is contained in:
Claude Fable 5 2026-07-31 18:24:04 +02:00
parent 18239e2fa9
commit da5fd7c770
16 changed files with 698 additions and 17 deletions

View File

@ -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<VsNfdProfileView> {
return this.vsNfdProfile.evaluate();
}
@Get('jobs')
async jobs(): Promise<SystemJobView[]> {

View File

@ -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 {}

View File

@ -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'));
});
});

View File

@ -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<string, string> = {};
const cookies: Record<string, string> = {};
const api = () => request(app.getHttpServer());
async function makeUser(handle: string, siteAdmin: boolean): Promise<void> {
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);
});
});

View File

@ -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<VsNfdProfileView> {
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,
};
}
}

View File

@ -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 {
<Link to="/admin/system">{t('system:settingsLink')} </Link>
</p>
<SettingsLayout>
<VsNfdProfileSection />
<section className="settings-section">
<h2>{t('settings:admin.general')}</h2>
<form onSubmit={onSubmit} noValidate>
@ -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<VsNfdProfileView>('/admin/system/vs-nfd-profile'),
});
if (!profile.data || profile.data.mode === 'off') return null;
const violations = profile.data.entries.filter((entry) => !entry.compliant);
return (
<section className="settings-section">
<h2>{t('admin.vsNfd.title')}</h2>
<p>{t('admin.vsNfd.intro')}</p>
<p>
{t('admin.vsNfd.modeLabel')}: <strong>{t(`admin.vsNfd.modes.${profile.data.mode}`)}</strong>
</p>
{violations.length === 0 ? (
<p>{t('admin.vsNfd.compliant')}</p>
) : (
<>
<p>{t('admin.vsNfd.violations', { count: violations.length })}</p>
<ul>
{violations.map((entry) => (
<li key={`${entry.scope}:${entry.key}`}>
<code>{entry.key}</code> {' '}
{t('admin.vsNfd.referenceValue', { value: entry.compliantValue })} (
{t('admin.vsNfd.guideRef', { section: entry.hardeningRef })})
</li>
))}
</ul>
</>
)}
</section>
);
}
/**
* 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

View File

@ -105,6 +105,17 @@ SMTP_FROM=Dorfteich <wiki@example.com>
# 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.

View File

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

View File

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

View File

@ -48,7 +48,7 @@ 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. |
@ -58,6 +58,7 @@ Settings-Cache ist in-process (operations.md).
| `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)

View File

@ -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",

View File

@ -56,7 +56,22 @@
"uploadPolicy": "File uploads to classified pages",
"uploadPolicyHelp": "Attachments inherit the pages 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",

View File

@ -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<typeof apiEnvSchema>;

View File

@ -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';

View File

@ -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('');
}
});
});

View File

@ -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';
}
}