feat: let admins export and import the whole user policy as one file

This commit is contained in:
Linus Rath
2026-10-28 00:44:31 +01:00
parent e76490580d
commit 4004b9be7c
7 changed files with 423 additions and 9 deletions
@@ -7,8 +7,23 @@ import { act, fireEvent, render, renderHook, screen, waitFor } from '@testing-li
const puts: Array<Record<string, unknown>> = [];
let stored: Record<string, unknown> = {};
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(<OrganisationPage />);
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());
});
});
+3
View File
@@ -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() {
>
<EffectivePolicy draft={draft} />
</SettingsSection>
<PolicyFileSection draft={draft} />
</OrgPage>
);
}
@@ -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<string, unknown> }>);
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<string, unknown>) => { 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: '<p>{name}</p>' } },
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);
});
});
+79
View File
@@ -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<SettingsPolicy>);
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 });
}
}
@@ -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<HTMLInputElement>(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 (
<SettingsSection
id="policy-file"
title="Policy file"
description="Everything on the Organisation and User policy pages is one file, policy.json. Export it to keep it in git or copy it to another Bulwark or a Lite build; importing replaces the current policy."
>
<div className="flex flex-wrap gap-2">
<Button type="button" variant="outline" size="sm" className="h-8 gap-1.5 text-xs" onClick={() => void exportFile()}>
<Download className="h-3.5 w-3.5" /> Export policy.json
</Button>
<Button
type="button"
variant="outline"
size="sm"
className="h-8 gap-1.5 text-xs"
disabled={busy || draft.dirty}
onClick={() => input.current?.click()}
>
{busy ? <Loader2 className="h-3.5 w-3.5 animate-spin" /> : <Upload className="h-3.5 w-3.5" />} Import a file
</Button>
<input
ref={input}
type="file"
accept="application/json,.json"
className="hidden"
aria-label="Policy file to import"
onChange={(e) => { const file = e.target.files?.[0]; if (file) void pick(file); }}
/>
</div>
{draft.dirty && <p className="text-xs text-muted-foreground">Save or discard your changes first.</p>}
{pending && (
<div className="rounded-md border border-border bg-muted/30 p-3 space-y-2" data-testid="policy-import-preview">
<p className="text-sm font-medium text-foreground">Importing {pending.name}</p>
{pending.summary.unchanged ? (
<p className="text-sm text-muted-foreground">It matches the current policy; nothing would change.</p>
) : (
<ul className="list-disc ps-5 text-sm text-foreground">
{summaryLines(pending.summary).map((line) => <li key={line}>{line}</li>)}
</ul>
)}
<div className="flex flex-wrap gap-2">
<Button type="button" size="sm" className="h-8 text-xs" disabled={busy || pending.summary.unchanged} onClick={() => void apply()}>
Replace the current policy
</Button>
<Button type="button" variant="outline" size="sm" className="h-8 text-xs" disabled={busy} onClick={() => setPending(null)}>
Cancel
</Button>
</div>
</div>
)}
</SettingsSection>
);
}
+16 -8
View File
@@ -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<SettingsPolicy>);
} else {
this.policyCache = { ...DEFAULT_POLICY };
}
@@ -184,13 +179,23 @@ class ConfigManager {
*/
async setPolicy(policy: SettingsPolicy): Promise<void> {
assertWritable('update settings policy');
this.policyCache = ConfigManager.normalizePolicy({
this.policyCache = ConfigManager.completePolicy(policy);
await this.writeJsonFile('policy.json', this.policyCache as unknown as Record<string, unknown>);
}
/**
* 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>): 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<string, unknown>);
}
/**
@@ -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>): SettingsPolicy => ConfigManager.completePolicy(policy);
+60
View File
@@ -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<string, unknown> = {}, after: Record<string, unknown> = {}): 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<string, unknown>, next.features as unknown as Record<string, unknown>),
restrictions: keyChanges(current.restrictions, next.restrictions),
defaults: keyChanges(current.defaults, next.defaults),
organisation: keyChanges(
current.organisation as unknown as Record<string, unknown>,
next.organisation as unknown as Record<string, unknown>,
),
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<string, unknown>)[key], (next as unknown as Record<string, unknown>)[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;
}