diff --git a/apps/api/src/settings/instance-settings.service.ts b/apps/api/src/settings/instance-settings.service.ts index e1755f2..d69a71b 100644 --- a/apps/api/src/settings/instance-settings.service.ts +++ b/apps/api/src/settings/instance-settings.service.ts @@ -1,10 +1,11 @@ -import { BadRequestException, Injectable } from '@nestjs/common'; -import { DEFAULT_ATTACHMENT_EXTENSIONS } from '@dorfteich/shared'; +import { BadRequestException, ForbiddenException, Injectable } from '@nestjs/common'; +import { DEFAULT_ATTACHMENT_EXTENSIONS, VS_NFD_PROFILE, isVsNfdCompliant } from '@dorfteich/shared'; import { Prisma } from '@prisma/client'; import { PinoLogger } from 'nestjs-pino'; import { z } from 'zod'; import { AuditService } from '../audit/audit.service'; +import { AppConfig } from '../config/app-config.service'; import { PrismaService } from '../prisma/prisma.service'; /** @@ -201,6 +202,7 @@ export class InstanceSettingsService { private readonly prisma: PrismaService, private readonly audit: AuditService, private readonly logger: PinoLogger, + private readonly config: AppConfig, ) { this.logger.setContext(InstanceSettingsService.name); } @@ -237,6 +239,22 @@ export class InstanceSettingsService { 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 — // Prisma requires the sentinel for that. const stored = parsed.data === null ? Prisma.JsonNull : parsed.data; diff --git a/apps/api/src/settings/vs-nfd-enforced.e2e.db.test.ts b/apps/api/src/settings/vs-nfd-enforced.e2e.db.test.ts new file mode 100644 index 0000000..b89cb65 --- /dev/null +++ b/apps/api/src/settings/vs-nfd-enforced.e2e.db.test.ts @@ -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 { + 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); + }); +}); 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 index 2ac9f89..ec3d5e3 100644 --- a/apps/api/src/settings/vs-nfd-profile.e2e.db.test.ts +++ b/apps/api/src/settings/vs-nfd-profile.e2e.db.test.ts @@ -93,6 +93,14 @@ describe.skipIf(!hasTestDb)('VS-NfD profile endpoint (e2e, issue #243)', () => { 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 () => { const settings = app.get(InstanceSettingsService); const before = ( diff --git a/apps/api/src/settings/vs-nfd-profile.service.ts b/apps/api/src/settings/vs-nfd-profile.service.ts index b6d6a91..19aeb66 100644 --- a/apps/api/src/settings/vs-nfd-profile.service.ts +++ b/apps/api/src/settings/vs-nfd-profile.service.ts @@ -1,4 +1,5 @@ -import { Injectable } from '@nestjs/common'; +import { Injectable, type OnModuleInit } from '@nestjs/common'; +import { PinoLogger } from 'nestjs-pino'; import { VS_NFD_PROFILE, describeCompliance, @@ -20,11 +21,33 @@ import { InstanceSettingsService, type InstanceSettings } from './instance-setti * (#244–#246) build on this evaluation; here it is exposure only. */ @Injectable() -export class VsNfdProfileService { +export class VsNfdProfileService implements OnModuleInit { constructor( private readonly settings: InstanceSettingsService, 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 { + 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 { return entry.scope === 'instance' diff --git a/docs/vs-nfd/50-haertungsleitfaden.md b/docs/vs-nfd/50-haertungsleitfaden.md index 19904da..04d30d4 100644 --- a/docs/vs-nfd/50-haertungsleitfaden.md +++ b/docs/vs-nfd/50-haertungsleitfaden.md @@ -47,18 +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). | -| `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. | +| 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` | `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) diff --git a/packages/shared/i18n/de/errors.json b/packages/shared/i18n/de/errors.json index bd32ec2..9a87a15 100644 --- a/packages/shared/i18n/de/errors.json +++ b/packages/shared/i18n/de/errors.json @@ -111,5 +111,6 @@ "scope_required": "Dieses API-Token hat nicht den erforderlichen Scope.", "pond_not_found": "Der Teich existiert nicht.", "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)." } diff --git a/packages/shared/i18n/en/errors.json b/packages/shared/i18n/en/errors.json index 4d4c07c..901a981 100644 --- a/packages/shared/i18n/en/errors.json +++ b/packages/shared/i18n/en/errors.json @@ -111,5 +111,6 @@ "scope_required": "This API token does not have the required scope.", "pond_not_found": "The pond does not exist.", "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)." }