#246: mode enforced — reject profile-violating configuration writes #295

Merged
fable-5 merged 1 commits from issue-246-vs-nfd-enforced into main 2026-07-31 20:05:34 +02:00
7 changed files with 213 additions and 19 deletions

View File

@ -1,10 +1,11 @@
import { BadRequestException, Injectable } from '@nestjs/common'; import { BadRequestException, ForbiddenException, Injectable } from '@nestjs/common';
import { DEFAULT_ATTACHMENT_EXTENSIONS } from '@dorfteich/shared'; import { DEFAULT_ATTACHMENT_EXTENSIONS, VS_NFD_PROFILE, isVsNfdCompliant } from '@dorfteich/shared';
import { Prisma } from '@prisma/client'; import { Prisma } from '@prisma/client';
import { PinoLogger } from 'nestjs-pino'; import { PinoLogger } from 'nestjs-pino';
import { z } from 'zod'; import { z } from 'zod';
import { AuditService } from '../audit/audit.service'; import { AuditService } from '../audit/audit.service';
import { AppConfig } from '../config/app-config.service';
import { PrismaService } from '../prisma/prisma.service'; import { PrismaService } from '../prisma/prisma.service';
/** /**
@ -201,6 +202,7 @@ export class InstanceSettingsService {
private readonly prisma: PrismaService, private readonly prisma: PrismaService,
private readonly audit: AuditService, private readonly audit: AuditService,
private readonly logger: PinoLogger, private readonly logger: PinoLogger,
private readonly config: AppConfig,
) { ) {
this.logger.setContext(InstanceSettingsService.name); this.logger.setContext(InstanceSettingsService.name);
} }
@ -237,6 +239,22 @@ export class InstanceSettingsService {
details: { [key]: parsed.error.issues.map((i) => i.message) }, details: { [key]: parsed.error.issues.map((i) => i.message) },
}); });
} }
// Mode `enforced` (#246, ADR 0027): a write that would set a
// catalog-violating value is rejected at the ONE write path every
// caller uses — hiding alone (#245) is UI cosmetics a scripted client
// bypasses. Existing violating values are reported (startup log,
// admin card), never auto-changed: the operator resolves them
// consciously, and writes that DECREASE compliance are what this
// blocks. 403, not 400: the request is well-formed, the policy says no.
if (this.config.env.VS_NFD_MODE === 'enforced') {
const entry = VS_NFD_PROFILE.find((e) => e.scope === 'instance' && e.key === key);
if (entry && !isVsNfdCompliant(entry, parsed.data)) {
throw new ForbiddenException({
code: 'vs_nfd_profile_violation',
details: { [key]: ['vs_nfd_profile_violation'] },
});
}
}
// Nullable settings (setup.completedAt) store JSON null explicitly — // Nullable settings (setup.completedAt) store JSON null explicitly —
// Prisma requires the sentinel for that. // Prisma requires the sentinel for that.
const stored = parsed.data === null ? Prisma.JsonNull : parsed.data; const stored = parsed.data === null ? Prisma.JsonNull : parsed.data;

View File

@ -0,0 +1,143 @@
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';
/**
* Mode `enforced` (issue #246, ADR 0027): the api rejects settings writes
* that would set a catalog-violating value with a stable error code
* while existing violations are reported and never auto-changed. The mode
* is env-fixed per boot, so each mode gets its own app (sequential
* describes; pattern vs-nfd-profile.e2e.db.test.ts).
*/
async function makeAdmin(
app: INestApplication,
prisma: PrismaClient,
suffix: string,
): Promise<string> {
const users = app.get(UsersService);
const username = `enf-admin-${suffix}`;
const user = await users.createUser({
username,
email: `${username}@example.org`,
displayName: 'Enf Admin',
password: 'erzwungen ist erzwungen 1',
locale: 'en',
});
await users.markEmailVerified(user.id);
await prisma.user.update({ where: { id: user.id }, data: { isSiteAdmin: true } });
return sessionCookieOf(
await request(app.getHttpServer())
.post('/api/v1/auth/login')
.send({ usernameOrEmail: username, password: 'erzwungen ist erzwungen 1' })
.expect(200),
);
}
describe.skipIf(!hasTestDb)('VS-NfD mode enforced (e2e, issue #246)', () => {
let app: INestApplication;
let prisma: PrismaClient;
let cookie: string;
const suffix = uniqueSuffix();
beforeAll(async () => {
process.env.VS_NFD_MODE = 'enforced';
prisma = createTestPrisma();
await prisma.rateLimit.deleteMany({});
// A violation that exists BEFORE the enforced boot: reported, never
// auto-changed (feeds.enabled default is true = violating anyway, but
// pin it as an explicit stored row).
await prisma.instanceSetting.upsert({
where: { key: 'feeds.enabled' },
create: { key: 'feeds.enabled', value: true },
update: { value: true },
});
app = await createTestApp();
cookie = await makeAdmin(app, prisma, suffix);
});
afterAll(async () => {
delete process.env.VS_NFD_MODE;
await prisma.instanceSetting.deleteMany({
where: { key: { in: ['feeds.enabled', 'auth.registrationMode', 'upload.svgPolicy'] } },
});
await prisma.user.deleteMany({ where: { username: { contains: suffix } } });
await prisma.$disconnect();
await app.close();
});
it('rejects a violating write with the stable error code', async () => {
const res = await request(app.getHttpServer())
.patch('/api/v1/admin/settings')
.set('Cookie', cookie)
.send({ 'upload.svgPolicy': 'sanitize' })
.expect(403);
expect(res.body.code).toBe('vs_nfd_profile_violation');
// Nothing was stored — the read still returns the schema default.
const settings = await request(app.getHttpServer())
.get('/api/v1/admin/settings')
.set('Cookie', cookie)
.expect(200);
expect(settings.body['upload.svgPolicy']).toBe('sanitize');
});
it('accepts compliant writes', async () => {
await request(app.getHttpServer())
.patch('/api/v1/admin/settings')
.set('Cookie', cookie)
.send({ 'auth.registrationMode': 'closed', 'upload.svgPolicy': 'reject' })
.expect(200);
});
it('reports the pre-existing violation and never auto-changes it', async () => {
const profile = (
await request(app.getHttpServer())
.get('/api/v1/admin/system/vs-nfd-profile')
.set('Cookie', cookie)
.expect(200)
).body as VsNfdProfileView;
expect(profile.mode).toBe('enforced');
expect(profile.entries.find((e) => e.key === 'feeds.enabled')!.compliant).toBe(false);
const settings = await request(app.getHttpServer())
.get('/api/v1/admin/settings')
.set('Cookie', cookie)
.expect(200);
expect(settings.body['feeds.enabled']).toBe(true);
});
});
describe.skipIf(!hasTestDb)('the same write passes outside enforced (issue #246)', () => {
let app: INestApplication;
let prisma: PrismaClient;
let cookie: string;
const suffix = uniqueSuffix();
beforeAll(async () => {
process.env.VS_NFD_MODE = 'hidden';
prisma = createTestPrisma();
await prisma.rateLimit.deleteMany({});
app = await createTestApp();
cookie = await makeAdmin(app, prisma, suffix);
});
afterAll(async () => {
delete process.env.VS_NFD_MODE;
await prisma.instanceSetting.deleteMany({ where: { key: 'upload.svgPolicy' } });
await prisma.user.deleteMany({ where: { username: { contains: suffix } } });
await prisma.$disconnect();
await app.close();
});
it('mode hidden: the violating write is NOT rejected (UI-level only)', async () => {
await request(app.getHttpServer())
.patch('/api/v1/admin/settings')
.set('Cookie', cookie)
.send({ 'upload.svgPolicy': 'sanitize' })
.expect(200);
});
});

View File

@ -93,6 +93,14 @@ describe.skipIf(!hasTestDb)('VS-NfD profile endpoint (e2e, issue #243)', () => {
expect(view.violations).toBeGreaterThan(0); expect(view.violations).toBeGreaterThan(0);
}); });
it('mode marked: a violating write is NOT rejected (issue #246 contrast)', async () => {
await api()
.patch('/api/v1/admin/settings')
.set('Cookie', cookies.admin!)
.send({ 'upload.svgPolicy': 'sanitize' })
.expect(200);
});
it('verdict follows a settings change', async () => { it('verdict follows a settings change', async () => {
const settings = app.get(InstanceSettingsService); const settings = app.get(InstanceSettingsService);
const before = ( const before = (

View File

@ -1,4 +1,5 @@
import { Injectable } from '@nestjs/common'; import { Injectable, type OnModuleInit } from '@nestjs/common';
import { PinoLogger } from 'nestjs-pino';
import { import {
VS_NFD_PROFILE, VS_NFD_PROFILE,
describeCompliance, describeCompliance,
@ -20,11 +21,33 @@ import { InstanceSettingsService, type InstanceSettings } from './instance-setti
* (#244#246) build on this evaluation; here it is exposure only. * (#244#246) build on this evaluation; here it is exposure only.
*/ */
@Injectable() @Injectable()
export class VsNfdProfileService { export class VsNfdProfileService implements OnModuleInit {
constructor( constructor(
private readonly settings: InstanceSettingsService, private readonly settings: InstanceSettingsService,
private readonly config: AppConfig, private readonly config: AppConfig,
) {} private readonly logger: PinoLogger,
) {
this.logger.setContext(VsNfdProfileService.name);
}
/** Startup report (#246): existing violations are stated once, never
* auto-changed the operator resolves them consciously. Must not throw
* on a database-less boot (healthz suites boot without a db). */
async onModuleInit(): Promise<void> {
if (this.config.env.VS_NFD_MODE === 'off') return;
try {
const view = await this.evaluate();
this.logger.info(
{
mode: view.mode,
violations: view.entries.filter((e) => !e.compliant).map((e) => e.key),
},
'VS-NfD profile verdict at startup',
);
} catch {
// Stated at the next successful evaluation instead.
}
}
private valueOf(entry: VsNfdProfileEntry, settings: InstanceSettings): unknown { private valueOf(entry: VsNfdProfileEntry, settings: InstanceSettings): unknown {
return entry.scope === 'instance' return entry.scope === 'instance'

View File

@ -47,18 +47,18 @@ Settings-Cache ist in-process (operations.md).
### 1.2 Deploy-Konfiguration (`.env` / Compose — nur Plattformzugriff, bewusst nicht per Admin-UI) ### 1.2 Deploy-Konfiguration (`.env` / Compose — nur Plattformzugriff, bewusst nicht per Admin-UI)
| Variable | Referenzwert | Warum | | 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). | | `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_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. | | `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). | | `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). | | `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. | | `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. | | `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_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). | | `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. | | `VS_NFD_MODE` | `enforced` (Default `off`) | 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 — alle drei Behandlungsstufen sind umgesetzt (#244#246); für den VS-NfD-Referenzbetrieb ist `enforced` der empfohlene Modus — nur er hält auch skriptgesteuerte Clients an das Profil. Bestehende Abweichungen werden beim Start und in der Admin-Ansicht gemeldet, nie automatisch geändert. 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) ### 1.3 Noch nicht verfügbar (Regel: landet hier im selben PR)

View File

@ -111,5 +111,6 @@
"scope_required": "Dieses API-Token hat nicht den erforderlichen Scope.", "scope_required": "Dieses API-Token hat nicht den erforderlichen Scope.",
"pond_not_found": "Der Teich existiert nicht.", "pond_not_found": "Der Teich existiert nicht.",
"classification_lower_forbidden": "Zum Herabstufen der Einstufung fehlt die Berechtigung (Teich-Admin erforderlich).", "classification_lower_forbidden": "Zum Herabstufen der Einstufung fehlt die Berechtigung (Teich-Admin erforderlich).",
"classified_upload_blocked": "Uploads auf eingestufte Seiten sind auf dieser Instanz blockiert." "classified_upload_blocked": "Uploads auf eingestufte Seiten sind auf dieser Instanz blockiert.",
"vs_nfd_profile_violation": "Diese Einstellung würde vom VS-NfD-Referenzprofil abweichen — das Deployment erzwingt das Profil (VS_NFD_MODE=enforced)."
} }

View File

@ -111,5 +111,6 @@
"scope_required": "This API token does not have the required scope.", "scope_required": "This API token does not have the required scope.",
"pond_not_found": "The pond does not exist.", "pond_not_found": "The pond does not exist.",
"classification_lower_forbidden": "You lack the permission to lower the classification (Pond Admin required).", "classification_lower_forbidden": "You lack the permission to lower the classification (Pond Admin required).",
"classified_upload_blocked": "Uploads to classified pages are blocked on this instance." "classified_upload_blocked": "Uploads to classified pages are blocked on this instance.",
"vs_nfd_profile_violation": "This setting would deviate from the VS-NfD reference profile — the deployment enforces the profile (VS_NFD_MODE=enforced)."
} }