#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
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:
parent
18239e2fa9
commit
da5fd7c770
@ -10,10 +10,12 @@ import {
|
|||||||
type StorageOverviewView,
|
type StorageOverviewView,
|
||||||
type SystemBackupView,
|
type SystemBackupView,
|
||||||
type SystemJobView,
|
type SystemJobView,
|
||||||
|
type VsNfdProfileView,
|
||||||
} from '@dorfteich/shared';
|
} from '@dorfteich/shared';
|
||||||
|
|
||||||
import { AuthedRequest } from '../auth/auth.guard';
|
import { AuthedRequest } from '../auth/auth.guard';
|
||||||
import { ZodValidationPipe } from '../common/zod-validation.pipe';
|
import { ZodValidationPipe } from '../common/zod-validation.pipe';
|
||||||
|
import { VsNfdProfileService } from '../settings/vs-nfd-profile.service';
|
||||||
import { SiteAdminGuard } from './site-admin.guard';
|
import { SiteAdminGuard } from './site-admin.guard';
|
||||||
import { SystemAdminService } from './system-admin.service';
|
import { SystemAdminService } from './system-admin.service';
|
||||||
|
|
||||||
@ -21,7 +23,17 @@ import { SystemAdminService } from './system-admin.service';
|
|||||||
@Controller('admin/system')
|
@Controller('admin/system')
|
||||||
@UseGuards(SiteAdminGuard)
|
@UseGuards(SiteAdminGuard)
|
||||||
export class SystemAdminController {
|
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')
|
@Get('jobs')
|
||||||
async jobs(): Promise<SystemJobView[]> {
|
async jobs(): Promise<SystemJobView[]> {
|
||||||
|
|||||||
@ -1,11 +1,12 @@
|
|||||||
import { Global, Module } from '@nestjs/common';
|
import { Global, Module } from '@nestjs/common';
|
||||||
|
|
||||||
import { InstanceSettingsService } from './instance-settings.service';
|
import { InstanceSettingsService } from './instance-settings.service';
|
||||||
|
import { VsNfdProfileService } from './vs-nfd-profile.service';
|
||||||
|
|
||||||
/** Global: instance configuration is read across many feature modules. */
|
/** Global: instance configuration is read across many feature modules. */
|
||||||
@Global()
|
@Global()
|
||||||
@Module({
|
@Module({
|
||||||
providers: [InstanceSettingsService],
|
providers: [InstanceSettingsService, VsNfdProfileService],
|
||||||
exports: [InstanceSettingsService],
|
exports: [InstanceSettingsService, VsNfdProfileService],
|
||||||
})
|
})
|
||||||
export class SettingsModule {}
|
export class SettingsModule {}
|
||||||
|
|||||||
54
apps/api/src/settings/vs-nfd-profile-catalogue.test.ts
Normal file
54
apps/api/src/settings/vs-nfd-profile-catalogue.test.ts
Normal 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'));
|
||||||
|
});
|
||||||
|
});
|
||||||
114
apps/api/src/settings/vs-nfd-profile.e2e.db.test.ts
Normal file
114
apps/api/src/settings/vs-nfd-profile.e2e.db.test.ts
Normal 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);
|
||||||
|
});
|
||||||
|
});
|
||||||
50
apps/api/src/settings/vs-nfd-profile.service.ts
Normal file
50
apps/api/src/settings/vs-nfd-profile.service.ts
Normal 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,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -1,5 +1,5 @@
|
|||||||
import { useQuery, useQueryClient } from '@tanstack/react-query';
|
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 { useState } from 'react';
|
||||||
import { useForm } from 'react-hook-form';
|
import { useForm } from 'react-hook-form';
|
||||||
import { useTranslation } from 'react-i18next';
|
import { useTranslation } from 'react-i18next';
|
||||||
@ -71,6 +71,7 @@ export function AdminSettingsPage(): React.JSX.Element {
|
|||||||
<Link to="/admin/system">{t('system:settingsLink')} →</Link>
|
<Link to="/admin/system">{t('system:settingsLink')} →</Link>
|
||||||
</p>
|
</p>
|
||||||
<SettingsLayout>
|
<SettingsLayout>
|
||||||
|
<VsNfdProfileSection />
|
||||||
<section className="settings-section">
|
<section className="settings-section">
|
||||||
<h2>{t('settings:admin.general')}</h2>
|
<h2>{t('settings:admin.general')}</h2>
|
||||||
<form onSubmit={onSubmit} noValidate>
|
<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
|
* 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
|
* api but edited here as a comma-separated field; images are always allowed
|
||||||
|
|||||||
@ -105,6 +105,17 @@ SMTP_FROM=Dorfteich <wiki@example.com>
|
|||||||
# mirror stop. Deploy-level on purpose: Site-Admins cannot widen it.
|
# mirror stop. Deploy-level on purpose: Site-Admins cannot widen it.
|
||||||
#BACKUP_ALLOWED_TARGETS=cloud.example.org,172.30.1.10
|
#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):
|
# 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 —
|
# 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.
|
# put the key on the secrets volume (docker compose cp), never in the repo.
|
||||||
|
|||||||
@ -58,6 +58,9 @@ services:
|
|||||||
# Must match the backup service's value — the api validates admin
|
# Must match the backup service's value — the api validates admin
|
||||||
# backup settings against the same allowlist (issue #192).
|
# backup settings against the same allowlist (issue #192).
|
||||||
BACKUP_ALLOWED_TARGETS: ${BACKUP_ALLOWED_TARGETS:-}
|
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
|
# SMTP relay. Empty (= unset in .env) is fine: the setup wizard writes
|
||||||
# the relay to the secret store on the `secrets` volume (issue #80);
|
# the relay to the secret store on the `secrets` volume (issue #80);
|
||||||
# values set here in the stage .env always win over the store.
|
# values set here in the stage .env always win over the store.
|
||||||
|
|||||||
66
docs/architecture/adr/0027-vs-nfd-hardening-profile-mode.md
Normal file
66
docs/architecture/adr/0027-vs-nfd-hardening-profile-mode.md
Normal 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`).
|
||||||
@ -48,7 +48,7 @@ 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. |
|
||||||
@ -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. |
|
| `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. |
|
||||||
|
|
||||||
### 1.3 Noch nicht verfügbar (Regel: landet hier im selben PR)
|
### 1.3 Noch nicht verfügbar (Regel: landet hier im selben PR)
|
||||||
|
|
||||||
|
|||||||
@ -56,7 +56,22 @@
|
|||||||
"uploadPolicy": "Datei-Uploads auf eingestufte Seiten",
|
"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.",
|
"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",
|
"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": {
|
"landing": {
|
||||||
"title": "Startseite",
|
"title": "Startseite",
|
||||||
|
|||||||
@ -56,7 +56,22 @@
|
|||||||
"uploadPolicy": "File uploads to classified pages",
|
"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.",
|
"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",
|
"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": {
|
"landing": {
|
||||||
"title": "Landing page",
|
"title": "Landing page",
|
||||||
|
|||||||
@ -1,6 +1,7 @@
|
|||||||
import { z } from 'zod';
|
import { z } from 'zod';
|
||||||
|
|
||||||
import { parseBackupTargetAllowlist } from './backup-target-policy';
|
import { parseBackupTargetAllowlist } from './backup-target-policy';
|
||||||
|
import { vsNfdModeSchema } from './vs-nfd-profile';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Environment schemas live here so api, collab, and tooling validate their
|
* 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. */
|
* proxy forwards it) and the identity is the configured attribute. */
|
||||||
AUTH_PROXY_MODE: z.enum(['plain', 'mtls-dn']).default('plain'),
|
AUTH_PROXY_MODE: z.enum(['plain', 'mtls-dn']).default('plain'),
|
||||||
AUTH_PROXY_DN_ATTRIBUTE: z.string().min(1).default('CN'),
|
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>;
|
export type ApiEnv = z.infer<typeof apiEnvSchema>;
|
||||||
|
|||||||
@ -36,4 +36,5 @@ export * from './quotas';
|
|||||||
export * from './text-diff';
|
export * from './text-diff';
|
||||||
export * from './theme';
|
export * from './theme';
|
||||||
export * from './tree';
|
export * from './tree';
|
||||||
|
export * from './vs-nfd-profile';
|
||||||
export * from './watches';
|
export * from './watches';
|
||||||
|
|||||||
59
packages/shared/src/vs-nfd-profile.test.ts
Normal file
59
packages/shared/src/vs-nfd-profile.test.ts
Normal 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('');
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
225
packages/shared/src/vs-nfd-profile.ts
Normal file
225
packages/shared/src/vs-nfd-profile.ts
Normal 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';
|
||||||
|
}
|
||||||
|
}
|
||||||
Loading…
Reference in New Issue
Block a user