From 59f8e462cd6c13130ca986be94b3495be95012fd Mon Sep 17 00:00:00 2001 From: Linus Rath <139418639+rathlinus@users.noreply.github.com> Date: Sun, 1 Nov 2026 05:07:33 +0100 Subject: [PATCH] feat: let admins define classification labels with presets --- .../org/__tests__/labels-section.test.tsx | 124 +++++++ .../admin/org/sections/labels-section.tsx | 335 +++++++++++++++++- .../organisation-policy-enterprise.test.ts | 7 + lib/admin/organisation-policy.ts | 8 +- lib/classification/presets.ts | 63 ++++ 5 files changed, 531 insertions(+), 6 deletions(-) create mode 100644 components/admin/org/__tests__/labels-section.test.tsx create mode 100644 lib/classification/presets.ts diff --git a/components/admin/org/__tests__/labels-section.test.tsx b/components/admin/org/__tests__/labels-section.test.tsx new file mode 100644 index 000000000..1c7a3a07e --- /dev/null +++ b/components/admin/org/__tests__/labels-section.test.tsx @@ -0,0 +1,124 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest'; +import { act, fireEvent, render, screen, waitFor } from '@testing-library/react'; + +// Organisation > Classification: presets, the list editor, default and +// required, the extra header, the VS-NfD warning, and a scope that has its +// own scheme as a whole (labels merge as one section). + +const puts: Array> = []; +let stored: Record = {}; +vi.mock('@/lib/browser-navigation', () => ({ + apiFetch: vi.fn(async (_url: string, init?: RequestInit) => { + if (init?.method === 'PUT') { + const body = JSON.parse(String(init.body)); + puts.push(body); + stored = body; + return new Response('{"ok":true}'); + } + return new Response(JSON.stringify(stored)); + }), + withBasePath: (p: string) => p, + getPathPrefix: () => '', +})); + +import { LabelsSection } from '../sections/labels-section'; +import { useOrgPolicyDraft, useOrgScopeStore, EVERYONE } from '../use-org-policy-draft'; +import { sanitizeOrganisationPolicy } from '@/lib/admin/organisation-policy'; +import { resolvePolicy } from '@/lib/admin/policy-scopes'; +import { DEFAULT_POLICY } from '@/lib/admin/types'; + +const POLICY = { + restrictions: {}, features: {}, defaults: {}, themePolicy: {}, + forceEnabledPlugins: [], approvedPlugins: [], forceEnabledThemes: [], + organisation: {}, + scopes: [{ id: 'gov', label: 'Gov', match: { domains: ['gov.example'] }, policy: {} }], +}; + +let latest: ReturnType | null = null; +function Harness() { + const draft = useOrgPolicyDraft('test'); + latest = draft; + if (draft.loading) return null; + return ; +} + +async function renderSection() { + render(); + await waitFor(() => expect(screen.getByTestId('org-labels-vsnfd-warning')).toBeInTheDocument()); +} + +beforeEach(() => { + puts.length = 0; + stored = JSON.parse(JSON.stringify(POLICY)); + latest = null; + useOrgScopeStore.setState({ scopeId: EVERYONE }); +}); + +describe('Classification section', () => { + it('says plainly that a label does not make mail meet the VS-NfD rules', async () => { + await renderSection(); + const warning = screen.getByTestId('org-labels-vsnfd-warning').textContent ?? ''; + expect(warning).toContain('VSA § 50, § 55'); + expect(warning).toContain('BSI-approved encryption'); + expect(warning).toContain('are not approved products'); + }); + + it('starts from the generic preset and saves it with the default', async () => { + await renderSection(); + fireEvent.change(screen.getByTestId('org-labels-preset'), { target: { value: 'generic' } }); + expect(screen.getAllByTestId('org-label-item')).toHaveLength(4); + fireEvent.click(screen.getByTestId('org-labels-required')); + await act(async () => { await latest!.save(); }); + const labels = (puts[0].organisation as { labels: { list: Array<{ id: string; subjectPrefix: string }>; defaultId: string; required: boolean } }).labels; + expect(labels.list.map((l) => l.id)).toEqual(['public', 'internal', 'confidential', 'strictly-confidential']); + expect(labels.defaultId).toBe('internal'); + expect(labels.required).toBe(true); + // What the server keeps of it is the same. + expect(sanitizeOrganisationPolicy(puts[0].organisation).labels.list[2].subjectPrefix).toBe('[Confidential]'); + }); + + it('offers no grade above VS-NfD in the German preset', async () => { + await renderSection(); + fireEvent.change(screen.getByTestId('org-labels-preset'), { target: { value: 'german-public-sector' } }); + const names = latest!.orgValue('labels', 'list').value.map((l) => l.name); + expect(names).toEqual(['OFFEN', 'VS-NfD']); + expect(latest!.orgValue('labels', 'list').value[1].marking).toBe('VS-NUR FÜR DEN DIENSTGEBRAUCH'); + }); + + it('clears the default and the requirement with the last label', async () => { + stored = { ...POLICY, organisation: { labels: { list: [{ id: 'a', name: 'A', color: '#000000' }], defaultId: 'a', required: true } } }; + await renderSection(); + fireEvent.click(screen.getByRole('button', { name: 'Remove A' })); + expect(latest!.orgValue('labels', 'defaultId').value).toBe(''); + expect(latest!.orgValue('labels', 'required').value).toBe(false); + }); + + it('stores the extra header name only when it is a header Bulwark doesn’t write', async () => { + await renderSection(); + const field = screen.getByTestId('org-labels-extra-header'); + fireEvent.change(field, { target: { value: 'Sensitivity' } }); + expect(screen.getAllByRole('alert').some((el) => el.textContent?.includes('not a header Bulwark writes itself'))).toBe(true); + expect(latest!.orgValue('labels', 'extraHeader').value).toBe(''); + fireEvent.change(field, { target: { value: 'X-Classification' } }); + expect(latest!.orgValue('labels', 'extraHeader').value).toBe('X-Classification'); + }); + + it('gives a scope its own scheme as a whole, and back', async () => { + stored = { ...POLICY, organisation: { labels: { list: [{ id: 'a', name: 'A', color: '#000000' }], defaultId: 'a', required: true, extraHeader: 'X-Class' } } }; + await renderSection(); + act(() => latest!.setScopeId('gov')); + expect(screen.getByTestId('org-labels-inherited').textContent).toContain('A'); + fireEvent.click(screen.getByTestId('org-labels-own')); + expect(latest!.policy?.scopes?.[0].policy.organisation?.labels).toEqual({ + list: [{ id: 'a', name: 'A', color: '#000000', description: '', marking: '', sensitivity: '', subjectPrefix: '', extraHeaderValue: '' }], + defaultId: 'a', required: true, extraHeader: 'X-Class', + }); + fireEvent.change(screen.getByTestId('org-labels-preset'), { target: { value: 'german-public-sector' } }); + const resolved = resolvePolicy({ ...DEFAULT_POLICY, ...(latest!.policy as object) } as never, { email: 'x@gov.example', groups: [] }).policy; + expect(resolved.organisation!.labels.list.map((l) => l.id)).toEqual(['offen', 'vs-nfd']); + expect(resolved.organisation!.labels.defaultId).toBe('offen'); + expect(resolved.organisation!.labels.extraHeader).toBe('X-Class'); + fireEvent.click(screen.getByRole('button', { name: /Use Everyone/ })); + expect(latest!.policy?.scopes?.[0].policy.organisation?.labels).toBeUndefined(); + }); +}); diff --git a/components/admin/org/sections/labels-section.tsx b/components/admin/org/sections/labels-section.tsx index fd1cc1d3c..dc4573a15 100644 --- a/components/admin/org/sections/labels-section.tsx +++ b/components/admin/org/sections/labels-section.tsx @@ -1,13 +1,338 @@ 'use client'; -import { SettingsSection } from '@/components/settings/settings-section'; +import { useEffect, useId, useState } from 'react'; +import { AlertTriangle, ChevronDown, ChevronRight, ChevronUp, Plus, RotateCcw, Trash2 } from '@/components/icons'; +import { SettingItem, SettingsSection, ToggleSwitch } from '@/components/settings/settings-section'; +import { Button } from '@/components/ui/button'; +import { + DEFAULT_ORGANISATION_POLICY, + LABEL_SENSITIVITIES, + isLabelExtraHeaderName, + type ClassificationLabel, + type LabelSensitivity, + type OrganisationPolicy, +} from '@/lib/admin/organisation-policy'; +import { LABEL_PRESETS } from '@/lib/classification/presets'; +import { cn, generateUUID } from '@/lib/utils'; import type { OrgPolicyDraft } from '../use-org-policy-draft'; +type Labels = OrganisationPolicy['labels']; +const KEYS = ['list', 'defaultId', 'required', 'extraHeader'] as const; +const DOCS_URL = 'https://bulwarkmail.org/docs/guides/organisation/classification-labels'; +/** Same cap as the sanitiser. */ +const MAX_LABELS = 50; + +const inputClass = + 'block w-full rounded-md border border-input bg-background px-3 py-2 text-sm text-foreground ' + + 'placeholder:text-muted-foreground hover:border-muted-foreground focus-visible:outline-hidden focus-visible:border-ring'; +const labelClass = 'mb-1 block text-xs font-medium text-foreground'; + +const SENSITIVITY_NAMES: Record = { + '': 'None', + Personal: 'Personal', + Private: 'Private', + 'Company-Confidential': 'Company-Confidential (Outlook: Confidential)', +}; + +/** The VS-NfD warning, here and in the docs. */ +function VsNfdWarning() { + return ( +
+
+ ); +} + +function LabelEditor({ label, index, count, extraHeader, onChange, onMove, onRemove }: { + label: ClassificationLabel; + index: number; + count: number; + extraHeader: string; + onChange: (label: ClassificationLabel) => void; + onMove: (delta: -1 | 1) => void; + onRemove: () => void; +}) { + const id = useId(); + const [open, setOpen] = useState(!label.name); + const set = (patch: Partial) => onChange({ ...label, ...patch }); + const name = label.name || 'New label'; + return ( +
  • +
    + + + + +
    + {!label.name.trim() &&

    A label without a name is not saved. Give it one.

    } + {open && ( +
    +
    + + set({ name: e.target.value })} /> +
    +
    + +
    + set({ color: e.target.value.toLowerCase() })} /> + {label.color} +
    +
    +
    + + set({ description: e.target.value })} /> +
    +
    + + set({ marking: e.target.value.replace(/["\\]/g, '') })} /> +

    Empty uses the name. Received mail is matched by this text.

    +
    +
    + + +

    Outlook shows it above the message.

    +
    +
    + + set({ subjectPrefix: e.target.value })} /> +

    Added in front of the subject when the mail goes out, once.

    +
    + {extraHeader && ( +
    + + set({ extraHeaderValue: e.target.value })} /> +
    + )} +
    + )} +
  • + ); +} + /** - * Placeholder from the enterprise-pack foundation, replaced by package A: - * Classification labels, their default, and whether a label is required. + * Organisation > Classification (enterprise pack item 3): the labels users + * pick in the composer (policy.json `organisation.labels`), the default, + * whether a label is required, and an extra header for gateways. A scope + * that sets labels replaces Everyone's scheme as a whole, so the scope + * either uses Everyone's labels or has all of its own. */ export function LabelsSection({ draft }: { draft: OrgPolicyDraft }) { - void draft; - return {null}; + const id = useId(); + const inScope = !!draft.scope; + const scopeOwn = inScope && KEYS.some((key) => draft.orgValue('labels', key).own); + const editing = !inScope || scopeOwn; + const value = (key: K): Labels[K] => { + const v = draft.orgValue('labels', key); + if (!inScope) return v.value; + if (!scopeOwn) return v.everyone; + return v.own ? v.value : DEFAULT_ORGANISATION_POLICY.labels[key]; + }; + const everyone = { + list: draft.orgValue('labels', 'list').everyone, + }; + const list = value('list'); + const defaultId = value('defaultId'); + const required = value('required'); + const extraHeader = value('extraHeader'); + + const set = (key: K, next: Labels[K]) => draft.setOrgValue('labels', key, next); + const setAll = (labels: Labels) => { for (const key of KEYS) draft.setOrgValue('labels', key, labels[key] as never); }; + const useOwn = () => setAll(JSON.parse(JSON.stringify({ + list: everyone.list, + defaultId: draft.orgValue('labels', 'defaultId').everyone, + required: draft.orgValue('labels', 'required').everyone, + extraHeader: draft.orgValue('labels', 'extraHeader').everyone, + })) as Labels); + const useEveryone = () => { for (const key of KEYS) draft.clearOrgValue('labels', key); }; + + const setList = (next: ClassificationLabel[]) => { + set('list', next); + if (defaultId && !next.some((l) => l.id === defaultId)) set('defaultId', ''); + if (next.length === 0 && required) set('required', false); + }; + const add = () => setList([...list, { id: generateUUID().slice(0, 12), name: '', color: '#6b7280', description: '', marking: '', sensitivity: '', subjectPrefix: '', extraHeaderValue: '' }]); + const change = (index: number, label: ClassificationLabel) => setList(list.map((l, i) => (i === index ? label : l))); + const remove = (index: number) => setList(list.filter((_, i) => i !== index)); + const move = (index: number, delta: -1 | 1) => { + const next = [...list]; + const [item] = next.splice(index, 1); + next.splice(index + delta, 0, item); + setList(next); + }; + const applyPreset = (presetId: string) => { + const preset = LABEL_PRESETS.find((p) => p.id === presetId); + if (!preset) return; + set('list', JSON.parse(JSON.stringify(preset.list))); + set('defaultId', preset.defaultId); + }; + + // The extra header name is typed freely and stored once it is a valid name. + const [headerText, setHeaderText] = useState(extraHeader); + useEffect(() => { setHeaderText(extraHeader); }, [extraHeader]); + const headerInvalid = headerText.trim() !== '' && !isLabelExtraHeaderName(headerText); + const onHeader = (text: string) => { + setHeaderText(text); + const next = text.trim(); + if (next === '' || isLabelExtraHeaderName(next)) set('extraHeader', next); + }; + + return ( + +
    + + + {inScope && ( +
    + {scopeOwn ? 'This scope has its own labels. They replace Everyone\u2019s.' : 'This scope uses Everyone\u2019s labels.'} + {scopeOwn ? ( + + ) : ( + + )} +
    + )} + + {editing ? ( + <> +
    + + + Replaces the list below. +
    + + {list.length === 0 ? ( +

    No labels. The composer shows no label picker.

    + ) : ( +
      + {list.map((label, index) => ( + change(index, l)} + onMove={(delta) => move(index, delta)} + onRemove={() => remove(index)} + /> + ))} +
    + )} +
    + + {list.length >= MAX_LABELS && At most {MAX_LABELS} labels.} +
    + +
    + + + + + set('required', v)} testId="org-labels-required" /> + + +
    + onHeader(e.target.value)} + aria-invalid={headerInvalid || undefined} + data-testid="org-labels-extra-header" + className={cn(inputClass, 'sm:w-56', headerInvalid && 'border-red-500')} + /> + {headerInvalid && ( +

    + Letters, digits and dashes only, and not a header Bulwark writes itself. +

    + )} +
    +
    +
    + + ) : ( +
    + {everyone.list.length === 0 ? ( +

    Everyone has no labels.

    + ) : ( +
      + {everyone.list.map((l) => ( +
    • +
    • + ))} +
    + )} +
    + )} + +

    + Calendar invitations, read receipts, phishing reports and unsubscribe mails go out without a label.{' '} + + How labels work + +

    +
    +
    + ); } diff --git a/lib/admin/__tests__/organisation-policy-enterprise.test.ts b/lib/admin/__tests__/organisation-policy-enterprise.test.ts index 76d0cc746..9c5810dcd 100644 --- a/lib/admin/__tests__/organisation-policy-enterprise.test.ts +++ b/lib/admin/__tests__/organisation-policy-enterprise.test.ts @@ -1,5 +1,6 @@ import { describe, expect, it } from 'vitest'; import { + isLabelExtraHeaderName, DEFAULT_ORGANISATION_POLICY, MIN_FEED_REFRESH_MINUTES, mergeOrganisation, @@ -58,6 +59,12 @@ describe('enterprise sections: defaults and sanitising', () => { } }); + it('refuses the header Bulwark keeps the label of a draft in', () => { + expect(sanitizeOrganisationPolicy({ labels: { extraHeader: 'X-Bulwark-Label' } }).labels.extraHeader).toBe(''); + expect(isLabelExtraHeaderName('X-Bulwark-Label')).toBe(false); + expect(isLabelExtraHeaderName('X-Classification')).toBe(true); + }); + it('keeps announcement text per locale and normalises times', () => { const { announcement } = sanitizeOrganisationPolicy({ announcement: { id: 'maint-1', severity: 'warning', text: { en: 'Maintenance tonight', 'pt-BR': 'Manutenção', 'x y': 'no', de: ' ' }, diff --git a/lib/admin/organisation-policy.ts b/lib/admin/organisation-policy.ts index a201c5e2e..e9aa61c8a 100644 --- a/lib/admin/organisation-policy.ts +++ b/lib/admin/organisation-policy.ts @@ -287,7 +287,7 @@ const RESERVED_HEADERS = new Set([ 'from', 'to', 'cc', 'bcc', 'subject', 'date', 'message-id', 'sender', 'reply-to', 'in-reply-to', 'references', 'mime-version', 'content-type', 'content-transfer-encoding', 'content-disposition', 'received', 'return-path', 'dkim-signature', 'arc-seal', 'arc-message-signature', 'authentication-results', - 'sio-label', 'sio-label-history', 'sensitivity', 'importance', 'x-priority', 'disposition-notification-to', + 'sio-label', 'sio-label-history', 'sensitivity', 'x-bulwark-label', 'importance', 'x-priority', 'disposition-notification-to', ]); const MAX_LABELS = 50; const MAX_FEEDS = 50; @@ -367,6 +367,12 @@ function sanitizeLabels(value: unknown): ClassificationLabel[] | undefined { }); } +/** Whether the admin page may store this as `labels.extraHeader` (the sanitiser's rule). */ +export function isLabelExtraHeaderName(value: string): boolean { + const v = value.trim(); + return HEADER_NAME_RE.test(v) && !RESERVED_HEADERS.has(v.toLowerCase()); +} + function headerName(value: unknown): string | undefined { if (typeof value !== 'string') return undefined; const v = value.trim(); diff --git a/lib/classification/presets.ts b/lib/classification/presets.ts new file mode 100644 index 000000000..f4703e46a --- /dev/null +++ b/lib/classification/presets.ts @@ -0,0 +1,63 @@ +/** + * The two label schemes the admin page offers to start from (enterprise + * pack item 3). Markings follow RFC 7444 (SIO-Label), Sensitivity values + * RFC 2156 (what Outlook shows). + * + * The German public-sector preset stops at VS-NfD on purpose: higher grades + * (VS-VERTRAULICH and up) need IT approved for them (VSA § 50 Abs. 4) and + * must not be offered in a mail client that isn't. Even VS-NfD needs + * approved IT and BSI-approved encryption outside an approved network + * (§ 50, § 55); the label only marks the message. + */ + +import type { ClassificationLabel } from '@/lib/admin/organisation-policy'; + +export interface LabelPreset { + id: 'generic' | 'german-public-sector'; + name: string; + defaultId: string; + list: ClassificationLabel[]; +} + +const label = (l: Partial & Pick): ClassificationLabel => ({ + description: '', + sensitivity: '', + subjectPrefix: '', + extraHeaderValue: '', + ...l, +}); + +export const LABEL_PRESETS: LabelPreset[] = [ + { + id: 'generic', + name: 'Generic: Public / Internal / Confidential / Strictly confidential', + defaultId: 'internal', + list: [ + label({ id: 'public', name: 'Public', color: '#16a34a', marking: 'PUBLIC', description: 'Can be shared with anyone.', extraHeaderValue: 'Public' }), + label({ id: 'internal', name: 'Internal', color: '#2563eb', marking: 'INTERNAL', description: 'For people in the organisation and those working with it.', extraHeaderValue: 'Internal' }), + label({ + id: 'confidential', name: 'Confidential', color: '#d97706', marking: 'CONFIDENTIAL', sensitivity: 'Company-Confidential', + subjectPrefix: '[Confidential]', description: 'Only for the people it is meant for.', extraHeaderValue: 'Confidential', + }), + label({ + id: 'strictly-confidential', name: 'Strictly confidential', color: '#dc2626', marking: 'STRICTLY CONFIDENTIAL', + sensitivity: 'Company-Confidential', subjectPrefix: '[Strictly confidential]', + description: 'Only for the named recipients. Don’t forward.', extraHeaderValue: 'Strictly confidential', + }), + ], + }, + { + id: 'german-public-sector', + name: 'German public sector: OFFEN / VS-NfD', + defaultId: 'offen', + list: [ + label({ id: 'offen', name: 'OFFEN', color: '#6b7280', marking: 'OFFEN', description: 'Nicht eingestuft.', extraHeaderValue: 'OFFEN' }), + label({ + id: 'vs-nfd', name: 'VS-NfD', color: '#b91c1c', marking: 'VS-NUR FÜR DEN DIENSTGEBRAUCH', sensitivity: 'Company-Confidential', + subjectPrefix: 'VS-NfD', + description: 'Verschlusssache – nur für den Dienstgebrauch. Nur nach der VSA behandeln und versenden.', + extraHeaderValue: 'VS-NfD', + }), + ], + }, +];