diff --git a/app/(main)/admin/__tests__/organisation-page.test.tsx b/app/(main)/admin/__tests__/organisation-page.test.tsx index 8ff9c8868..542c15140 100644 --- a/app/(main)/admin/__tests__/organisation-page.test.tsx +++ b/app/(main)/admin/__tests__/organisation-page.test.tsx @@ -7,8 +7,23 @@ import { act, fireEvent, render, renderHook, screen, waitFor } from '@testing-li const puts: Array> = []; let stored: Record = {}; +const filePosts: string[] = []; vi.mock('@/lib/browser-navigation', () => ({ - apiFetch: vi.fn(async (_url: string, init?: RequestInit) => { + apiFetch: vi.fn(async (url: string, init?: RequestInit) => { + if (url.startsWith('/api/admin/policy/file') && init?.method === 'POST') { + filePosts.push(url); + return new Response(JSON.stringify({ + ok: true, + summary: { + scopes: { added: ['Two (two)'], changed: [], removed: [] }, + organisation: { added: [], changed: ['session'], removed: [] }, + restrictions: { added: [], changed: [], removed: [] }, + features: { added: [], changed: [], removed: [] }, + defaults: { added: [], changed: [], removed: [] }, + other: [], unchanged: false, + }, + })); + } if (init?.method === 'PUT') { const body = JSON.parse(String(init.body)); puts.push(body); @@ -34,6 +49,7 @@ const POLICY = { beforeEach(() => { puts.length = 0; + filePosts.length = 0; stored = JSON.parse(JSON.stringify(POLICY)); useOrgScopeStore.setState({ scopeId: EVERYONE }); }); @@ -94,4 +110,19 @@ describe('OrganisationPage', () => { expect(table.textContent).toContain('restrictions.density'); expect(table.textContent).toContain('organisation.session.idleMinutes'); }); + + it('shows what an imported file changes and replaces the policy only when asked', async () => { + render(); + await screen.findByTestId('org-scope-team'); + const file = new File(['{"scopes":[]}'], 'policy.json', { type: 'application/json' }); + fireEvent.change(screen.getByLabelText('Policy file to import'), { target: { files: [file] } }); + const preview = await screen.findByTestId('policy-import-preview'); + expect(preview.textContent).toContain('Domains and groups: 1 added'); + expect(preview.textContent).toContain('Organisation settings: 1 changed'); + expect(filePosts).toEqual(['/api/admin/policy/file?preview=1']); + + fireEvent.click(screen.getByRole('button', { name: 'Replace the current policy' })); + await waitFor(() => expect(filePosts).toEqual(['/api/admin/policy/file?preview=1', '/api/admin/policy/file'])); + await waitFor(() => expect(screen.queryByTestId('policy-import-preview')).toBeNull()); + }); }); diff --git a/app/(main)/admin/_pages/organisation.tsx b/app/(main)/admin/_pages/organisation.tsx index 0276ef616..7191fd911 100644 --- a/app/(main)/admin/_pages/organisation.tsx +++ b/app/(main)/admin/_pages/organisation.tsx @@ -7,6 +7,7 @@ import { Button } from '@/components/ui/button'; import { Badge } from '@/components/admin/badge'; import { inputClass } from '@/components/admin/config-items'; import { OrgPage, scopeMatchSummary } from '@/components/admin/org/org-page'; +import { PolicyFileSection } from '@/components/admin/org/policy-file-section'; import { EVERYONE, useOrgPolicyDraft, type OrgPolicyDraft } from '@/components/admin/org/use-org-policy-draft'; import { resolvePolicy, type PolicyScope } from '@/lib/admin/policy-scopes'; import { cn } from '@/lib/utils'; @@ -260,6 +261,8 @@ export function OrganisationPage() { > + + ); } diff --git a/app/api/admin/__tests__/policy-file-route.test.ts b/app/api/admin/__tests__/policy-file-route.test.ts new file mode 100644 index 000000000..9e7afce38 --- /dev/null +++ b/app/api/admin/__tests__/policy-file-route.test.ts @@ -0,0 +1,89 @@ +// @vitest-environment node +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; +import { mkdtemp, readFile, rm, writeFile } from 'node:fs/promises'; +import { tmpdir } from 'node:os'; +import path from 'node:path'; +import { NextRequest } from 'next/server'; + +// The whole user policy goes out and comes back as one policy.json. An +// import is previewed first and goes through the same sanitising as the +// file on disk. + +const audit = vi.hoisted(() => [] as Array<{ action: string; detail: Record }>); +vi.mock('@/lib/admin/session', () => ({ + requireAdminAuth: async () => ({ payload: { role: 'admin' } }), + getClientIP: () => '127.0.0.1', +})); +vi.mock('@/lib/admin/audit', () => ({ + auditLog: async (action: string, detail: Record) => { audit.push({ action, detail }); }, +})); + +import { GET, POST } from '@/app/api/admin/policy/file/route'; +import { configManager } from '@/lib/admin/config-manager'; + +let dir: string; +const url = (query = '') => `https://mail.example/api/admin/policy/file${query}`; +const post = (body: string, query = '') => POST(new NextRequest(url(query), { method: 'POST', body })); +const stored = async () => JSON.parse(await readFile(path.join(dir, 'policy.json'), 'utf-8')); + +beforeEach(async () => { + dir = await mkdtemp(path.join(tmpdir(), 'bw-policy-file-')); + process.env.ADMIN_CONFIG_DIR = dir; + audit.length = 0; + await writeFile(path.join(dir, 'policy.json'), JSON.stringify({ + restrictions: { density: { locked: true } }, + organisation: { session: { idleMinutes: 30 } }, + scopes: [{ id: 'team', label: 'Team', match: { groups: ['team@example.org'] }, policy: {} }], + })); + await configManager.reload(); +}); + +afterEach(async () => { + delete process.env.ADMIN_CONFIG_DIR; + await rm(dir, { recursive: true, force: true }); +}); + +describe('policy file', () => { + it('exports the stored policy as a dated download and logs it', async () => { + const res = await GET(new NextRequest(url())); + expect(res.headers.get('content-disposition')).toMatch(/^attachment; filename="bulwark-policy-\d{4}-\d{2}-\d{2}\.json"$/); + const body = JSON.parse(await res.text()); + expect(body.scopes.map((s: { id: string }) => s.id)).toEqual(['team']); + expect(body.organisation.session.idleMinutes).toBe(30); + expect(audit.map((a) => a.action)).toEqual(['policy.export']); + }); + + it('previews an import without writing anything', async () => { + const file = JSON.stringify({ + restrictions: {}, + organisation: { session: { idleMinutes: 10 }, signature: { html: '

{name}

' } }, + scopes: [{ id: 'two', label: 'Two', match: { domains: ['org-two.test'] }, policy: {} }], + }); + const res = await post(file, '?preview=1'); + const { summary } = await res.json(); + expect(summary.scopes).toEqual({ added: ['Two (two)'], changed: [], removed: ['Team (team)'] }); + expect(summary.restrictions.removed).toEqual(['density']); + expect(summary.organisation.changed.sort()).toEqual(['session', 'signature']); + expect((await stored()).organisation.session.idleMinutes).toBe(30); + expect(audit).toEqual([]); + }); + + it('imports the sanitised policy and logs it', async () => { + const res = await post(JSON.stringify({ + organisation: { session: { idleMinutes: 'soon' } }, + scopes: [{ id: 'Bad id!' }, { id: 'two', label: 'Two', match: { domains: ['org-two.test'] }, policy: {} }], + })); + expect(res.status).toBe(200); + const policy = await stored(); + expect(policy.scopes.map((s: { id: string }) => s.id)).toEqual(['two']); + expect(policy.organisation.session.idleMinutes).toBe(0); + expect(configManager.getPolicy().scopes?.map((s) => s.id)).toEqual(['two']); + expect(audit.map((a) => a.action)).toEqual(['policy.import']); + }); + + it('refuses what isn\'t a policy object', async () => { + expect((await post('{nope')).status).toBe(400); + expect((await post('[1,2]')).status).toBe(400); + expect((await stored()).scopes).toHaveLength(1); + }); +}); diff --git a/app/api/admin/policy/file/route.ts b/app/api/admin/policy/file/route.ts new file mode 100644 index 000000000..b1448ad0b --- /dev/null +++ b/app/api/admin/policy/file/route.ts @@ -0,0 +1,79 @@ +import { NextRequest, NextResponse } from 'next/server'; +import { completePolicy, configManager } from '@/lib/admin/config-manager'; +import { requireAdminAuth, getClientIP } from '@/lib/admin/session'; +import { auditLog } from '@/lib/admin/audit'; +import { logger } from '@/lib/logger'; +import { summarizePolicyChange } from '@/lib/admin/policy-diff'; +import { configReadOnlyResponse } from '@/lib/admin/config-read-only-response'; +import type { SettingsPolicy } from '@/lib/admin/types'; + +/** Largest policy file an import accepts. */ +const MAX_IMPORT_BYTES = 2 * 1024 * 1024; + +/** + * GET /api/admin/policy/file - the whole user policy as a policy.json + * download: the same file the server reads, so it can go into git, into + * another instance's ADMIN_CONFIG_DIR or next to a Lite build. + */ +export async function GET(request: NextRequest) { + const auth = await requireAdminAuth(request); + if ('error' in auth) return auth.error; + await configManager.ensureLoaded(); + const policy = configManager.getPolicy(); + await auditLog('policy.export', { scopeCount: policy.scopes?.length ?? 0 }, getClientIP(request)); + const date = new Date().toISOString().slice(0, 10); + return new NextResponse(`${JSON.stringify(policy, null, 2)}\n`, { + headers: { + 'Content-Type': 'application/json; charset=utf-8', + 'Content-Disposition': `attachment; filename="bulwark-policy-${date}.json"`, + 'Cache-Control': 'no-store', + }, + }); +} + +/** + * POST /api/admin/policy/file - import a policy file. With `?preview=1` it + * only says what would change; otherwise it replaces the policy. The file + * goes through the same defaults and sanitising as policy.json on load, and + * the preview shows the result of that, not the raw file. + */ +export async function POST(request: NextRequest) { + const auth = await requireAdminAuth(request); + if ('error' in auth) return auth.error; + try { + const text = await request.text(); + if (text.length > MAX_IMPORT_BYTES) { + return NextResponse.json({ error: 'The file is larger than 2 MB' }, { status: 413 }); + } + let raw: unknown; + try { + raw = JSON.parse(text); + } catch { + return NextResponse.json({ error: 'The file is not valid JSON' }, { status: 400 }); + } + if (!raw || typeof raw !== 'object' || Array.isArray(raw)) { + return NextResponse.json({ error: 'The file does not hold a policy object' }, { status: 400 }); + } + + await configManager.ensureLoaded(); + const next = completePolicy(raw as Partial); + const summary = summarizePolicyChange(configManager.getPolicy(), next); + if (request.nextUrl.searchParams.get('preview') === '1') { + return NextResponse.json({ summary }); + } + + await configManager.setPolicy(next); + await auditLog('policy.import', { + scopeCount: next.scopes?.length ?? 0, + scopesAdded: summary.scopes.added.length, + scopesRemoved: summary.scopes.removed.length, + restrictionsChanged: summary.restrictions.added.length + summary.restrictions.changed.length + summary.restrictions.removed.length, + }, getClientIP(request)); + return NextResponse.json({ ok: true, summary }); + } catch (error) { + const readOnly = configReadOnlyResponse(error); + if (readOnly) return readOnly; + logger.error('Policy import error', { error: error instanceof Error ? error.message : 'Unknown error' }); + return NextResponse.json({ error: 'Internal server error' }, { status: 500 }); + } +} diff --git a/components/admin/org/policy-file-section.tsx b/components/admin/org/policy-file-section.tsx new file mode 100644 index 000000000..bbb7e737a --- /dev/null +++ b/components/admin/org/policy-file-section.tsx @@ -0,0 +1,144 @@ +'use client'; + +import { useRef, useState } from 'react'; +import { Download, Loader2, Upload } from '@/components/icons'; +import { SettingsSection } from '@/components/settings/settings-section'; +import { Button } from '@/components/ui/button'; +import { apiFetch } from '@/lib/browser-navigation'; +import type { KeyChanges, PolicyChangeSummary } from '@/lib/admin/policy-diff'; +import type { OrgPolicyDraft } from './use-org-policy-draft'; + +function describe(label: string, changes: KeyChanges): string | null { + const parts = [ + changes.added.length ? `${changes.added.length} added` : null, + changes.changed.length ? `${changes.changed.length} changed` : null, + changes.removed.length ? `${changes.removed.length} removed` : null, + ].filter(Boolean); + return parts.length ? `${label}: ${parts.join(', ')}` : null; +} + +function summaryLines(summary: PolicyChangeSummary): string[] { + return [ + describe('Domains and groups', summary.scopes), + describe('Organisation settings', summary.organisation), + describe('Locked and hidden settings', summary.restrictions), + describe('Features', summary.features), + describe('Defaults for new users', summary.defaults), + summary.other.length ? `Also: ${summary.other.join(', ')}` : null, + ].filter((line): line is string => !!line); +} + +/** + * Export and import of the whole user policy as policy.json, so the policy + * can live in git, move to another instance or go next to a Lite build. + * An import shows what it changes before it replaces anything. + */ +export function PolicyFileSection({ draft }: { draft: OrgPolicyDraft }) { + const input = useRef(null); + const [pending, setPending] = useState<{ name: string; text: string; summary: PolicyChangeSummary } | null>(null); + const [busy, setBusy] = useState(false); + + async function exportFile() { + const res = await apiFetch('/api/admin/policy/file'); + if (!res.ok) { + draft.setMessage({ type: 'error', text: 'Could not export the policy' }); + return; + } + const blob = await res.blob(); + const name = /filename="([^"]+)"/.exec(res.headers.get('Content-Disposition') ?? '')?.[1] ?? 'bulwark-policy.json'; + const url = URL.createObjectURL(blob); + const link = document.createElement('a'); + link.href = url; + link.download = name; + link.click(); + URL.revokeObjectURL(url); + } + + async function pick(file: File) { + setBusy(true); + try { + const text = await file.text(); + const res = await apiFetch('/api/admin/policy/file?preview=1', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: text }); + const data = await res.json().catch(() => ({})); + if (!res.ok) { + draft.setMessage({ type: 'error', text: data.error || 'Could not read the file' }); + return; + } + setPending({ name: file.name, text, summary: data.summary }); + } finally { + setBusy(false); + if (input.current) input.current.value = ''; + } + } + + async function apply() { + if (!pending) return; + setBusy(true); + try { + const res = await apiFetch('/api/admin/policy/file', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: pending.text }); + const data = await res.json().catch(() => ({})); + if (!res.ok) { + draft.setMessage({ type: 'error', text: data.error || 'Could not import the file' }); + return; + } + setPending(null); + await draft.reload(); + draft.setMessage({ type: 'success', text: `Imported ${pending.name}.` }); + } finally { + setBusy(false); + } + } + + return ( + +
+ + + { const file = e.target.files?.[0]; if (file) void pick(file); }} + /> +
+ {draft.dirty &&

Save or discard your changes first.

} + {pending && ( +
+

Importing {pending.name}

+ {pending.summary.unchanged ? ( +

It matches the current policy; nothing would change.

+ ) : ( +
    + {summaryLines(pending.summary).map((line) =>
  • {line}
  • )} +
+ )} +
+ + +
+
+ )} +
+ ); +} diff --git a/lib/admin/config-manager.ts b/lib/admin/config-manager.ts index 087ad5700..52e7d5fc6 100644 --- a/lib/admin/config-manager.ts +++ b/lib/admin/config-manager.ts @@ -38,12 +38,7 @@ class ConfigManager { this.adminConfig = await this.readJsonFile('config.json') || {}; const policy = await this.readJsonFile('policy.json'); if (policy) { - this.policyCache = ConfigManager.normalizePolicy({ - ...DEFAULT_POLICY, - ...policy, - features: { ...DEFAULT_FEATURE_GATES, ...(policy.features || {}) }, - themePolicy: { ...DEFAULT_THEME_POLICY, ...(policy.themePolicy || {}) }, - }); + this.policyCache = ConfigManager.completePolicy(policy as Partial); } else { this.policyCache = { ...DEFAULT_POLICY }; } @@ -184,13 +179,23 @@ class ConfigManager { */ async setPolicy(policy: SettingsPolicy): Promise { assertWritable('update settings policy'); - this.policyCache = ConfigManager.normalizePolicy({ + this.policyCache = ConfigManager.completePolicy(policy); + await this.writeJsonFile('policy.json', this.policyCache as unknown as Record); + } + + /** + * A policy as it would be stored: defaults filled in, migrated and + * sanitised, exactly as policy.json is on load. For previewing an import. + */ + static completePolicy(policy: Partial): SettingsPolicy { + return ConfigManager.normalizePolicy({ ...DEFAULT_POLICY, ...policy, + restrictions: { ...(policy.restrictions || {}) }, + defaults: { ...(policy.defaults || {}) }, features: { ...DEFAULT_FEATURE_GATES, ...(policy.features || {}) }, themePolicy: { ...DEFAULT_THEME_POLICY, ...(policy.themePolicy || {}) }, }); - await this.writeJsonFile('policy.json', this.policyCache as unknown as Record); } /** @@ -281,3 +286,6 @@ type GlobalWithConfig = typeof globalThis & { [SINGLETON_KEY]?: ConfigManager }; const g = globalThis as GlobalWithConfig; export const configManager: ConfigManager = g[SINGLETON_KEY] ?? (g[SINGLETON_KEY] = new ConfigManager()); + +/** See ConfigManager.completePolicy. */ +export const completePolicy = (policy: Partial): SettingsPolicy => ConfigManager.completePolicy(policy); diff --git a/lib/admin/policy-diff.ts b/lib/admin/policy-diff.ts new file mode 100644 index 000000000..4224d3e09 --- /dev/null +++ b/lib/admin/policy-diff.ts @@ -0,0 +1,60 @@ +import type { SettingsPolicy } from './types'; + +export interface KeyChanges { + added: string[]; + changed: string[]; + removed: string[]; +} + +/** What importing a policy file would change, for the preview before applying it. */ +export interface PolicyChangeSummary { + scopes: KeyChanges; + features: KeyChanges; + restrictions: KeyChanges; + defaults: KeyChanges; + /** Organisation sections, by name (signature, session...). */ + organisation: KeyChanges; + /** Other top-level parts (theme policy, plugins, push relays, sidebar apps). */ + other: string[]; + /** Nothing would change. */ + unchanged: boolean; +} + +const same = (a: unknown, b: unknown) => JSON.stringify(a) === JSON.stringify(b); + +function keyChanges(before: Record = {}, after: Record = {}): KeyChanges { + const out: KeyChanges = { added: [], changed: [], removed: [] }; + for (const key of Object.keys(after)) { + if (!(key in before)) out.added.push(key); + else if (!same(before[key], after[key])) out.changed.push(key); + } + for (const key of Object.keys(before)) if (!(key in after)) out.removed.push(key); + return out; +} + +const empty = (c: KeyChanges) => c.added.length + c.changed.length + c.removed.length === 0; + +/** Compares two complete policies (both as configManager stores them). */ +export function summarizePolicyChange(current: SettingsPolicy, next: SettingsPolicy): PolicyChangeSummary { + const byId = (policy: SettingsPolicy) => Object.fromEntries((policy.scopes ?? []).map((s) => [s.label ? `${s.label} (${s.id})` : s.id, s])); + const summary: PolicyChangeSummary = { + scopes: keyChanges(byId(current), byId(next)), + features: keyChanges(current.features as unknown as Record, next.features as unknown as Record), + restrictions: keyChanges(current.restrictions, next.restrictions), + defaults: keyChanges(current.defaults, next.defaults), + organisation: keyChanges( + current.organisation as unknown as Record, + next.organisation as unknown as Record, + ), + other: [], + unchanged: false, + }; + const covered = new Set(['scopes', 'features', 'restrictions', 'defaults', 'organisation']); + for (const key of new Set([...Object.keys(current), ...Object.keys(next)])) { + if (covered.has(key)) continue; + if (!same((current as unknown as Record)[key], (next as unknown as Record)[key])) summary.other.push(key); + } + summary.unchanged = [summary.scopes, summary.features, summary.restrictions, summary.defaults, summary.organisation].every(empty) + && summary.other.length === 0; + return summary; +}