Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
2bd7487ba7 | ||
|
|
29a9aea3b1 | ||
|
|
b5f431c766 |
+2
-2
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "@govoplan/policy-webui",
|
"name": "@govoplan/policy-webui",
|
||||||
"version": "0.1.18",
|
"version": "0.1.23",
|
||||||
"private": true,
|
"private": true,
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"main": "webui/src/index.ts",
|
"main": "webui/src/index.ts",
|
||||||
@@ -18,7 +18,7 @@
|
|||||||
"LICENSE"
|
"LICENSE"
|
||||||
],
|
],
|
||||||
"peerDependencies": {
|
"peerDependencies": {
|
||||||
"@govoplan/core-webui": "^0.1.18",
|
"@govoplan/core-webui": "^0.1.45",
|
||||||
"lucide-react": "^1.23.0",
|
"lucide-react": "^1.23.0",
|
||||||
"react": ">=19.2.7 <20",
|
"react": ">=19.2.7 <20",
|
||||||
"react-dom": ">=19.2.7 <20",
|
"react-dom": ">=19.2.7 <20",
|
||||||
|
|||||||
+2
-2
@@ -4,13 +4,13 @@ build-backend = "setuptools.build_meta"
|
|||||||
|
|
||||||
[project]
|
[project]
|
||||||
name = "govoplan-policy"
|
name = "govoplan-policy"
|
||||||
version = "0.1.20"
|
version = "0.1.23"
|
||||||
description = "GovOPlaN policy platform module."
|
description = "GovOPlaN policy platform module."
|
||||||
readme = "README.md"
|
readme = "README.md"
|
||||||
requires-python = ">=3.12"
|
requires-python = ">=3.12"
|
||||||
authors = [{ name = "GovOPlaN" }]
|
authors = [{ name = "GovOPlaN" }]
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"govoplan-core>=0.1.29",
|
"govoplan-core>=0.1.45",
|
||||||
]
|
]
|
||||||
|
|
||||||
[tool.setuptools.packages.find]
|
[tool.setuptools.packages.find]
|
||||||
|
|||||||
@@ -204,6 +204,9 @@ def write_campaign_archive_encryption_policy(
|
|||||||
owner_type=owner_type,
|
owner_type=owner_type,
|
||||||
owner_id=owner_id,
|
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":
|
if clean_scope == "system":
|
||||||
approval = ensure_configuration_change_allowed(
|
approval = ensure_configuration_change_allowed(
|
||||||
session,
|
session,
|
||||||
@@ -230,7 +233,7 @@ def write_campaign_archive_encryption_policy(
|
|||||||
record_configuration_change_applied(
|
record_configuration_change_applied(
|
||||||
session,
|
session,
|
||||||
key="campaign_archive_encryption_policy",
|
key="campaign_archive_encryption_policy",
|
||||||
before_value=(dict(before.row.policy) if before.row else {}),
|
before_value=before_policy,
|
||||||
after_value=policy_value,
|
after_value=policy_value,
|
||||||
actor_user_id=principal.user.id,
|
actor_user_id=principal.user.id,
|
||||||
approval=approval,
|
approval=approval,
|
||||||
|
|||||||
@@ -0,0 +1,122 @@
|
|||||||
|
"""German translations for public structured documentation metadata."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
|
|
||||||
|
GERMAN_STRUCTURED_TRANSLATIONS: dict[str, dict[str, Any]] = {'policy.data-subject-requests': {'consequence_classes': {'export_policy_attribution': 'Returns '
|
||||||
|
'minimiert '
|
||||||
|
'Politik '
|
||||||
|
'Aktivität '
|
||||||
|
'für das '
|
||||||
|
'genaue '
|
||||||
|
'Konto.',
|
||||||
|
'retain_policy_evidence': 'Bewahrt die '
|
||||||
|
'politische '
|
||||||
|
'Governance-Rechenschaftspflicht.'}},
|
||||||
|
'policy.function-assignment-delegation-escalation': {'fields': [{'consequence': 'Erlaubt nur dann '
|
||||||
|
'geregelte '
|
||||||
|
'abgeleitete '
|
||||||
|
'Zuweisungen, '
|
||||||
|
'wenn '
|
||||||
|
'Organisationen '
|
||||||
|
'auch die '
|
||||||
|
'Funktion '
|
||||||
|
'delegierbar '
|
||||||
|
'markieren.',
|
||||||
|
'key': 'delegation_allowed'},
|
||||||
|
{'consequence': 'Lehnt längere '
|
||||||
|
'aktuelle Ketten '
|
||||||
|
'ab, '
|
||||||
|
'einschließlich '
|
||||||
|
'Ketten, die vor '
|
||||||
|
'einem engeren '
|
||||||
|
'Limit akzeptiert '
|
||||||
|
'wurden.',
|
||||||
|
'key': 'maximum_delegation_depth'},
|
||||||
|
{'consequence': 'Caps jedes '
|
||||||
|
'delegierte '
|
||||||
|
'Gültigkeitsfenster '
|
||||||
|
'zusätzlich zu '
|
||||||
|
'seinem '
|
||||||
|
'Quellfenster.',
|
||||||
|
'key': 'maximum_delegated_validity_days'},
|
||||||
|
{'consequence': 'Pins eine '
|
||||||
|
'Zielfunktion und '
|
||||||
|
'Frist ohne '
|
||||||
|
'Erteilung oder '
|
||||||
|
'Ersatz '
|
||||||
|
'Genehmigung.',
|
||||||
|
'key': 'escalation.<step>'}]},
|
||||||
|
'policy.hierarchy-overrides-and-retention': {'outcome': 'Der ausgewählte Berechtigungsumfang hat '
|
||||||
|
'eine erklärbare Aufbewahrungsrichtlinie '
|
||||||
|
'und jeder destruktiven Anwendung wird '
|
||||||
|
'eine Dry-Run-Überprüfung vorausgegangen.',
|
||||||
|
'prerequisites': ['Policy und Access sind aktiviert.',
|
||||||
|
'Die handelnde Person kann die '
|
||||||
|
'Richtlinieneinstellungen am '
|
||||||
|
'ausgewählten Bereich lesen.'],
|
||||||
|
'steps': ['Überprüfen Sie den effektiven Wert und '
|
||||||
|
'seinen Policy Source Path.',
|
||||||
|
'Schmale nur Felder, die die übergeordnete '
|
||||||
|
'Richtlinie diesen Bereich außer Kraft '
|
||||||
|
'setzt.',
|
||||||
|
'Speichern Sie die Richtlinie und führen '
|
||||||
|
'Sie dann einen System-Dry-Run aus, bevor '
|
||||||
|
'Sie die Retention anwenden.',
|
||||||
|
'Überprüfen Sie das Bounded Outcome und '
|
||||||
|
'prüfen Sie den Nachweis nach einem '
|
||||||
|
'angewandten Durchlauf.'],
|
||||||
|
'verification': 'Laden Sie die Richtlinie neu, '
|
||||||
|
'bestätigen Sie ihren Quellpfad und '
|
||||||
|
'vergleichen Sie die Trockenlauf- '
|
||||||
|
'oder angewandte Ergebnistabelle mit '
|
||||||
|
'den Prüfungsnachweisen.'},
|
||||||
|
'policy.impact-preview': {'limitations': ['Nicht verfügbare optionale Anbieter werden erklärt und '
|
||||||
|
'niemals als Null-Auswirkungen behandelt.',
|
||||||
|
'Ressourcendetails werden ohne policy:impact:details '
|
||||||
|
'ausgeblendet.'],
|
||||||
|
'steps': ['Wählen Sie eine explizite Impact-Provider-Population und ein '
|
||||||
|
'begrenztes Limit.',
|
||||||
|
'Preview und Inspect Outcome Counts, Coverage State und '
|
||||||
|
'Provenienz.',
|
||||||
|
'Reauthentifizieren, wenn eine systemweite Änderung als hohe '
|
||||||
|
'Auswirkungen eingestuft wird.',
|
||||||
|
'Speichern Sie erst, nachdem die Vorschau mit dem aktuellen '
|
||||||
|
'Dirty Draft übereinstimmt.']},
|
||||||
|
'policy.retention-execution-and-recovery': {'limitations': ['Die Anwendung kann gelöschte EML- '
|
||||||
|
'oder Mock-Mailbox-Inhalte nicht '
|
||||||
|
'wiederherstellen.',
|
||||||
|
'Ein Trockenlauf ist eine Vorschau '
|
||||||
|
'und reserviert den gemeldeten Satz '
|
||||||
|
'nicht gegen gleichzeitige '
|
||||||
|
'Änderungen.'],
|
||||||
|
'outcome': 'Förderfähige Details werden redigiert und '
|
||||||
|
'förderfähige generierte Artefakte werden '
|
||||||
|
'mit begrenztem Ergebnis und '
|
||||||
|
'Prüfungsnachweis gelöscht.',
|
||||||
|
'prerequisites': ['Die handelnde Person kann '
|
||||||
|
'Systemeinstellungen schreiben.',
|
||||||
|
'Die vorgesehene '
|
||||||
|
'Systemaufbewahrungsrichtlinie wird '
|
||||||
|
'gespeichert und neu geladen.',
|
||||||
|
'Recovery Evidenz ist aktuell für '
|
||||||
|
'generierte Artefakte.'],
|
||||||
|
'steps': ['Führen Sie einen Trockenlauf durch und '
|
||||||
|
'überprüfen Sie jede Datenklasse und '
|
||||||
|
'Ergebniszahl.',
|
||||||
|
'Stoppen Sie, wenn Anbieter ausfallen, die '
|
||||||
|
'Wiederherstellung blockiert wird oder '
|
||||||
|
'Zählungen unerwartet sind.',
|
||||||
|
'Bestätigen Sie den destruktiven Lauf erst '
|
||||||
|
'nach einer Überprüfung der Politik und der '
|
||||||
|
'Wiederherstellung.',
|
||||||
|
'Vergleichen Sie das angewandte Ergebnis '
|
||||||
|
'mit den Prüfungsnachweisen.'],
|
||||||
|
'verification': 'Überprüfen Sie das neueste Ergebnis, '
|
||||||
|
'die Fehler- und '
|
||||||
|
'Wiederherstellungszahlen des '
|
||||||
|
'Anbieters und suchen Sie dann den '
|
||||||
|
'Auditdatensatz retention '
|
||||||
|
'policy.run.'}}
|
||||||
@@ -1,5 +1,8 @@
|
|||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from govoplan_core.core.modules import with_documentation_structured_translations
|
||||||
|
from govoplan_policy.backend.german_structured_documentation import GERMAN_STRUCTURED_TRANSLATIONS
|
||||||
|
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
|
|
||||||
from govoplan_core.core.access import (
|
from govoplan_core.core.access import (
|
||||||
@@ -25,6 +28,7 @@ from govoplan_core.core.policy import (
|
|||||||
)
|
)
|
||||||
from govoplan_core.core.modules import (
|
from govoplan_core.core.modules import (
|
||||||
CapabilityDocumentation,
|
CapabilityDocumentation,
|
||||||
|
DocumentationLink,
|
||||||
DocumentationTopic,
|
DocumentationTopic,
|
||||||
FrontendModule,
|
FrontendModule,
|
||||||
MigrationSpec,
|
MigrationSpec,
|
||||||
@@ -150,7 +154,7 @@ POLICY_IMPACT_DETAILS_SCOPE = "policy:impact:details"
|
|||||||
manifest = ModuleManifest(
|
manifest = ModuleManifest(
|
||||||
id="policy",
|
id="policy",
|
||||||
name="Policy",
|
name="Policy",
|
||||||
version="0.1.20",
|
version="0.1.23",
|
||||||
permissions=(
|
permissions=(
|
||||||
PermissionDefinition(
|
PermissionDefinition(
|
||||||
scope=ACCESS_EXPLANATION_SUBJECT_SCOPE,
|
scope=ACCESS_EXPLANATION_SUBJECT_SCOPE,
|
||||||
@@ -230,6 +234,25 @@ manifest = ModuleManifest(
|
|||||||
documentation_types=("admin", "user"),
|
documentation_types=("admin", "user"),
|
||||||
audience=("policy_admin", "data_steward", "auditor"),
|
audience=("policy_admin", "data_steward", "auditor"),
|
||||||
related_modules=("datasources", "access", "audit"),
|
related_modules=("datasources", "access", "audit"),
|
||||||
|
translations={
|
||||||
|
"de": {
|
||||||
|
"title": "Richtlinienebenen für die Sichtbarkeit von Datenquellen",
|
||||||
|
"summary": (
|
||||||
|
"Die von Datasources verwaltete Sichtbarkeit für ACLs, Felder und Zeilen durch referenzierte hierarchische "
|
||||||
|
"Richtlinien weiter einschränken."
|
||||||
|
),
|
||||||
|
"body": (
|
||||||
|
"Datasources besitzt die Durchsetzung und eine lokale Sichtbarkeitsgrundlage. Policy kann für das globale Ziel und "
|
||||||
|
"einen ausdrücklich referenzierten Richtlinienschlüssel zusätzliche Ebenen auf System-, Mandanten-, Gruppen- oder "
|
||||||
|
"Benutzerebene liefern. Jede passende Ebene wirkt als weitere Einschränkung und kann keine Quelle, kein Feld und keine "
|
||||||
|
"Zeile wiederherstellen, die eine andere Ebene entfernt hat. Eine nicht auflösbare Referenz, fehlerhafte Nutzdaten oder "
|
||||||
|
"eine nicht verfügbare Entscheidung schließen den Zugriff sicher. Entscheidungsnachweise enthalten Richtlinienkennungen, "
|
||||||
|
"Geltungsbereiche, Revisionen und einen stabilen Hash, aber niemals Zeilen- oder Feldwerte, Connector-Endpunkte oder "
|
||||||
|
"Zugangsdaten. Ist Policy nicht installiert und keine externe Richtlinienreferenz konfiguriert, setzt Datasources seine "
|
||||||
|
"lokalen Regeln für Umfang, ACL, Projektion, Schwärzung und Zeilenfilterung weiterhin durch."
|
||||||
|
),
|
||||||
|
}
|
||||||
|
},
|
||||||
order=28,
|
order=28,
|
||||||
),
|
),
|
||||||
DocumentationTopic(
|
DocumentationTopic(
|
||||||
@@ -253,6 +276,23 @@ manifest = ModuleManifest(
|
|||||||
documentation_types=("admin", "user"),
|
documentation_types=("admin", "user"),
|
||||||
audience=("user", "policy_admin", "privacy_officer", "auditor"),
|
audience=("user", "policy_admin", "privacy_officer", "auditor"),
|
||||||
related_modules=("core", "access", "audit"),
|
related_modules=("core", "access", "audit"),
|
||||||
|
translations={
|
||||||
|
"de": {
|
||||||
|
"title": "Betroffenenanfragen für Richtlinien",
|
||||||
|
"summary": (
|
||||||
|
"Zuordnung von Richtlinienänderungen exportieren, ohne Richtliniendokumente oder eingegrenzte "
|
||||||
|
"Betroffenenkennungen offenzulegen."
|
||||||
|
),
|
||||||
|
"body": (
|
||||||
|
"Policy gleicht innerhalb des aktiven Mandanten nur eine exakte Kontokennung ab und kann eine bereits verifizierte Suche "
|
||||||
|
"auf eine einzelne Überschreibung begrenzen. Ausgegeben werden minimierte Erstellungs- und Änderungsaktivitäten mit "
|
||||||
|
"Richtlinienfamilie, Bereichstyp, Revision und Zeitpunkten. Richtlinienwerte, Ziel- und Bereichsschlüssel, "
|
||||||
|
"Bereichskennungen und Entscheidungsherkunft sind nicht enthalten. Systemweite Überschreibungen werden nicht in eine "
|
||||||
|
"Mandantenanfrage projiziert. Die Zuordnung von Richtlinienänderungen bleibt Governance-Nachweis und wird aufbewahrt, "
|
||||||
|
"statt automatisch gelöscht zu werden."
|
||||||
|
),
|
||||||
|
}
|
||||||
|
},
|
||||||
metadata={
|
metadata={
|
||||||
"help_contexts": ["privacy.data-subject-requests"],
|
"help_contexts": ["privacy.data-subject-requests"],
|
||||||
"consequence_classes": {
|
"consequence_classes": {
|
||||||
@@ -280,6 +320,22 @@ manifest = ModuleManifest(
|
|||||||
documentation_types=("admin", "user"),
|
documentation_types=("admin", "user"),
|
||||||
audience=("user", "tenant_admin", "policy_admin"),
|
audience=("user", "tenant_admin", "policy_admin"),
|
||||||
related_modules=("access", "audit", "campaign", "files"),
|
related_modules=("access", "audit", "campaign", "files"),
|
||||||
|
translations={
|
||||||
|
"de": {
|
||||||
|
"title": "Zielpersonen für Zugriffsdiagnosen auswählen",
|
||||||
|
"summary": (
|
||||||
|
"Policy beschränkt Zugriffserklärungen auf die angemeldete Person, sofern die handelnde Person nicht die Berechtigung "
|
||||||
|
"zur Diagnose für ausgewählte Benutzende besitzt."
|
||||||
|
),
|
||||||
|
"body": (
|
||||||
|
"Files und Campaign verwenden die gemeinsame Auswahl für Zugriffserklärungen. Ohne "
|
||||||
|
"policy:access_explanation:select_user liefert Access nur die angemeldete Person und legt keine Metadaten des "
|
||||||
|
"Mandantenverzeichnisses offen. Erlaubte Erklärungen für andere Personen bleiben auf den aktiven Mandanten begrenzt und "
|
||||||
|
"werden als administrative Diagnose im Auditnachweis festgehalten. Die Berechtigung erweitert nur die Sichtbarkeit der "
|
||||||
|
"Diagnose; sie gewährt keinen Zugriff auf die erklärte Ressource."
|
||||||
|
),
|
||||||
|
}
|
||||||
|
},
|
||||||
metadata={
|
metadata={
|
||||||
"kind": "reference",
|
"kind": "reference",
|
||||||
"help_contexts": ["access.resource-explanation.subject"],
|
"help_contexts": ["access.resource-explanation.subject"],
|
||||||
@@ -296,6 +352,23 @@ manifest = ModuleManifest(
|
|||||||
documentation_types=("admin", "user"),
|
documentation_types=("admin", "user"),
|
||||||
audience=("system_admin", "tenant_admin", "policy_admin"),
|
audience=("system_admin", "tenant_admin", "policy_admin"),
|
||||||
related_modules=("views", "admin", "access"),
|
related_modules=("views", "admin", "access"),
|
||||||
|
translations={
|
||||||
|
"de": {
|
||||||
|
"title": "View-Verfügbarkeit und -Aktionen steuern",
|
||||||
|
"summary": (
|
||||||
|
"View-Richtlinien begrenzen verfügbare Definitionen und Oberflächen sowie die View-Aktionen, die untergeordnete "
|
||||||
|
"Ebenen ausführen dürfen."
|
||||||
|
),
|
||||||
|
"body": (
|
||||||
|
"View-Richtlinien auf System-, Mandanten-, Gruppen- und Benutzerebene bilden eine einschränkende Hierarchie. Jede Ebene "
|
||||||
|
"kann Anzeigen, Auswählen, Zuweisen, Bearbeiten, Ableiten und Workflow-Aktivierung erben, erlauben oder blockieren. "
|
||||||
|
"Optionale Obergrenzen für View- und Oberflächenkennungen werden entlang der Hierarchie geschnitten, sodass eine "
|
||||||
|
"untergeordnete Ebene einen darüber ausgeschlossenen Eintrag nicht wiederherstellen kann. Verfügbare, standardmäßige "
|
||||||
|
"und verpflichtende View-Zuweisungen gehören weiterhin Views; Policy liefert die Aktions- und Katalogobergrenze und "
|
||||||
|
"zeichnet Herkunft sowie Diagnosen fehlerhafter Richtlinien auf."
|
||||||
|
),
|
||||||
|
}
|
||||||
|
},
|
||||||
metadata={
|
metadata={
|
||||||
"kind": "reference",
|
"kind": "reference",
|
||||||
"help_contexts": [
|
"help_contexts": [
|
||||||
@@ -316,7 +389,31 @@ manifest = ModuleManifest(
|
|||||||
),
|
),
|
||||||
documentation_types=("admin", "user"),
|
documentation_types=("admin", "user"),
|
||||||
audience=("tenant_admin", "policy_admin", "access_admin", "user"),
|
audience=("tenant_admin", "policy_admin", "access_admin", "user"),
|
||||||
related_modules=("idm", "organizations", "workflow_engine", "notifications", "audit"),
|
related_modules=(
|
||||||
|
"idm",
|
||||||
|
"organizations",
|
||||||
|
"workflow_engine",
|
||||||
|
"notifications",
|
||||||
|
"audit",
|
||||||
|
),
|
||||||
|
translations={
|
||||||
|
"de": {
|
||||||
|
"title": "Funktionsdelegation und Prüfeskalation steuern",
|
||||||
|
"summary": (
|
||||||
|
"Policy begrenzt vollständige Delegationsketten und definiert ausdrückliche Zielfunktionen für überfällige "
|
||||||
|
"Zuweisungsprüfungen."
|
||||||
|
),
|
||||||
|
"body": (
|
||||||
|
"Mandantenstandards und Funktionseinstellungen können Delegation erlauben, Kettentiefe und Gültigkeit begrenzen und "
|
||||||
|
"eine Prüfungsfrist für Inhaber, verantwortliche Stelle oder empfangende Person mit exakter Eskalations-Zielfunktion "
|
||||||
|
"festlegen. IDM prüft die vollständige aktuelle Kette und die wirksame Policy bei Einreichung, jeder Entscheidung, "
|
||||||
|
"Wiederherstellung und Anwendung erneut. Eine verschärfte Grenze verwirft einen älteren Weg mit Begründung. Eine "
|
||||||
|
"Fristüberschreitung erzeugt einen sichtbaren eskalierten Zustand, ersetzt aber keine freigebende Person und schließt "
|
||||||
|
"die Prüfung nicht ab; eine aktuelle Inhaberin oder ein aktueller Inhaber der Zielfunktion muss ausdrücklich entscheiden. "
|
||||||
|
"Fehlerhafte oder unvollständige Eskalationsregeln schließen sicher."
|
||||||
|
),
|
||||||
|
}
|
||||||
|
},
|
||||||
metadata={
|
metadata={
|
||||||
"kind": "reference",
|
"kind": "reference",
|
||||||
"help_contexts": [
|
"help_contexts": [
|
||||||
@@ -324,10 +421,22 @@ manifest = ModuleManifest(
|
|||||||
"idm.field.escalation",
|
"idm.field.escalation",
|
||||||
],
|
],
|
||||||
"fields": [
|
"fields": [
|
||||||
{"key": "delegation_allowed", "consequence": "Allows governed derived assignments only when Organizations also marks the function delegable."},
|
{
|
||||||
{"key": "maximum_delegation_depth", "consequence": "Rejects longer current chains, including chains accepted before a tighter limit."},
|
"key": "delegation_allowed",
|
||||||
{"key": "maximum_delegated_validity_days", "consequence": "Caps each delegated validity window in addition to its source window."},
|
"consequence": "Allows governed derived assignments only when Organizations also marks the function delegable.",
|
||||||
{"key": "escalation.<step>", "consequence": "Pins a target function and deadline without granting or substituting approval."},
|
},
|
||||||
|
{
|
||||||
|
"key": "maximum_delegation_depth",
|
||||||
|
"consequence": "Rejects longer current chains, including chains accepted before a tighter limit.",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"key": "maximum_delegated_validity_days",
|
||||||
|
"consequence": "Caps each delegated validity window in addition to its source window.",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"key": "escalation.<step>",
|
||||||
|
"consequence": "Pins a target function and deadline without granting or substituting approval.",
|
||||||
|
},
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
),
|
),
|
||||||
@@ -338,6 +447,21 @@ manifest = ModuleManifest(
|
|||||||
body="A lower scope may narrow an inherited ceiling but cannot silently loosen a stronger system or tenant rule. Consuming modules remain responsible for enforcing the returned decision and displaying its reason. Malformed explicit policy fails closed for the affected governed action rather than being treated as absent.",
|
body="A lower scope may narrow an inherited ceiling but cannot silently loosen a stronger system or tenant rule. Consuming modules remain responsible for enforcing the returned decision and displaying its reason. Malformed explicit policy fails closed for the affected governed action rather than being treated as absent.",
|
||||||
documentation_types=("user",),
|
documentation_types=("user",),
|
||||||
audience=("user", "tenant_admin", "policy_admin"),
|
audience=("user", "tenant_admin", "policy_admin"),
|
||||||
|
translations={
|
||||||
|
"de": {
|
||||||
|
"title": "Wirksame Richtlinienentscheidungen verstehen",
|
||||||
|
"summary": (
|
||||||
|
"Richtlinienentscheidungen erläutern, ob eine Aktion erlaubt, begrenzt, geerbt oder nicht verfügbar ist, und nennen "
|
||||||
|
"die Quellen des Ergebnisses."
|
||||||
|
),
|
||||||
|
"body": (
|
||||||
|
"Eine untergeordnete Ebene darf eine geerbte Obergrenze verschärfen, aber eine stärkere System- oder Mandantenregel "
|
||||||
|
"nicht stillschweigend lockern. Die nutzenden Module bleiben dafür verantwortlich, die gelieferte Entscheidung "
|
||||||
|
"durchzusetzen und ihre Begründung anzuzeigen. Eine ausdrücklich konfigurierte fehlerhafte Richtlinie schließt die "
|
||||||
|
"betroffene gesteuerte Aktion sicher, statt als nicht vorhanden zu gelten."
|
||||||
|
),
|
||||||
|
}
|
||||||
|
},
|
||||||
metadata={"kind": "reference"},
|
metadata={"kind": "reference"},
|
||||||
),
|
),
|
||||||
DocumentationTopic(
|
DocumentationTopic(
|
||||||
@@ -365,6 +489,26 @@ manifest = ModuleManifest(
|
|||||||
documentation_types=("admin",),
|
documentation_types=("admin",),
|
||||||
audience=("system_admin", "tenant_admin", "policy_admin"),
|
audience=("system_admin", "tenant_admin", "policy_admin"),
|
||||||
related_modules=("admin", "audit", "views"),
|
related_modules=("admin", "audit", "views"),
|
||||||
|
translations={
|
||||||
|
"de": {
|
||||||
|
"title": "Richtlinienauswirkung vor dem Speichern prüfen",
|
||||||
|
"summary": (
|
||||||
|
"Aktuelle und vorgeschlagene wirksame Richtlinie über ausdrücklich ausgewählte, begrenzte Provider-Populationen "
|
||||||
|
"vergleichen, ohne den Vorschlag zu speichern."
|
||||||
|
),
|
||||||
|
"body": (
|
||||||
|
"Die Policy-Auswirkungsvorschau gruppiert neu erlaubte, neu verweigerte, unveränderte und unbestimmte Wirkungen und "
|
||||||
|
"hält Regel-, Quellen- und Bereichsherkunft fest. Aufrufende müssen eine bis zehn Provider-Populationen und je Population "
|
||||||
|
"eine Grenze von höchstens 500 Subjekten wählen; Policy durchsucht die Plattform niemals implizit. Der "
|
||||||
|
"Populationsnachweis kennzeichnet Ergebnisse als vollständig, stichprobenartig, abgeschnitten oder nicht verfügbar. "
|
||||||
|
"Mit Leseberechtigung für Richtlinien sind aggregierte Anzahlen sichtbar, während policy:impact:details "
|
||||||
|
"Ressourcenkennungen und -bezeichnungen steuert. Jede Vorschau wird mit Vorschlagshash und begrenzten Anzahlen auditiert. "
|
||||||
|
"Systemweite View-Richtlinienänderungen verlangen eine Authentifizierung innerhalb der letzten 15 Minuten und behalten "
|
||||||
|
"ihre vorhandenen Audit- und Konfigurationsfreigabenachweise. Optionale Module liefern Subjekte über den Core-Providervertrag; "
|
||||||
|
"Policy importiert weder ihre Modelle noch ihre Dienste."
|
||||||
|
),
|
||||||
|
}
|
||||||
|
},
|
||||||
metadata={
|
metadata={
|
||||||
"kind": "workflow",
|
"kind": "workflow",
|
||||||
"route": "/admin?section=system-view-policy",
|
"route": "/admin?section=system-view-policy",
|
||||||
@@ -397,6 +541,7 @@ manifest = ModuleManifest(
|
|||||||
summary="Restrict password-protected Campaign ZIP formats and password-delivery channels through an explainable hierarchy.",
|
summary="Restrict password-protected Campaign ZIP formats and password-delivery channels through an explainable hierarchy.",
|
||||||
body=(
|
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."
|
"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"),
|
documentation_types=("admin", "user"),
|
||||||
audience=(
|
audience=(
|
||||||
@@ -406,6 +551,43 @@ manifest = ModuleManifest(
|
|||||||
"campaign_manager",
|
"campaign_manager",
|
||||||
),
|
),
|
||||||
related_modules=("campaign", "audit", "access"),
|
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",
|
||||||
|
"summary": (
|
||||||
|
"Formate passwortgeschützter Campaign-ZIP-Dateien und Übertragungskanäle für Passwörter über eine erklärbare "
|
||||||
|
"Hierarchie einschränken."
|
||||||
|
),
|
||||||
|
"body": (
|
||||||
|
"Die sichere Grundlage erlaubt nur AES. Eine berechtigte Richtlinienadministration kann das veraltete ZipCrypto auf "
|
||||||
|
"Systemebene ausdrücklich zulassen; Regeln auf Mandanten-, Eigentümergruppen-, Benutzer- und Campaign-Ebene dürfen die "
|
||||||
|
"geerbten Methoden anschließend nur weiter einschränken. Derselbe Schnitt steuert getrennt den Kanal zur Übermittlung "
|
||||||
|
"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."
|
||||||
|
),
|
||||||
|
}
|
||||||
|
},
|
||||||
metadata={
|
metadata={
|
||||||
"kind": "reference",
|
"kind": "reference",
|
||||||
"help_contexts": [
|
"help_contexts": [
|
||||||
@@ -720,5 +902,10 @@ manifest = ModuleManifest(
|
|||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
manifest = with_documentation_structured_translations(
|
||||||
|
manifest, locale="de", translations=GERMAN_STRUCTURED_TRANSLATIONS
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
def get_manifest() -> ModuleManifest:
|
def get_manifest() -> ModuleManifest:
|
||||||
return manifest
|
return manifest
|
||||||
|
|||||||
@@ -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: "<redacted>" 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("<redacted>", 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()
|
||||||
@@ -26,6 +26,18 @@ ROOT = pathlib.Path(__file__).resolve().parents[1]
|
|||||||
|
|
||||||
|
|
||||||
class PolicyModuleContractTests(unittest.TestCase):
|
class PolicyModuleContractTests(unittest.TestCase):
|
||||||
|
def test_all_static_topics_have_complete_german_content(self) -> None:
|
||||||
|
for topic in manifest.documentation:
|
||||||
|
german = (topic.translations or {}).get("de", {})
|
||||||
|
self.assertEqual(
|
||||||
|
{"title", "summary", "body"},
|
||||||
|
set(german),
|
||||||
|
topic.id,
|
||||||
|
)
|
||||||
|
self.assertTrue(
|
||||||
|
all(str(value).strip() for value in german.values()), topic.id
|
||||||
|
)
|
||||||
|
|
||||||
def test_policy_package_does_not_hard_require_access(self) -> None:
|
def test_policy_package_does_not_hard_require_access(self) -> None:
|
||||||
project = tomllib.loads((ROOT / "pyproject.toml").read_text(encoding="utf-8"))[
|
project = tomllib.loads((ROOT / "pyproject.toml").read_text(encoding="utf-8"))[
|
||||||
"project"
|
"project"
|
||||||
|
|||||||
+4
-3
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "@govoplan/policy-webui",
|
"name": "@govoplan/policy-webui",
|
||||||
"version": "0.1.20",
|
"version": "0.1.23",
|
||||||
"private": true,
|
"private": true,
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"main": "src/index.ts",
|
"main": "src/index.ts",
|
||||||
@@ -13,10 +13,11 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"scripts": {
|
"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": {
|
"peerDependencies": {
|
||||||
"@govoplan/core-webui": "^0.1.18",
|
"@govoplan/core-webui": "^0.1.45",
|
||||||
"lucide-react": "^1.23.0",
|
"lucide-react": "^1.23.0",
|
||||||
"react": ">=19.2.7 <20",
|
"react": ">=19.2.7 <20",
|
||||||
"react-dom": ">=19.2.7 <20",
|
"react-dom": ">=19.2.7 <20",
|
||||||
|
|||||||
@@ -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.");
|
||||||
@@ -20,11 +20,19 @@ import {
|
|||||||
fetchArchiveEncryptionPolicy,
|
fetchArchiveEncryptionPolicy,
|
||||||
updateArchiveEncryptionPolicy,
|
updateArchiveEncryptionPolicy,
|
||||||
type ArchiveEncryptionMethod,
|
type ArchiveEncryptionMethod,
|
||||||
type ArchiveEncryptionPolicyItem,
|
|
||||||
type ArchiveEncryptionPolicyResponse,
|
type ArchiveEncryptionPolicyResponse,
|
||||||
type ArchiveEncryptionPolicyScope,
|
type ArchiveEncryptionPolicyScope,
|
||||||
type PasswordDeliveryChannel
|
type PasswordDeliveryChannel
|
||||||
} from "../../api/archiveEncryptionPolicies";
|
} from "../../api/archiveEncryptionPolicies";
|
||||||
|
import {
|
||||||
|
buildPolicy,
|
||||||
|
draftFromPolicy,
|
||||||
|
inheritedControlDisabled,
|
||||||
|
setDraftChannel,
|
||||||
|
setDraftMethod,
|
||||||
|
stable,
|
||||||
|
type ArchiveEncryptionDraft
|
||||||
|
} from "./archiveEncryptionDraft";
|
||||||
|
|
||||||
type Props = {
|
type Props = {
|
||||||
settings: ApiSettings;
|
settings: ApiSettings;
|
||||||
@@ -32,13 +40,6 @@ type Props = {
|
|||||||
canWrite: boolean;
|
canWrite: boolean;
|
||||||
};
|
};
|
||||||
|
|
||||||
type Draft = {
|
|
||||||
inheritMethods: boolean;
|
|
||||||
methods: ArchiveEncryptionMethod[];
|
|
||||||
inheritChannels: boolean;
|
|
||||||
channels: PasswordDeliveryChannel[];
|
|
||||||
};
|
|
||||||
|
|
||||||
const METHODS: Array<{ id: ArchiveEncryptionMethod; label: string; description: string }> = [
|
const METHODS: Array<{ id: ArchiveEncryptionMethod; label: string; description: string }> = [
|
||||||
{ id: "aes", label: "AES (strong, default)", description: "Modern AES encryption for compatible ZIP clients." },
|
{ 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." }
|
{ 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<SearchableSelectOption[]>([]);
|
const [targets, setTargets] = useState<SearchableSelectOption[]>([]);
|
||||||
const [targetId, setTargetId] = useState("");
|
const [targetId, setTargetId] = useState("");
|
||||||
const [state, setState] = useState<ArchiveEncryptionPolicyResponse | null>(null);
|
const [state, setState] = useState<ArchiveEncryptionPolicyResponse | null>(null);
|
||||||
const [draft, setDraft] = useState<Draft | null>(null);
|
const [draft, setDraft] = useState<ArchiveEncryptionDraft | null>(null);
|
||||||
const [loading, setLoading] = useState(true);
|
const [loading, setLoading] = useState(true);
|
||||||
const [busy, setBusy] = useState(false);
|
const [busy, setBusy] = useState(false);
|
||||||
const [error, setError] = useState("");
|
const [error, setError] = useState("");
|
||||||
@@ -157,11 +158,11 @@ export default function ArchiveEncryptionPoliciesPanel({ settings, scopeType, ca
|
|||||||
</DismissibleAlert>
|
</DismissibleAlert>
|
||||||
<Card title="Allowed password-encryption methods">
|
<Card title="Allowed password-encryption methods">
|
||||||
{scopeType !== "system" && <ToggleSwitch label="Inherit methods from the parent scope" checked={draft.inheritMethods} disabled={!canWrite || busy} onChange={(checked) => setDraft({ ...draft, inheritMethods: checked, methods: checked ? [...parentMethods] : draft.methods })} />}
|
{scopeType !== "system" && <ToggleSwitch label="Inherit methods from the parent scope" checked={draft.inheritMethods} disabled={!canWrite || busy} onChange={(checked) => setDraft({ ...draft, inheritMethods: checked, methods: checked ? [...parentMethods] : draft.methods })} />}
|
||||||
{METHODS.map((method) => <ToggleSwitch key={method.id} label={method.label} help={method.description} checked={draft.methods.includes(method.id)} disabled={!canWrite || busy || draft.inheritMethods || (scopeType !== "system" && !parentMethods.includes(method.id))} onChange={(checked) => setDraft({ ...draft, methods: toggle(draft.methods, method.id, checked) })} />)}
|
{METHODS.map((method) => <ToggleSwitch key={method.id} label={method.label} help={method.description} checked={draft.methods.includes(method.id)} disabled={!canWrite || busy || inheritedControlDisabled(scopeType, draft.inheritMethods) || (scopeType !== "system" && !parentMethods.includes(method.id))} onChange={(checked) => setDraft(setDraftMethod(draft, method.id, checked))} />)}
|
||||||
</Card>
|
</Card>
|
||||||
<Card title="Allowed separate password-delivery channels">
|
<Card title="Allowed separate password-delivery channels">
|
||||||
{scopeType !== "system" && <ToggleSwitch label="Inherit channels from the parent scope" checked={draft.inheritChannels} disabled={!canWrite || busy} onChange={(checked) => setDraft({ ...draft, inheritChannels: checked, channels: checked ? [...parentChannels] : draft.channels })} />}
|
{scopeType !== "system" && <ToggleSwitch label="Inherit channels from the parent scope" checked={draft.inheritChannels} disabled={!canWrite || busy} onChange={(checked) => setDraft({ ...draft, inheritChannels: checked, channels: checked ? [...parentChannels] : draft.channels })} />}
|
||||||
{CHANNELS.map((channel) => <ToggleSwitch key={channel.id} label={channel.label} checked={draft.channels.includes(channel.id)} disabled={!canWrite || busy || draft.inheritChannels || (scopeType !== "system" && !parentChannels.includes(channel.id))} onChange={(checked) => setDraft({ ...draft, channels: toggle(draft.channels, channel.id, checked) })} />)}
|
{CHANNELS.map((channel) => <ToggleSwitch key={channel.id} label={channel.label} checked={draft.channels.includes(channel.id)} disabled={!canWrite || busy || inheritedControlDisabled(scopeType, draft.inheritChannels) || (scopeType !== "system" && !parentChannels.includes(channel.id))} onChange={(checked) => setDraft(setDraftChannel(draft, channel.id, checked))} />)}
|
||||||
</Card>
|
</Card>
|
||||||
<Card title="Effective policy evidence">
|
<Card title="Effective policy evidence">
|
||||||
<DescriptionList>
|
<DescriptionList>
|
||||||
@@ -173,33 +174,6 @@ export default function ArchiveEncryptionPoliciesPanel({ settings, scopeType, ca
|
|||||||
</AdminPageLayout>;
|
</AdminPageLayout>;
|
||||||
}
|
}
|
||||||
|
|
||||||
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<T extends string>(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<SearchableSelectOption[]> {
|
async function loadTargets(settings: ApiSettings, scope: ArchiveEncryptionPolicyScope): Promise<SearchableSelectOption[]> {
|
||||||
if (scope === "group") {
|
if (scope === "group") {
|
||||||
const response = await fetchGroupsDelta(settings, { limit: 1000 });
|
const response = await fetchGroupsDelta(settings, { limit: 1000 });
|
||||||
|
|||||||
@@ -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<T extends string>(values: T[], value: T, checked: boolean): T[] {
|
||||||
|
return checked ? Array.from(new Set([...values, value])) : values.filter((item) => item !== value);
|
||||||
|
}
|
||||||
+1
-1
@@ -79,7 +79,7 @@ const policyAdminSections: AdminSectionsUiCapability = {
|
|||||||
render: ({ settings, auth }) => createElement(ArchiveEncryptionPoliciesPanel, {
|
render: ({ settings, auth }) => createElement(ArchiveEncryptionPoliciesPanel, {
|
||||||
settings,
|
settings,
|
||||||
scopeType: "system",
|
scopeType: "system",
|
||||||
canWrite: hasScope(auth, "admin:policies:write")
|
canWrite: hasScope(auth, "system:settings:write") && hasScope(auth, "admin:policies:write")
|
||||||
})
|
})
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
|
|||||||
Reference in New Issue
Block a user