From 2bd7487ba7e263f6c558002e1cda48c5bc47ac27 Mon Sep 17 00:00:00 2001 From: Albrecht Degering Date: Tue, 8 Sep 2026 01:32:48 +0200 Subject: [PATCH] Release govoplan-policy v0.1.23: unify interface contracts and documentation --- package.json | 4 +- pyproject.toml | 4 +- src/govoplan_policy/backend/api/v1/routes.py | 5 +- src/govoplan_policy/backend/manifest.py | 24 ++- tests/test_archive_encryption_api.py | 192 ++++++++++++++++++ webui/package.json | 7 +- .../scripts/test-archive-encryption-draft.mjs | 36 ++++ .../policy/ArchiveEncryptionPoliciesPanel.tsx | 50 ++--- .../features/policy/archiveEncryptionDraft.ts | 54 +++++ webui/src/module.ts | 2 +- 10 files changed, 330 insertions(+), 48 deletions(-) create mode 100644 tests/test_archive_encryption_api.py create mode 100644 webui/scripts/test-archive-encryption-draft.mjs create mode 100644 webui/src/features/policy/archiveEncryptionDraft.ts diff --git a/package.json b/package.json index 1e1f285..9778c7d 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@govoplan/policy-webui", - "version": "0.1.22", + "version": "0.1.23", "private": true, "type": "module", "main": "webui/src/index.ts", @@ -18,7 +18,7 @@ "LICENSE" ], "peerDependencies": { - "@govoplan/core-webui": "^0.1.18", + "@govoplan/core-webui": "^0.1.45", "lucide-react": "^1.23.0", "react": ">=19.2.7 <20", "react-dom": ">=19.2.7 <20", diff --git a/pyproject.toml b/pyproject.toml index 74b472a..41a8b68 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -4,13 +4,13 @@ build-backend = "setuptools.build_meta" [project] name = "govoplan-policy" -version = "0.1.22" +version = "0.1.23" description = "GovOPlaN policy platform module." readme = "README.md" requires-python = ">=3.12" authors = [{ name = "GovOPlaN" }] dependencies = [ - "govoplan-core>=0.1.29", + "govoplan-core>=0.1.45", ] [tool.setuptools.packages.find] diff --git a/src/govoplan_policy/backend/api/v1/routes.py b/src/govoplan_policy/backend/api/v1/routes.py index b409258..4858d33 100644 --- a/src/govoplan_policy/backend/api/v1/routes.py +++ b/src/govoplan_policy/backend/api/v1/routes.py @@ -204,6 +204,9 @@ def write_campaign_archive_encryption_policy( owner_type=owner_type, owner_id=owner_id, ) + # Saving updates the same ORM row held by ``before``. Capture its + # value now so history/rollback does not silently record the new policy. + before_policy = dict(before.row.policy) if before.row else {} if clean_scope == "system": approval = ensure_configuration_change_allowed( session, @@ -230,7 +233,7 @@ def write_campaign_archive_encryption_policy( record_configuration_change_applied( session, key="campaign_archive_encryption_policy", - before_value=(dict(before.row.policy) if before.row else {}), + before_value=before_policy, after_value=policy_value, actor_user_id=principal.user.id, approval=approval, diff --git a/src/govoplan_policy/backend/manifest.py b/src/govoplan_policy/backend/manifest.py index 8926627..9a5b97f 100644 --- a/src/govoplan_policy/backend/manifest.py +++ b/src/govoplan_policy/backend/manifest.py @@ -28,6 +28,7 @@ from govoplan_core.core.policy import ( ) from govoplan_core.core.modules import ( CapabilityDocumentation, + DocumentationLink, DocumentationTopic, FrontendModule, MigrationSpec, @@ -153,7 +154,7 @@ POLICY_IMPACT_DETAILS_SCOPE = "policy:impact:details" manifest = ModuleManifest( id="policy", name="Policy", - version="0.1.22", + version="0.1.23", permissions=( PermissionDefinition( scope=ACCESS_EXPLANATION_SUBJECT_SCOPE, @@ -540,6 +541,7 @@ manifest = ModuleManifest( summary="Restrict password-protected Campaign ZIP formats and password-delivery channels through an explainable hierarchy.", body=( "The secure baseline permits AES only. An authorized policy administrator may explicitly permit legacy ZipCrypto at system scope, after which tenant, owner group or user, and campaign rules may only narrow the inherited methods. The same intersection controls the separate channel used to convey a password. Policy records the complete source path and a stable policy hash; malformed configuration fails closed. Policy changes never rewrite old build evidence, while Campaign rejects a queued or sent build whose effective policy is now more restrictive." + " To configure the exception, open Administration → SYSTEM → Campaign archive encryption, enable Legacy ZipCrypto, and Save. The system methods and channels remain editable before any explicit override exists; opening default settings alone does not create an override or unsaved changes. Lower scopes inherit until their inheritance switch is disabled and may select only parent-permitted methods and channels. Campaign Settings, Policies, and Attachments link authorized readers to the system and tenant settings and let them reload effective policy. Reading requires admin:policies:read. Saving the global system ceiling requires both system:settings:write and admin:policies:write; lower-scope saves require admin:policies:write. Core's configuration safety catalog validates this registered setting and retains audited before/after and rollback choices; only the two validated format/channel enum lists are exempted from password-name redaction, never real secrets or unknown values. Using the exception additionally requires Campaign's dedicated legacy-encryption permission and a weak-encryption acknowledgment with a reason of at least 10 characters. Saving policy does not send mail or silently change any archive's selected method." ), documentation_types=("admin", "user"), audience=( @@ -549,6 +551,18 @@ manifest = ModuleManifest( "campaign_manager", ), related_modules=("campaign", "audit", "access"), + links=( + DocumentationLink( + label="System Campaign archive encryption", + href="/admin?section=system-campaign-archive-encryption", + kind="runtime", + ), + DocumentationLink( + label="Tenant Campaign archive encryption", + href="/admin?section=tenant-campaign-archive-encryption", + kind="runtime", + ), + ), translations={ "de": { "title": "Verschlüsselung von Campaign-Archiven steuern", @@ -563,6 +577,14 @@ manifest = ModuleManifest( "eines Passworts. Policy zeichnet den vollständigen Quellenpfad und einen stabilen Richtlinienhash auf; fehlerhafte " "Konfiguration schließt sicher. Richtlinienänderungen schreiben alte Erstellungsnachweise niemals um, während Campaign " "einen eingereihten oder versandten Build zurückweist, wenn dessen wirksame Richtlinie inzwischen strenger ist." + " Öffnen Sie zur Konfiguration Administration → SYSTEM → Campaign archive encryption, aktivieren Sie Legacy ZipCrypto und speichern Sie. " + "Methoden und Kanäle auf Systemebene sind schon vor der ersten ausdrücklichen Ausnahme bearbeitbar; das bloße Öffnen erzeugt weder eine Ausnahme noch ungespeicherte Änderungen. " + "Untergeordnete Ebenen erben bis zum Abschalten ihres Vererbungsschalters und dürfen nur übergeordnet erlaubte Methoden und Kanäle wählen. " + "Kampagneneinstellungen, Richtlinien und Anhänge verlinken berechtigte Lesende auf System- und Mandantenkonfiguration und erlauben das Neuladen der wirksamen Richtlinie. " + "Lesen erfordert admin:policies:read. Das Speichern der globalen Systemgrenze benötigt system:settings:write und admin:policies:write gemeinsam; untergeordnete Ebenen benötigen admin:policies:write. " + "Der zentrale Konfigurations-Sicherheitskatalog prüft dieses registrierte Feld und bewahrt auditierte Vorher-/Nachherwerte sowie Rücknahmewerte. Nur die beiden validierten Format-/Kanal-Enumlisten bleiben trotz Passwortbegriff im Feldnamen sichtbar, niemals echte Geheimnisse oder unbekannte Werte. " + "Die Nutzung benötigt zusätzlich Campaigns gesonderte Legacy-Verschlüsselungsberechtigung und die Bestätigung schwacher Verschlüsselung mit mindestens 10 Zeichen Begründung. " + "Das Speichern einer Richtlinie versendet keine E-Mail und ändert keine gewählte Archivmethode stillschweigend." ), } }, diff --git a/tests/test_archive_encryption_api.py b/tests/test_archive_encryption_api.py new file mode 100644 index 0000000..8840420 --- /dev/null +++ b/tests/test_archive_encryption_api.py @@ -0,0 +1,192 @@ +from __future__ import annotations + +from types import SimpleNamespace +import unittest + +from fastapi import FastAPI +from fastapi.testclient import TestClient +from sqlalchemy import create_engine +from sqlalchemy.orm import Session +from sqlalchemy.pool import StaticPool + +from govoplan_core.admin.models import SystemSettings +from govoplan_core.auth import ApiPrincipal, get_api_principal +from govoplan_core.core.access import PrincipalRef +from govoplan_core.core.change_sequence import ChangeSequenceEntry +from govoplan_core.core.configuration_control import ( + configuration_control_snapshot, + create_configuration_change_request, +) +from govoplan_core.core.configuration_safety import ( + classify_configuration_field, + plan_configuration_change, +) +from govoplan_core.db.session import get_session +from govoplan_policy.backend.api.v1.routes import router +from govoplan_policy.backend.db.models import PolicyOverride + + +class CampaignArchiveEncryptionApiTests(unittest.TestCase): + """Exercise the real HTTP route, safety catalog, persistence, and history.""" + + def setUp(self) -> None: + self.engine = create_engine( + "sqlite:///:memory:", + connect_args={"check_same_thread": False}, + poolclass=StaticPool, + ) + self.addCleanup(self.engine.dispose) + for table in (SystemSettings.__table__, PolicyOverride.__table__, ChangeSequenceEntry.__table__): + table.create(self.engine) + self.principal = self._principal("admin:policies:read", "admin:policies:write", "system:settings:write") + app = FastAPI() + app.include_router(router, prefix="/api/v1") + + def session_dependency(): + with Session(self.engine) as session: + yield session + + app.dependency_overrides[get_session] = session_dependency + app.dependency_overrides[get_api_principal] = lambda: self.principal + self.client = TestClient(app) + self.addCleanup(self.client.close) + + @staticmethod + def _principal(*scopes: str) -> ApiPrincipal: + return ApiPrincipal( + principal=PrincipalRef( + account_id="admin-account", membership_id="admin-user", tenant_id="tenant-1", + scopes=frozenset(scopes), + ), + account=SimpleNamespace(id="admin-account"), + user=SimpleNamespace(id="admin-user"), + ) + + def test_system_legacy_opt_in_passes_real_catalog_and_retains_history(self) -> None: + path = "/api/v1/admin/campaign-archive-encryption/policies/system" + field = classify_configuration_field("campaign_archive_encryption_policy") + self.assertIsNotNone(field) + self.assertEqual("policy", field.owner_module) + self.assertTrue(field.validation_required) + self.assertTrue(field.rollback_history_required) + self.assertEqual({}, self.client.get(path).json()["policy"]) + policy = { + "allowed_password_encryption_methods": ["aes", "zip_standard"], + "allowed_password_delivery_channels": ["phone", "letter"], + } + response = self.client.put(path, json={"policy": policy}) + self.assertEqual(200, response.status_code, response.text) + self.assertEqual(policy["allowed_password_encryption_methods"], response.json()["effective_policy"]["allowed_password_encryption_methods"]) + loaded = self.client.get(path) + self.assertEqual(200, loaded.status_code) + self.assertEqual(policy, loaded.json()["policy"]) + with Session(self.engine) as session: + history = configuration_control_snapshot(session)["history"] + self.assertEqual(1, len(history)) + self.assertEqual("campaign_archive_encryption_policy", history[0]["key"]) + self.assertEqual("campaign_archive_encryption_policy.updated", history[0]["audit_event"]) + self.assertEqual({}, history[0]["before"]) + self.assertEqual(policy, history[0]["after"]) + self.assertTrue(history[0]["plan"]["allowed"]) + self.assertEqual([], history[0]["plan"]["blockers"]) + audit_changes = session.query(ChangeSequenceEntry).filter( + ChangeSequenceEntry.module_id == "audit" + ).all() + self.assertEqual(1, len(audit_changes)) + self.assertEqual("campaign_archive_encryption_policy.updated", audit_changes[0].payload["action"]) + + narrowed_policy = {"allowed_password_encryption_methods": ["aes"]} + narrowed = self.client.put(path, json={"policy": narrowed_policy}) + self.assertEqual(200, narrowed.status_code, narrowed.text) + with Session(self.engine) as session: + history = configuration_control_snapshot(session)["history"] + self.assertEqual(2, len(history)) + self.assertEqual(policy, history[0]["before"]) + self.assertEqual(policy, history[0]["rollback_value"]) + self.assertEqual(narrowed_policy, history[0]["after"]) + + def test_read_only_actor_cannot_change_system_policy(self) -> None: + self.principal = self._principal("admin:policies:read") + policy = {"allowed_password_encryption_methods": ["aes", "zip_standard"]} + response = self.client.put( + "/api/v1/admin/campaign-archive-encryption/policies/system", json={"policy": policy} + ) + self.assertEqual(403, response.status_code) + plan = plan_configuration_change("campaign_archive_encryption_policy", actor_scopes=tuple(self.principal.scopes), value=policy) + self.assertFalse(plan.allowed) + self.assertEqual(("system:settings:write", "admin:policies:write"), plan.missing_scopes) + with Session(self.engine) as session: + self.assertEqual(0, session.query(PolicyOverride).count()) + + def test_tenant_policy_writer_cannot_loosen_global_system_ceiling(self) -> None: + self.principal = self._principal("admin:policies:read", "admin:policies:write") + policy = {"allowed_password_encryption_methods": ["aes", "zip_standard"]} + response = self.client.put( + "/api/v1/admin/campaign-archive-encryption/policies/system", json={"policy": policy} + ) + self.assertIn(response.status_code, (403, 409)) + self.assertIn("system:settings:write", response.text) + with Session(self.engine) as session: + self.assertEqual(0, session.query(PolicyOverride).count()) + self.assertEqual(0, session.query(ChangeSequenceEntry).count()) + + narrowed = self.client.put( + "/api/v1/admin/campaign-archive-encryption/policies/tenant", + json={"policy": {"allowed_password_encryption_methods": ["aes"]}}, + ) + self.assertEqual(200, narrowed.status_code, narrowed.text) + + def test_invalid_method_and_child_ceiling_still_fail_closed(self) -> None: + path = "/api/v1/admin/campaign-archive-encryption/policies" + invalid = self.client.put(f"{path}/system", json={"policy": {"allowed_password_encryption_methods": ["plaintext"]}}) + self.assertEqual(422, invalid.status_code) + child = self.client.put(f"{path}/tenant", json={"policy": {"allowed_password_encryption_methods": ["aes", "zip_standard"]}}) + self.assertEqual(422, child.status_code) + self.assertIn("parent", child.text) + with Session(self.engine) as session: + self.assertEqual(0, session.query(PolicyOverride).count()) + + def test_configuration_preview_preserves_only_known_non_secret_enum_lists(self) -> None: + unsafe = { + "allowed_password_encryption_methods": ["aes", "literal-secret"], + "allowed_password_delivery_channels": {"password": "nested-secret"}, + "password": "actual-secret", + "arbitrary_field": ["unknown-secret"], + } + with Session(self.engine) as session: + request = create_configuration_change_request( + session, + key="campaign_archive_encryption_policy", + value=unsafe, + actor_user_id="admin-user", + actor_scopes=tuple(self.principal.scopes), + dry_run=False, + target={"scope_type": "system"}, + ) + self.assertEqual({key: "" for key in unsafe}, request["value_preview"]) + self.assertNotIn("literal-secret", str(configuration_control_snapshot(session))) + self.assertNotIn("actual-secret", str(configuration_control_snapshot(session))) + for malformed in ("scalar-secret", ["list-secret"], None, 7): + with self.subTest(malformed=type(malformed).__name__): + malformed_request = create_configuration_change_request( + session, + key="campaign_archive_encryption_policy", + value=malformed, + actor_user_id="admin-user", + actor_scopes=tuple(self.principal.scopes), + dry_run=False, + target={"scope_type": "system"}, + ) + self.assertEqual("", malformed_request["value_preview"]) + snapshot = str(configuration_control_snapshot(session)) + self.assertNotIn("scalar-secret", snapshot) + self.assertNotIn("list-secret", snapshot) + invalid = self.client.put( + "/api/v1/admin/campaign-archive-encryption/policies/system", + json={"policy": unsafe}, + ) + self.assertEqual(422, invalid.status_code) + + +if __name__ == "__main__": + unittest.main() diff --git a/webui/package.json b/webui/package.json index 42768c0..c10ebf1 100644 --- a/webui/package.json +++ b/webui/package.json @@ -1,6 +1,6 @@ { "name": "@govoplan/policy-webui", - "version": "0.1.22", + "version": "0.1.23", "private": true, "type": "module", "main": "src/index.ts", @@ -13,10 +13,11 @@ } }, "scripts": { - "test:interface-patterns": "node scripts/test-interface-pattern-language.mjs" + "test:interface-patterns": "node scripts/test-interface-pattern-language.mjs", + "test:archive-encryption": "node --experimental-strip-types scripts/test-archive-encryption-draft.mjs" }, "peerDependencies": { - "@govoplan/core-webui": "^0.1.18", + "@govoplan/core-webui": "^0.1.45", "lucide-react": "^1.23.0", "react": ">=19.2.7 <20", "react-dom": ">=19.2.7 <20", diff --git a/webui/scripts/test-archive-encryption-draft.mjs b/webui/scripts/test-archive-encryption-draft.mjs new file mode 100644 index 0000000..f7e4dd0 --- /dev/null +++ b/webui/scripts/test-archive-encryption-draft.mjs @@ -0,0 +1,36 @@ +import assert from "node:assert/strict"; +import { readFileSync } from "node:fs"; +import { + buildPolicy, draftFromPolicy, inheritedControlDisabled, setDraftChannel, + setDraftMethod, stable +} from "../src/features/policy/archiveEncryptionDraft.ts"; + +const baseline = { + allowed_password_encryption_methods: ["aes"], + allowed_password_delivery_channels: ["separate_mail", "sms", "letter", "phone", "in_person"], + policy_hash: "baseline", source_path: [], reason: "Secure baseline", diagnostics: [] +}; +const initial = draftFromPolicy({}, baseline); +assert.deepEqual(buildPolicy(initial), {}, "Opening default system settings must not create an override or dirty state"); +assert.equal(inheritedControlDisabled("system", initial.inheritMethods), false, "System defaults must be editable without a hidden inheritance toggle"); +assert.equal(inheritedControlDisabled("system", initial.inheritChannels), false); +const enabled = setDraftMethod(initial, "zip_standard", true); +assert.deepEqual(buildPolicy(enabled), { allowed_password_encryption_methods: ["aes", "zip_standard"] }, "The first system Legacy click must produce an explicit override"); +assert.notEqual(stable(buildPolicy(enabled)), stable({})); +assert.deepEqual(buildPolicy(initial), {}, "Changing a draft must preserve the original policy"); +const narrowedChannels = setDraftChannel(initial, "sms", false); +assert.deepEqual(buildPolicy(narrowedChannels), { allowed_password_delivery_channels: ["separate_mail", "letter", "phone", "in_person"] }); +assert.equal(inheritedControlDisabled("tenant", initial.inheritMethods), true, "Child scopes retain explicit inheritance controls"); +assert.equal(inheritedControlDisabled("user", false), false); +assert.deepEqual(buildPolicy(draftFromPolicy(buildPolicy(enabled), baseline)), buildPolicy(enabled), "An explicit system policy survives save/reload"); +assert.deepEqual(buildPolicy(setDraftMethod(enabled, "zip_standard", false)), { allowed_password_encryption_methods: ["aes"] }); + +const panel = readFileSync(new URL("../src/features/policy/ArchiveEncryptionPoliciesPanel.tsx", import.meta.url), "utf8"); +assert.match(panel, /inheritedControlDisabled\(scopeType, draft\.inheritMethods\)/); +assert.match(panel, /inheritedControlDisabled\(scopeType, draft\.inheritChannels\)/); +assert.match(panel, /setDraft\(setDraftMethod\(draft, method\.id, checked\)\)/); +assert.match(panel, /setDraft\(setDraftChannel\(draft, channel\.id, checked\)\)/); +assert.match(panel, /scopeType !== "system" && !parentMethods\.includes\(method\.id\)/, "Child scopes must still respect parent ceilings"); +const moduleSource = readFileSync(new URL("../src/module.ts", import.meta.url), "utf8"); +assert.match(moduleSource, /scopeType: "system",\s*canWrite: hasScope\(auth, "system:settings:write"\) && hasScope\(auth, "admin:policies:write"\)/, "Tenant policy administration alone must not enable edits to the global system archive ceiling"); +console.log("Archive encryption settings regressions passed."); diff --git a/webui/src/features/policy/ArchiveEncryptionPoliciesPanel.tsx b/webui/src/features/policy/ArchiveEncryptionPoliciesPanel.tsx index e5c4b32..8ffef3b 100644 --- a/webui/src/features/policy/ArchiveEncryptionPoliciesPanel.tsx +++ b/webui/src/features/policy/ArchiveEncryptionPoliciesPanel.tsx @@ -20,11 +20,19 @@ import { fetchArchiveEncryptionPolicy, updateArchiveEncryptionPolicy, type ArchiveEncryptionMethod, - type ArchiveEncryptionPolicyItem, type ArchiveEncryptionPolicyResponse, type ArchiveEncryptionPolicyScope, type PasswordDeliveryChannel } from "../../api/archiveEncryptionPolicies"; +import { + buildPolicy, + draftFromPolicy, + inheritedControlDisabled, + setDraftChannel, + setDraftMethod, + stable, + type ArchiveEncryptionDraft +} from "./archiveEncryptionDraft"; type Props = { settings: ApiSettings; @@ -32,13 +40,6 @@ type Props = { canWrite: boolean; }; -type Draft = { - inheritMethods: boolean; - methods: ArchiveEncryptionMethod[]; - inheritChannels: boolean; - channels: PasswordDeliveryChannel[]; -}; - const METHODS: Array<{ id: ArchiveEncryptionMethod; label: string; description: string }> = [ { id: "aes", label: "AES (strong, default)", description: "Modern AES encryption for compatible ZIP clients." }, { id: "zip_standard", label: "Legacy ZipCrypto — Windows-compatible, weak encryption", description: "Requires a separate Campaign permission and reasoned acknowledgement." } @@ -56,7 +57,7 @@ export default function ArchiveEncryptionPoliciesPanel({ settings, scopeType, ca const [targets, setTargets] = useState([]); const [targetId, setTargetId] = useState(""); const [state, setState] = useState(null); - const [draft, setDraft] = useState(null); + const [draft, setDraft] = useState(null); const [loading, setLoading] = useState(true); const [busy, setBusy] = useState(false); const [error, setError] = useState(""); @@ -157,11 +158,11 @@ export default function ArchiveEncryptionPoliciesPanel({ settings, scopeType, ca {scopeType !== "system" && setDraft({ ...draft, inheritMethods: checked, methods: checked ? [...parentMethods] : draft.methods })} />} - {METHODS.map((method) => setDraft({ ...draft, methods: toggle(draft.methods, method.id, checked) })} />)} + {METHODS.map((method) => setDraft(setDraftMethod(draft, method.id, checked))} />)} {scopeType !== "system" && setDraft({ ...draft, inheritChannels: checked, channels: checked ? [...parentChannels] : draft.channels })} />} - {CHANNELS.map((channel) => setDraft({ ...draft, channels: toggle(draft.channels, channel.id, checked) })} />)} + {CHANNELS.map((channel) => setDraft(setDraftChannel(draft, channel.id, checked))} />)} @@ -173,33 +174,6 @@ export default function ArchiveEncryptionPoliciesPanel({ settings, scopeType, ca ; } -function draftFromPolicy(policy: ArchiveEncryptionPolicyItem, parent: ArchiveEncryptionPolicyResponse["parent_policy"]): Draft { - return { - inheritMethods: policy.allowed_password_encryption_methods === undefined, - methods: [...(policy.allowed_password_encryption_methods ?? parent.allowed_password_encryption_methods)], - inheritChannels: policy.allowed_password_delivery_channels === undefined, - channels: [...(policy.allowed_password_delivery_channels ?? parent.allowed_password_delivery_channels)] - }; -} - -function buildPolicy(draft: Draft): ArchiveEncryptionPolicyItem { - return { - ...(draft.inheritMethods ? {} : { allowed_password_encryption_methods: draft.methods }), - ...(draft.inheritChannels ? {} : { allowed_password_delivery_channels: draft.channels }) - }; -} - -function stable(value: ArchiveEncryptionPolicyItem): string { - return JSON.stringify({ - methods: value.allowed_password_encryption_methods ? [...value.allowed_password_encryption_methods].sort() : null, - channels: value.allowed_password_delivery_channels ? [...value.allowed_password_delivery_channels].sort() : null - }); -} - -function toggle(values: T[], value: T, checked: boolean): T[] { - return checked ? Array.from(new Set([...values, value])) : values.filter((item) => item !== value); -} - async function loadTargets(settings: ApiSettings, scope: ArchiveEncryptionPolicyScope): Promise { if (scope === "group") { const response = await fetchGroupsDelta(settings, { limit: 1000 }); diff --git a/webui/src/features/policy/archiveEncryptionDraft.ts b/webui/src/features/policy/archiveEncryptionDraft.ts new file mode 100644 index 0000000..cac103e --- /dev/null +++ b/webui/src/features/policy/archiveEncryptionDraft.ts @@ -0,0 +1,54 @@ +import type { + ArchiveEncryptionMethod, + ArchiveEncryptionPolicyItem, + ArchiveEncryptionPolicyResponse, + ArchiveEncryptionPolicyScope, + PasswordDeliveryChannel +} from "../../api/archiveEncryptionPolicies"; + +export type ArchiveEncryptionDraft = { + inheritMethods: boolean; + methods: ArchiveEncryptionMethod[]; + inheritChannels: boolean; + channels: PasswordDeliveryChannel[]; +}; + +export function draftFromPolicy(policy: ArchiveEncryptionPolicyItem, parent: ArchiveEncryptionPolicyResponse["parent_policy"]): ArchiveEncryptionDraft { + return { + inheritMethods: policy.allowed_password_encryption_methods === undefined, + methods: [...(policy.allowed_password_encryption_methods ?? parent.allowed_password_encryption_methods)], + inheritChannels: policy.allowed_password_delivery_channels === undefined, + channels: [...(policy.allowed_password_delivery_channels ?? parent.allowed_password_delivery_channels)] + }; +} + +export function buildPolicy(draft: ArchiveEncryptionDraft): ArchiveEncryptionPolicyItem { + return { + ...(draft.inheritMethods ? {} : { allowed_password_encryption_methods: draft.methods }), + ...(draft.inheritChannels ? {} : { allowed_password_delivery_channels: draft.channels }) + }; +} + +export function stable(value: ArchiveEncryptionPolicyItem): string { + return JSON.stringify({ + methods: value.allowed_password_encryption_methods ? [...value.allowed_password_encryption_methods].sort() : null, + channels: value.allowed_password_delivery_channels ? [...value.allowed_password_delivery_channels].sort() : null + }); +} + +/** System defaults are editable even before the first explicit override exists. */ +export function inheritedControlDisabled(scope: ArchiveEncryptionPolicyScope, inherited: boolean): boolean { + return scope !== "system" && inherited; +} + +export function setDraftMethod(draft: ArchiveEncryptionDraft, method: ArchiveEncryptionMethod, checked: boolean): ArchiveEncryptionDraft { + return { ...draft, inheritMethods: false, methods: toggle(draft.methods, method, checked) }; +} + +export function setDraftChannel(draft: ArchiveEncryptionDraft, channel: PasswordDeliveryChannel, checked: boolean): ArchiveEncryptionDraft { + return { ...draft, inheritChannels: false, channels: toggle(draft.channels, channel, checked) }; +} + +function toggle(values: T[], value: T, checked: boolean): T[] { + return checked ? Array.from(new Set([...values, value])) : values.filter((item) => item !== value); +} diff --git a/webui/src/module.ts b/webui/src/module.ts index 2df91c2..d04fb78 100644 --- a/webui/src/module.ts +++ b/webui/src/module.ts @@ -79,7 +79,7 @@ const policyAdminSections: AdminSectionsUiCapability = { render: ({ settings, auth }) => createElement(ArchiveEncryptionPoliciesPanel, { settings, scopeType: "system", - canWrite: hasScope(auth, "admin:policies:write") + canWrite: hasScope(auth, "system:settings:write") && hasScope(auth, "admin:policies:write") }) }, {