Harden module update compatibility cleanup
Some checks failed
Dependency Audit / dependency-audit (push) Successful in 1m46s
Module Matrix / module-matrix (push) Failing after 2m38s
Release Integration / release-integration (push) Failing after 1m41s

This commit is contained in:
2026-07-10 23:27:48 +02:00
parent 2b0cdf13f3
commit b788afcae1
16 changed files with 1442 additions and 886 deletions

View File

@@ -1,5 +1,5 @@
[![Module Matrix](https://git.add-ideas.de/add-ideas/govoplan-core/actions/workflows/module-matrix.yml/badge.svg?branch=main)](https://git.add-ideas.de/add-ideas/govoplan-core/actions/workflows/module-matrix.yml) [![Module Matrix](https://git.add-ideas.de/add-ideas/govoplan-core/actions/workflows/module-matrix.yml/badge.svg?branch=main)](https://git.add-ideas.de/add-ideas/govoplan-core/actions?workflow=module-matrix.yml&actor=0&status=0)
[![Dependency Audit](https://git.add-ideas.de/add-ideas/govoplan-core/actions/workflows/dependency-audit.yml/badge.svg?branch=main)](https://git.add-ideas.de/add-ideas/govoplan-core/actions/workflows/dependency-audit.yml) [![Dependency Audit](https://git.add-ideas.de/add-ideas/govoplan-core/actions/workflows/dependency-audit.yml/badge.svg?branch=main)](https://git.add-ideas.de/add-ideas/govoplan-core/actions?workflow=dependency-audit.yml&actor=0&status=0)
# govoplan-core # govoplan-core

View File

@@ -489,11 +489,16 @@ does not trust the website by location alone. A catalog must pass the configured
signature, channel, freshness, and replay rules before a catalog entry can be signature, channel, freshness, and replay rules before a catalog entry can be
planned. Catalog entries may declare `license_features`; core checks those planned. Catalog entries may declare `license_features`; core checks those
against the configured offline license before adding the entry to the install against the configured offline license before adding the entry to the install
plan. plan. Catalog entries may also declare `migration_safety` as `automatic`,
`requires_review`, `forward_only`, or `destructive`; forward-only and
destructive entries require explicit operator acknowledgement in the install
plan before installer preflight allows activation.
Modules should provide: Modules should provide:
- pinned backend and WebUI package refs for official catalog entries - pinned backend and WebUI package refs for official catalog entries
- module dependency metadata for catalog target-state planning
- migration-safety metadata for catalog update planning
- compatibility metadata in the module manifest - compatibility metadata in the module manifest
- named interface contracts in the manifest and catalog entry when the module - named interface contracts in the manifest and catalog entry when the module
provides or consumes cross-module APIs provides or consumes cross-module APIs
@@ -977,8 +982,15 @@ The package install-plan API records operator intent only:
also reports catalog validity, channel, signature, trust state, and the also reports catalog validity, channel, signature, trust state, and the
configured path. configured path.
- `POST /api/v1/admin/system/modules/install-plan/catalog/{module_id}` saves - `POST /api/v1/admin/system/modules/install-plan/catalog/{module_id}` saves
a planned install row from a validated catalog entry. Catalog signature and a planned install or update row from a validated catalog entry. Installed
approved-channel policy are enforced before the row is saved. modules are planned as updates. Catalog signature and approved-channel policy
are enforced before the row is saved. The saved plan row can also carry a
data-safety acknowledgement used by preflight for forward-only or destructive
catalog entries.
- Install-plan preflight returns a structured `target_plan` summary so the
admin UI can show current version, target version, package refs,
migration-safety level, and acknowledgement state without requiring JSON
editing.
- `POST /api/v1/admin/system/modules/{module_id}/uninstall-plan` saves a - `POST /api/v1/admin/system/modules/{module_id}/uninstall-plan` saves a
planned non-destructive uninstall row for an installed module after it has planned non-destructive uninstall row for an installed module after it has
been disabled. The Python distribution name is resolved from the installed been disabled. The Python distribution name is resolved from the installed

View File

@@ -205,6 +205,12 @@ Each module entry can declare:
- WebUI package name and pinned install reference - WebUI package name and pinned install reference
- display metadata and tags - display metadata and tags
- `license_features`, the feature entitlements required to plan that install - `license_features`, the feature entitlements required to plan that install
- `dependencies` and `optional_dependencies`, the module ids expected in the
target module set
- `migration_safety`, one of `automatic`, `requires_review`, `forward_only`,
or `destructive`
- `migration_notes`, operator-facing data/migration guidance for review,
forward-only, or destructive changes
- `provides_interfaces`, named interface contracts exported by this module - `provides_interfaces`, named interface contracts exported by this module
- `requires_interfaces`, named interface contracts and version ranges required - `requires_interfaces`, named interface contracts and version ranges required
by this module by this module
@@ -264,16 +270,22 @@ source/path, source type, cache path, channel, sequence, generated/validity
timestamps, signature state, trusted key id, and cache state where available. timestamps, signature state, trusted key id, and cache state where available.
Catalog provenance changes preflight severity: Catalog provenance changes preflight severity:
- catalog-sourced installs require a configured, valid package catalog before - catalog-sourced installs and updates require a configured, valid package
activation catalog before activation
- invalid, untrusted, expired, not-yet-valid, replayed, or unapproved-channel - invalid, untrusted, expired, not-yet-valid, replayed, or unapproved-channel
catalogs block catalog-sourced installs catalogs block catalog-sourced installs and updates
- the same catalog validation failures remain warnings for manual install - the same catalog validation failures remain warnings for manual install
plans, so operators can still use offline or emergency package refs plans, so operators can still use offline or emergency package refs
- valid-catalog warnings, such as intentionally unsigned local catalogs when - valid-catalog warnings, such as intentionally unsigned local catalogs when
signature enforcement is disabled, remain warnings signature enforcement is disabled, remain warnings
- selected catalog entries with unsatisfied non-optional named interface ranges - selected catalog entries with unsatisfied non-optional named interface ranges
block activation before the installer runs block activation before the installer runs
- selected catalog entries whose target dependencies are neither installed nor
planned block activation before the installer runs
- catalog entries marked `forward_only` or `destructive` block activation until
the plan row has an explicit data-safety acknowledgement
- catalog entries marked `destructive` also require catalog migration notes or
operator notes describing the cleanup or retirement plan
### Update Paths ### Update Paths
@@ -283,6 +295,16 @@ catalog validation snapshot. The installer may install multiple packages into
the environment before activation, then validate the discovered manifests and the environment before activation, then validate the discovered manifests and
activate the resulting set together. activate the resulting set together.
Install-plan rows support explicit `install`, `update`, and `uninstall`
actions. Catalog planning writes `update` when the module is already installed.
Preflight resolves the target set from installed manifests plus the planned
catalog entries. Unplanned catalog entries are not treated as installed; when
they would satisfy a missing dependency or named interface, preflight blocks
activation with a companion-update issue so the operator can add them to the
same plan. The preflight response also includes a structured `target_plan`
summary with each planned module's action, current version, catalog target
version, package refs, migration-safety level, and acknowledgement state.
This avoids circular "upgrade A first / upgrade B first" traps: named interface This avoids circular "upgrade A first / upgrade B first" traps: named interface
requirements are solved against the target set, not against each intermediate requirements are solved against the target set, not against each intermediate
package-install moment. If the target set cannot satisfy all non-optional package-install moment. If the target set cannot satisfy all non-optional
@@ -303,6 +325,15 @@ Live data upgrades need an even stricter rule:
must publish an intermediate compatibility release rather than a circular must publish an intermediate compatibility release rather than a circular
update chain update chain
The release catalog is the first safety gate for this. Generated catalogs mark
modules with registered migrations as `requires_review` by default. Release
authors should keep that value for ordinary reversible migrations, raise it to
`forward_only` when database rollback requires restoring a snapshot, and raise
it to `destructive` when the update removes or irreversibly rewrites persisted
data. The admin install-plan UI exposes the safety level and lets operators
record an explicit acknowledgement; preflight keeps acknowledged
forward-only/destructive changes visible as warnings.
In practice, circular dependencies are avoided by designing interfaces with In practice, circular dependencies are avoided by designing interfaces with
compatibility windows and by publishing bridge releases. A bridge release keeps compatibility windows and by publishing bridge releases. A bridge release keeps
the old interface while introducing the new one, allowing dependent modules to the old interface while introducing the new one, allowing dependent modules to

View File

@@ -253,8 +253,8 @@ def _catalog_payload(
if module.webui_package: if module.webui_package:
entry["webui_package"] = module.webui_package entry["webui_package"] = module.webui_package
entry["webui_ref"] = f"{repository_base}/{module.repo}.git#{module_tag}" entry["webui_ref"] = f"{repository_base}/{module.repo}.git#{module_tag}"
manifest_interfaces = _manifest_interface_metadata(manifest) manifest_metadata = _manifest_catalog_metadata(manifest)
entry.update(manifest_interfaces) entry.update(manifest_metadata)
if module.provides_interfaces: if module.provides_interfaces:
entry["provides_interfaces"] = [dict(item) for item in module.provides_interfaces] entry["provides_interfaces"] = [dict(item) for item in module.provides_interfaces]
if module.requires_interfaces: if module.requires_interfaces:
@@ -292,10 +292,17 @@ def _discovered_catalog_manifests() -> dict[str, ModuleManifest]:
return {} return {}
def _manifest_interface_metadata(manifest: ModuleManifest | None) -> dict[str, object]: def _manifest_catalog_metadata(manifest: ModuleManifest | None) -> dict[str, object]:
if manifest is None: if manifest is None:
return {} return {}
payload: dict[str, object] = {} payload: dict[str, object] = {}
if manifest.dependencies:
payload["dependencies"] = list(manifest.dependencies)
if manifest.optional_dependencies:
payload["optional_dependencies"] = list(manifest.optional_dependencies)
if manifest.migration_spec is not None:
payload["migration_safety"] = "requires_review"
payload["migration_notes"] = "Module owns database migrations; review release notes and migration output before activation."
if manifest.provides_interfaces: if manifest.provides_interfaces:
payload["provides_interfaces"] = [ payload["provides_interfaces"] = [
{"name": item.name, "version": item.version} {"name": item.name, "version": item.version}

View File

@@ -41,6 +41,7 @@ from govoplan_core.core.versioning import compare_versions, format_version_range
IssueSeverity = Literal["blocker", "warning", "info"] IssueSeverity = Literal["blocker", "warning", "info"]
ChecklistStatus = Literal["done", "pending", "blocked", "warning"] ChecklistStatus = Literal["done", "pending", "blocked", "warning"]
SUPPORTED_MANIFEST_CONTRACT_VERSION = "1" SUPPORTED_MANIFEST_CONTRACT_VERSION = "1"
PACKAGE_TARGET_ACTIONS = {"install", "update"}
@dataclass(frozen=True, slots=True) @dataclass(frozen=True, slots=True)
@@ -79,6 +80,44 @@ class ModuleInstallChecklistItem:
return payload return payload
@dataclass(frozen=True, slots=True)
class ModuleInstallTargetItem:
module_id: str
action: str
source: str
current_version: str | None = None
target_version: str | None = None
python_package: str | None = None
python_ref: str | None = None
webui_package: str | None = None
webui_ref: str | None = None
migration_safety: str = "automatic"
migration_notes: str | None = None
data_safety_acknowledged: bool = False
def as_dict(self) -> dict[str, object]:
payload: dict[str, object] = {
"module_id": self.module_id,
"action": self.action,
"source": self.source,
"migration_safety": self.migration_safety,
"data_safety_acknowledged": self.data_safety_acknowledged,
}
for key in (
"current_version",
"target_version",
"python_package",
"python_ref",
"webui_package",
"webui_ref",
"migration_notes",
):
value = getattr(self, key)
if value:
payload[key] = value
return payload
@dataclass(frozen=True, slots=True) @dataclass(frozen=True, slots=True)
class ModuleInstallerPreflight: class ModuleInstallerPreflight:
allowed: bool allowed: bool
@@ -89,6 +128,7 @@ class ModuleInstallerPreflight:
rollback_commands: tuple[str, ...] rollback_commands: tuple[str, ...]
issues: tuple[ModuleInstallerIssue, ...] issues: tuple[ModuleInstallerIssue, ...]
checklist: tuple[ModuleInstallChecklistItem, ...] = () checklist: tuple[ModuleInstallChecklistItem, ...] = ()
target_plan: tuple[ModuleInstallTargetItem, ...] = ()
artifact_integrity: tuple[dict[str, object], ...] = () artifact_integrity: tuple[dict[str, object], ...] = ()
def as_dict(self) -> dict[str, object]: def as_dict(self) -> dict[str, object]:
@@ -101,6 +141,7 @@ class ModuleInstallerPreflight:
"rollback_commands": list(self.rollback_commands), "rollback_commands": list(self.rollback_commands),
"issues": [issue.as_dict() for issue in self.issues], "issues": [issue.as_dict() for issue in self.issues],
"checklist": [item.as_dict() for item in self.checklist], "checklist": [item.as_dict() for item in self.checklist],
"target_plan": [item.as_dict() for item in self.target_plan],
"artifact_integrity": list(self.artifact_integrity), "artifact_integrity": list(self.artifact_integrity),
} }
@@ -158,6 +199,7 @@ def module_install_preflight(
desired = {item for item in desired_enabled} desired = {item for item in desired_enabled}
planned_items = tuple(item for item in plan.items if item.status == "planned") planned_items = tuple(item for item in plan.items if item.status == "planned")
dependents = module_dependents(available) dependents = module_dependents(available)
target_plan = _module_install_target_plan(plan, available)
if not planned_items: if not planned_items:
issues.append(ModuleInstallerIssue("warning", "empty_plan", "No planned package changes are present.")) issues.append(ModuleInstallerIssue("warning", "empty_plan", "No planned package changes are present."))
@@ -179,7 +221,14 @@ def module_install_preflight(
available=available, available=available,
session=session, session=session,
)) ))
if item.action == "install": if item.action in PACKAGE_TARGET_ACTIONS:
if item.action == "update" and item.module_id not in available:
issues.append(ModuleInstallerIssue(
"blocker",
"update_module_missing",
"Package updates require the module to be installed; use install for new modules.",
item.module_id,
))
if item.module_id not in available: if item.module_id not in available:
issues.append(ModuleInstallerIssue( issues.append(ModuleInstallerIssue(
"info", "info",
@@ -243,6 +292,7 @@ def module_install_preflight(
rollback_commands=rollback_commands, rollback_commands=rollback_commands,
issues=tuple(issues), issues=tuple(issues),
checklist=checklist, checklist=checklist,
target_plan=target_plan,
artifact_integrity=artifact_integrity, artifact_integrity=artifact_integrity,
) )
@@ -795,7 +845,7 @@ def structured_install_commands(
for item in plan.items: for item in plan.items:
if item.status != "planned": if item.status != "planned":
continue continue
if item.action == "install": if item.action in PACKAGE_TARGET_ACTIONS:
if item.python_ref: if item.python_ref:
commands.append(_structured_command([sys.executable, "-m", "pip", "install", item.python_ref])) commands.append(_structured_command([sys.executable, "-m", "pip", "install", item.python_ref]))
if item.webui_package and item.webui_ref and webui_root is not None: if item.webui_package and item.webui_ref and webui_root is not None:
@@ -955,7 +1005,7 @@ def _module_verify_command(plan: ModuleInstallPlan) -> dict[str, Any] | None:
for item in plan.items: for item in plan.items:
if item.status != "planned": if item.status != "planned":
continue continue
if item.action == "install" and item.python_ref: if item.action in PACKAGE_TARGET_ACTIONS and item.python_ref:
installed.append(item.module_id) installed.append(item.module_id)
elif item.action == "uninstall" and item.python_package: elif item.action == "uninstall" and item.python_package:
absent.append(item.module_id) absent.append(item.module_id)
@@ -1089,10 +1139,10 @@ def _package_catalog_preflight_issues(
plan: ModuleInstallPlan, plan: ModuleInstallPlan,
available: Mapping[str, ModuleManifest], available: Mapping[str, ModuleManifest],
) -> tuple[ModuleInstallerIssue, ...]: ) -> tuple[ModuleInstallerIssue, ...]:
install_items = tuple(item for item in plan.items if item.status == "planned" and item.action == "install") package_items = tuple(item for item in plan.items if item.status == "planned" and item.action in PACKAGE_TARGET_ACTIONS)
if not install_items: if not package_items:
return () return ()
catalog_items = tuple(item for item in install_items if item.source == "catalog") catalog_items = tuple(item for item in package_items if item.source == "catalog")
try: try:
from govoplan_core.core.module_package_catalog import validate_module_package_catalog from govoplan_core.core.module_package_catalog import validate_module_package_catalog
@@ -1132,27 +1182,79 @@ def _package_catalog_preflight_issues(
return tuple(issues) return tuple(issues)
def _module_install_target_plan(
plan: ModuleInstallPlan,
available: Mapping[str, ModuleManifest],
) -> tuple[ModuleInstallTargetItem, ...]:
planned_items = tuple(item for item in plan.items if item.status == "planned")
if not planned_items:
return ()
catalog_modules = _catalog_modules_for_target_plan(planned_items)
targets: list[ModuleInstallTargetItem] = []
for item in planned_items:
manifest = available.get(item.module_id)
catalog_module = catalog_modules.get(item.module_id) if item.source == "catalog" else None
target_version = None if item.action == "uninstall" else _catalog_optional_string(catalog_module, "version")
migration_safety = "automatic"
migration_notes = None
if catalog_module is not None and item.action in PACKAGE_TARGET_ACTIONS:
migration_safety = _catalog_optional_string(catalog_module, "migration_safety") or "automatic"
migration_notes = _catalog_optional_string(catalog_module, "migration_notes")
targets.append(ModuleInstallTargetItem(
module_id=item.module_id,
action=item.action,
source=item.source,
current_version=manifest.version if manifest is not None else None,
target_version=target_version,
python_package=item.python_package or _catalog_optional_string(catalog_module, "python_package"),
python_ref=item.python_ref or _catalog_optional_string(catalog_module, "python_ref"),
webui_package=item.webui_package or _catalog_optional_string(catalog_module, "webui_package"),
webui_ref=item.webui_ref or _catalog_optional_string(catalog_module, "webui_ref"),
migration_safety=migration_safety,
migration_notes=migration_notes,
data_safety_acknowledged=item.data_safety_acknowledged,
))
return tuple(targets)
def _catalog_modules_for_target_plan(
planned_items: tuple[ModuleInstallPlanItem, ...],
) -> dict[str, Mapping[str, object]]:
if not any(item.source == "catalog" and item.action in PACKAGE_TARGET_ACTIONS for item in planned_items):
return {}
try:
from govoplan_core.core.module_package_catalog import validate_module_package_catalog
result = validate_module_package_catalog()
except Exception:
return {}
if result.get("valid") is not True:
return {}
return _catalog_modules_by_id(result)
def _catalog_optional_string(module: Mapping[str, object] | None, key: str) -> str | None:
if module is None:
return None
value = module.get(key)
if isinstance(value, str):
cleaned = value.strip()
return cleaned or None
return None
def _selected_catalog_interface_issues( def _selected_catalog_interface_issues(
catalog_items: tuple[ModuleInstallPlanItem, ...], catalog_items: tuple[ModuleInstallPlanItem, ...],
validation: Mapping[str, object], validation: Mapping[str, object],
available: Mapping[str, ModuleManifest], available: Mapping[str, ModuleManifest],
) -> tuple[ModuleInstallerIssue, ...]: ) -> tuple[ModuleInstallerIssue, ...]:
modules = { modules = _catalog_modules_by_id(validation)
str(item.get("module_id")): item planned_ids = {item.module_id for item in catalog_items}
for item in validation.get("modules", []) target_provider_versions = _catalog_target_provider_versions(
if isinstance(item, Mapping) and item.get("module_id") catalog_items=catalog_items,
} modules=modules,
catalog_provider_versions: dict[str, list[tuple[str, str]]] = defaultdict(list) available=available,
for module_id, module in modules.items(): )
for provided in _catalog_interface_items(module.get("provides_interfaces")):
name = provided.get("name") if isinstance(provided.get("name"), str) else None
version = provided.get("version") if isinstance(provided.get("version"), str) else None
if name and version:
catalog_provider_versions[name].append((module_id, version))
installed_provider_versions: dict[str, list[tuple[str, str]]] = defaultdict(list)
for manifest in available.values():
for provided in manifest.provides_interfaces:
installed_provider_versions[provided.name].append((manifest.id, provided.version))
issues: list[ModuleInstallerIssue] = [] issues: list[ModuleInstallerIssue] = []
for item in catalog_items: for item in catalog_items:
@@ -1165,64 +1267,344 @@ def _selected_catalog_interface_issues(
item.module_id, item.module_id,
)) ))
continue continue
for required in _catalog_interface_items(module.get("requires_interfaces")): issues.extend(_catalog_dependency_issues(
name = required.get("name") if isinstance(required.get("name"), str) else None module_id=item.module_id,
if not name: module=module,
continue planned_ids=planned_ids,
version_min = required.get("version_min") if isinstance(required.get("version_min"), str) else None available=available,
version_max_exclusive = ( modules=modules,
required.get("version_max_exclusive") ))
if isinstance(required.get("version_max_exclusive"), str) issues.extend(_catalog_data_safety_issues(item=item, module=module))
else None issues.extend(_catalog_requirement_issues(
module_id=item.module_id,
requirements=_catalog_interface_items(module.get("requires_interfaces")),
target_provider_versions=target_provider_versions,
catalog_modules=modules,
planned_ids=planned_ids,
code_prefix="catalog",
))
for manifest in available.values():
if manifest.id in planned_ids:
continue
requirements = tuple(
{
"name": requirement.name,
"version_min": requirement.version_min,
"version_max_exclusive": requirement.version_max_exclusive,
"optional": requirement.optional,
}
for requirement in manifest.requires_interfaces
)
requirement_issues = _catalog_requirement_issues(
module_id=manifest.id,
requirements=requirements,
target_provider_versions=target_provider_versions,
catalog_modules=modules,
planned_ids=planned_ids,
code_prefix="target",
)
candidate_update = modules.get(manifest.id)
if (
any(issue.severity == "blocker" for issue in requirement_issues)
and candidate_update is not None
and _catalog_requirements_satisfied(
_catalog_interface_items(candidate_update.get("requires_interfaces")),
target_provider_versions,
) )
optional = required.get("optional") is True ):
providers = [ issues.append(ModuleInstallerIssue(
*catalog_provider_versions.get(name, ()), "blocker",
*installed_provider_versions.get(name, ()), "companion_update_required",
] (
matching = [ f"Target package plan would leave installed module {manifest.id!r} "
(provider_module_id, version) f"incompatible; add the catalog update for {manifest.id!r} to the same plan."
for provider_module_id, version in providers ),
if version_satisfies_range( manifest.id,
version, ))
version_min=version_min, continue
version_max_exclusive=version_max_exclusive, issues.extend(requirement_issues)
) return tuple(issues)
]
if matching:
continue def _catalog_data_safety_issues(
version_range = format_version_range( *,
item: ModuleInstallPlanItem,
module: Mapping[str, object],
) -> tuple[ModuleInstallerIssue, ...]:
raw_safety = module.get("migration_safety")
safety = raw_safety if isinstance(raw_safety, str) and raw_safety else "automatic"
raw_notes = module.get("migration_notes")
notes = raw_notes.strip() if isinstance(raw_notes, str) and raw_notes.strip() else None
detail = f" Catalog note: {notes}" if notes else ""
if safety == "automatic":
return ()
if safety == "requires_review":
severity: IssueSeverity = "info" if item.data_safety_acknowledged else "warning"
code = "migration_review_acknowledged" if item.data_safety_acknowledged else "migration_review_required"
message = "Catalog marks this package change as requiring migration review before activation." + detail
return (ModuleInstallerIssue(severity, code, message, item.module_id),)
if safety == "forward_only":
if not item.data_safety_acknowledged:
return (ModuleInstallerIssue(
"blocker",
"forward_only_migration_acknowledgement_required",
"Catalog marks this package change as forward-only; acknowledge the data safety review before activation." + detail,
item.module_id,
),)
return (ModuleInstallerIssue(
"warning",
"forward_only_migration_acknowledged",
"Forward-only package change has been acknowledged by the operator." + detail,
item.module_id,
),)
if safety == "destructive":
issues: list[ModuleInstallerIssue] = []
if not notes and not item.notes:
issues.append(ModuleInstallerIssue(
"blocker",
"destructive_migration_plan_required",
"Catalog marks this package change as destructive; add catalog migration notes or operator notes describing the cleanup/retirement plan.",
item.module_id,
))
if not item.data_safety_acknowledged:
issues.append(ModuleInstallerIssue(
"blocker",
"destructive_migration_acknowledgement_required",
"Catalog marks this package change as destructive; acknowledge the data safety review before activation." + detail,
item.module_id,
))
if not issues:
issues.append(ModuleInstallerIssue(
"warning",
"destructive_migration_acknowledged",
"Destructive package change has been acknowledged by the operator." + detail,
item.module_id,
))
return tuple(issues)
return (ModuleInstallerIssue(
"blocker",
"catalog_migration_safety_invalid",
f"Catalog entry has unsupported migration_safety value {safety!r}.",
item.module_id,
),)
def _catalog_modules_by_id(validation: Mapping[str, object]) -> dict[str, Mapping[str, object]]:
return {
str(item.get("module_id")): item
for item in validation.get("modules", [])
if isinstance(item, Mapping) and item.get("module_id")
}
def _catalog_target_provider_versions(
*,
catalog_items: tuple[ModuleInstallPlanItem, ...],
modules: Mapping[str, Mapping[str, object]],
available: Mapping[str, ModuleManifest],
) -> dict[str, list[tuple[str, str]]]:
planned_ids = {item.module_id for item in catalog_items}
providers: dict[str, list[tuple[str, str]]] = defaultdict(list)
for manifest in available.values():
if manifest.id in planned_ids:
continue
for provided in manifest.provides_interfaces:
providers[provided.name].append((manifest.id, provided.version))
for item in catalog_items:
module = modules.get(item.module_id)
if module is None:
continue
for provided in _catalog_interface_items(module.get("provides_interfaces")):
name = provided.get("name") if isinstance(provided.get("name"), str) else None
version = provided.get("version") if isinstance(provided.get("version"), str) else None
if name and version:
providers[name].append((item.module_id, version))
return providers
def _catalog_dependency_issues(
*,
module_id: str,
module: Mapping[str, object],
planned_ids: set[str],
available: Mapping[str, ModuleManifest],
modules: Mapping[str, Mapping[str, object]],
) -> tuple[ModuleInstallerIssue, ...]:
issues: list[ModuleInstallerIssue] = []
for dependency_id in _catalog_string_list(module.get("dependencies")):
if dependency_id in planned_ids or dependency_id in available:
continue
if dependency_id in modules:
issues.append(ModuleInstallerIssue(
"blocker",
"companion_update_required",
(
f"Catalog-sourced module {module_id!r} depends on {dependency_id!r}; "
"add that catalog entry to the same package plan."
),
module_id,
))
continue
issues.append(ModuleInstallerIssue(
"blocker",
"catalog_required_dependency_missing",
(
f"Catalog-sourced module {module_id!r} depends on {dependency_id!r}, "
"but it is neither installed nor present in the package catalog."
),
module_id,
))
return tuple(issues)
def _catalog_requirement_issues(
*,
module_id: str,
requirements: tuple[Mapping[str, object], ...],
target_provider_versions: Mapping[str, list[tuple[str, str]]],
catalog_modules: Mapping[str, Mapping[str, object]],
planned_ids: set[str],
code_prefix: str,
) -> tuple[ModuleInstallerIssue, ...]:
issues: list[ModuleInstallerIssue] = []
for required in requirements:
name = required.get("name") if isinstance(required.get("name"), str) else None
if not name:
continue
version_min = required.get("version_min") if isinstance(required.get("version_min"), str) else None
version_max_exclusive = (
required.get("version_max_exclusive")
if isinstance(required.get("version_max_exclusive"), str)
else None
)
optional = required.get("optional") is True
providers = target_provider_versions.get(name, ())
matching = [
(provider_module_id, version)
for provider_module_id, version in providers
if version_satisfies_range(
version,
version_min=version_min, version_min=version_min,
version_max_exclusive=version_max_exclusive, version_max_exclusive=version_max_exclusive,
) )
if not providers: ]
if optional: if matching:
continue continue
issues.append(ModuleInstallerIssue( version_range = format_version_range(
"blocker", version_min=version_min,
"catalog_required_interface_missing", version_max_exclusive=version_max_exclusive,
( )
f"Catalog-sourced module {item.module_id!r} requires interface " suggestions = _catalog_provider_suggestions(
f"{name!r} ({version_range}), but neither the package catalog nor " name=name,
"installed modules provide it." version_min=version_min,
), version_max_exclusive=version_max_exclusive,
item.module_id, catalog_modules=catalog_modules,
)) planned_ids=planned_ids,
continue )
provider_summary = ", ".join(f"{provider_module_id}@{version}" for provider_module_id, version in providers) if suggestions:
severity: IssueSeverity = "warning" if optional else "blocker"
issues.append(ModuleInstallerIssue( issues.append(ModuleInstallerIssue(
severity, "blocker",
"catalog_interface_version_mismatch", "companion_update_required",
( (
f"Catalog-sourced module {item.module_id!r} requires interface " f"Module {module_id!r} requires interface {name!r} ({version_range}); "
f"{name!r} ({version_range}), but available providers are {provider_summary}." f"add companion catalog update(s) to the same plan: {', '.join(suggestions)}."
), ),
item.module_id, module_id,
)) ))
continue
if not providers:
if optional:
continue
issues.append(ModuleInstallerIssue(
"blocker",
f"{code_prefix}_required_interface_missing",
(
f"Module {module_id!r} requires interface {name!r} ({version_range}), "
"but the target package plan has no provider."
),
module_id,
))
continue
provider_summary = ", ".join(f"{provider_module_id}@{version}" for provider_module_id, version in providers)
severity: IssueSeverity = "warning" if optional else "blocker"
issues.append(ModuleInstallerIssue(
severity,
f"{code_prefix}_interface_version_mismatch",
(
f"Module {module_id!r} requires interface {name!r} ({version_range}), "
f"but target providers are {provider_summary}."
),
module_id,
))
return tuple(issues) return tuple(issues)
def _catalog_provider_suggestions(
*,
name: str,
version_min: str | None,
version_max_exclusive: str | None,
catalog_modules: Mapping[str, Mapping[str, object]],
planned_ids: set[str],
) -> tuple[str, ...]:
suggestions: list[str] = []
for module_id, module in catalog_modules.items():
if module_id in planned_ids:
continue
for provided in _catalog_interface_items(module.get("provides_interfaces")):
provided_name = provided.get("name") if isinstance(provided.get("name"), str) else None
version = provided.get("version") if isinstance(provided.get("version"), str) else None
if provided_name != name or not version:
continue
if version_satisfies_range(
version,
version_min=version_min,
version_max_exclusive=version_max_exclusive,
):
suggestions.append(module_id)
break
return tuple(sorted(dict.fromkeys(suggestions)))
def _catalog_requirements_satisfied(
requirements: tuple[Mapping[str, object], ...],
target_provider_versions: Mapping[str, list[tuple[str, str]]],
) -> bool:
for required in requirements:
name = required.get("name") if isinstance(required.get("name"), str) else None
if not name:
continue
version_min = required.get("version_min") if isinstance(required.get("version_min"), str) else None
version_max_exclusive = (
required.get("version_max_exclusive")
if isinstance(required.get("version_max_exclusive"), str)
else None
)
providers = target_provider_versions.get(name, ())
matching = [
version
for _provider_module_id, version in providers
if version_satisfies_range(
version,
version_min=version_min,
version_max_exclusive=version_max_exclusive,
)
]
if matching:
continue
if required.get("optional") is True and not providers:
continue
return False
return True
def _catalog_string_list(value: object) -> tuple[str, ...]:
if not isinstance(value, list):
return ()
return tuple(str(item).strip() for item in value if str(item).strip())
def _catalog_interface_items(value: object) -> tuple[Mapping[str, object], ...]: def _catalog_interface_items(value: object) -> tuple[Mapping[str, object], ...]:
if not isinstance(value, list): if not isinstance(value, list):
return () return ()
@@ -1547,7 +1929,7 @@ def _verify_artifact_integrity(
records: list[dict[str, object]] = [] records: list[dict[str, object]] = []
issues: list[ModuleInstallerIssue] = [] issues: list[ModuleInstallerIssue] = []
for item in planned_items: for item in planned_items:
if item.action != "install": if item.action not in PACKAGE_TARGET_ACTIONS:
continue continue
for kind, package_name, package_ref in ( for kind, package_name, package_ref in (
("python", item.python_package, item.python_ref), ("python", item.python_package, item.python_ref),

View File

@@ -19,7 +19,7 @@ MODULE_SETTINGS_KEY = "module_management"
INSTALL_PLAN_KEY = "install_plan" INSTALL_PLAN_KEY = "install_plan"
REQUIRED_PLATFORM_MODULES = ("access",) REQUIRED_PLATFORM_MODULES = ("access",)
PROTECTED_MODULES = (*REQUIRED_PLATFORM_MODULES, "admin") PROTECTED_MODULES = (*REQUIRED_PLATFORM_MODULES, "admin")
INSTALL_PLAN_ACTIONS = ("install", "uninstall") INSTALL_PLAN_ACTIONS = ("install", "update", "uninstall")
INSTALL_PLAN_STATUSES = ("planned", "applied", "blocked") INSTALL_PLAN_STATUSES = ("planned", "applied", "blocked")
INSTALL_PLAN_SOURCES = ("manual", "catalog") INSTALL_PLAN_SOURCES = ("manual", "catalog")
LOCAL_DEPENDENCY_REF_PREFIXES = ("file:", "path:", "workspace:", "link:") LOCAL_DEPENDENCY_REF_PREFIXES = ("file:", "path:", "workspace:", "link:")
@@ -46,6 +46,7 @@ class ModuleInstallPlanItem:
webui_package: str | None = None webui_package: str | None = None
webui_ref: str | None = None webui_ref: str | None = None
artifact_integrity: Mapping[str, object] | None = None artifact_integrity: Mapping[str, object] | None = None
data_safety_acknowledged: bool = False
destroy_data: bool = False destroy_data: bool = False
status: str = "planned" status: str = "planned"
notes: str | None = None notes: str | None = None
@@ -59,6 +60,8 @@ class ModuleInstallPlanItem:
} }
if self.destroy_data: if self.destroy_data:
payload["destroy_data"] = True payload["destroy_data"] = True
if self.data_safety_acknowledged:
payload["data_safety_acknowledged"] = True
if self.catalog: if self.catalog:
payload["catalog"] = dict(self.catalog) payload["catalog"] = dict(self.catalog)
for key in ("python_package", "python_ref", "webui_package", "webui_ref", "notes"): for key in ("python_package", "python_ref", "webui_package", "webui_ref", "notes"):
@@ -206,7 +209,7 @@ def module_install_plan_commands(
for item in items: for item in items:
if item.status not in included_statuses: if item.status not in included_statuses:
continue continue
if item.action == "install": if item.action in {"install", "update"}:
if item.python_ref: if item.python_ref:
commands.append(f"python -m pip install {shlex.quote(item.python_ref)}") commands.append(f"python -m pip install {shlex.quote(item.python_ref)}")
if item.webui_package and item.webui_ref: if item.webui_package and item.webui_ref:
@@ -281,7 +284,7 @@ def desired_modules_after_package_plan(
removed = {item.module_id for item in planned_items if item.action == "uninstall"} removed = {item.module_id for item in planned_items if item.action == "uninstall"}
desired = [module_id for module_id in desired if module_id not in removed] desired = [module_id for module_id in desired if module_id not in removed]
if activate_installed_modules: if activate_installed_modules:
desired.extend(item.module_id for item in planned_items if item.action == "install") desired.extend(item.module_id for item in planned_items if item.action in {"install", "update"})
return tuple(dict.fromkeys(desired)) return tuple(dict.fromkeys(desired))
@@ -305,6 +308,7 @@ def normalize_module_install_plan_item(
webui_package = _clean_optional_string(raw.get("webui_package")) webui_package = _clean_optional_string(raw.get("webui_package"))
webui_ref = _clean_optional_string(raw.get("webui_ref")) webui_ref = _clean_optional_string(raw.get("webui_ref"))
artifact_integrity = _clean_optional_mapping(raw.get("artifact_integrity"), field="artifact_integrity", module_id=module_id) artifact_integrity = _clean_optional_mapping(raw.get("artifact_integrity"), field="artifact_integrity", module_id=module_id)
data_safety_acknowledged = _clean_bool(raw.get("data_safety_acknowledged"))
destroy_data = _clean_bool(raw.get("destroy_data")) destroy_data = _clean_bool(raw.get("destroy_data"))
notes = _clean_optional_string(raw.get("notes")) notes = _clean_optional_string(raw.get("notes"))
@@ -314,13 +318,13 @@ def normalize_module_install_plan_item(
raise ModuleManagementError(f"Unsupported install plan status for {module_id!r}: {status!r}.") raise ModuleManagementError(f"Unsupported install plan status for {module_id!r}: {status!r}.")
if source not in INSTALL_PLAN_SOURCES: if source not in INSTALL_PLAN_SOURCES:
raise ModuleManagementError(f"Unsupported install plan source for {module_id!r}: {source!r}.") raise ModuleManagementError(f"Unsupported install plan source for {module_id!r}: {source!r}.")
if action == "install" and not python_ref: if action in {"install", "update"} and not python_ref:
raise ModuleManagementError(f"Install plan item {module_id!r} needs a Python package reference.") raise ModuleManagementError(f"Install plan item {module_id!r} needs a Python package reference.")
if action == "uninstall" and not python_package: if action == "uninstall" and not python_package:
raise ModuleManagementError(f"Uninstall plan item {module_id!r} needs a Python package name.") raise ModuleManagementError(f"Uninstall plan item {module_id!r} needs a Python package name.")
if action != "uninstall" and destroy_data: if action != "uninstall" and destroy_data:
raise ModuleManagementError(f"Install plan item {module_id!r} can only destroy data during uninstall.") raise ModuleManagementError(f"Install plan item {module_id!r} can only destroy data during uninstall.")
if action == "install" and bool(webui_package) != bool(webui_ref): if action in {"install", "update"} and bool(webui_package) != bool(webui_ref):
raise ModuleManagementError(f"Install plan item {module_id!r} needs both WebUI package and WebUI reference, or neither.") raise ModuleManagementError(f"Install plan item {module_id!r} needs both WebUI package and WebUI reference, or neither.")
if action == "uninstall" and webui_ref and not webui_package: if action == "uninstall" and webui_ref and not webui_package:
raise ModuleManagementError(f"Uninstall plan item {module_id!r} has a WebUI reference but no WebUI package.") raise ModuleManagementError(f"Uninstall plan item {module_id!r} has a WebUI reference but no WebUI package.")
@@ -339,6 +343,7 @@ def normalize_module_install_plan_item(
webui_package=webui_package, webui_package=webui_package,
webui_ref=webui_ref, webui_ref=webui_ref,
artifact_integrity=artifact_integrity, artifact_integrity=artifact_integrity,
data_safety_acknowledged=data_safety_acknowledged,
destroy_data=destroy_data, destroy_data=destroy_data,
status=status, status=status,
notes=notes, notes=notes,

View File

@@ -19,6 +19,7 @@ from cryptography.hazmat.primitives.asymmetric.ed25519 import Ed25519PrivateKey,
from govoplan_core.core.versioning import format_version_range, version_range_is_valid, version_satisfies_range from govoplan_core.core.versioning import format_version_range, version_range_is_valid, version_satisfies_range
_INTERFACE_NAME_RE = re.compile(r"^[a-z][a-z0-9_]*(?:\.[a-z][a-z0-9_]*)+$") _INTERFACE_NAME_RE = re.compile(r"^[a-z][a-z0-9_]*(?:\.[a-z][a-z0-9_]*)+$")
CATALOG_MIGRATION_SAFETY = ("automatic", "requires_review", "forward_only", "destructive")
def module_package_catalog( def module_package_catalog(
@@ -598,7 +599,7 @@ def _normalize_catalog_item(value: Any) -> dict[str, object]:
raise ValueError("Module package catalog entries must be objects.") raise ValueError("Module package catalog entries must be objects.")
module_id = _required_str(value, "module_id") module_id = _required_str(value, "module_id")
action = str(value.get("action") or "install") action = str(value.get("action") or "install")
if action not in {"install", "uninstall"}: if action not in {"install", "update", "uninstall"}:
raise ValueError(f"Unsupported catalog action for {module_id!r}: {action!r}") raise ValueError(f"Unsupported catalog action for {module_id!r}: {action!r}")
item = { item = {
"module_id": module_id, "module_id": module_id,
@@ -606,6 +607,10 @@ def _normalize_catalog_item(value: Any) -> dict[str, object]:
"description": _optional_str(value, "description"), "description": _optional_str(value, "description"),
"version": _optional_str(value, "version"), "version": _optional_str(value, "version"),
"action": action, "action": action,
"dependencies": _string_list(value.get("dependencies")),
"optional_dependencies": _string_list(value.get("optional_dependencies")),
"migration_safety": _catalog_migration_safety(value.get("migration_safety"), module_id=module_id),
"migration_notes": _optional_str(value, "migration_notes"),
"python_package": _optional_str(value, "python_package"), "python_package": _optional_str(value, "python_package"),
"python_ref": _optional_str(value, "python_ref"), "python_ref": _optional_str(value, "python_ref"),
"webui_package": _optional_str(value, "webui_package"), "webui_package": _optional_str(value, "webui_package"),
@@ -622,6 +627,16 @@ def _normalize_catalog_item(value: Any) -> dict[str, object]:
return item return item
def _catalog_migration_safety(value: Any, *, module_id: str) -> str:
if value is None:
return "automatic"
safety = str(value).strip()
if safety not in CATALOG_MIGRATION_SAFETY:
allowed = ", ".join(CATALOG_MIGRATION_SAFETY)
raise ValueError(f"Unsupported migration_safety for {module_id!r}: {safety!r}; expected one of {allowed}.")
return safety
def _required_str(value: dict[str, Any], key: str) -> str: def _required_str(value: dict[str, Any], key: str) -> str:
item = _optional_str(value, key) item = _optional_str(value, key)
if not item: if not item:

View File

@@ -6,6 +6,42 @@ from govoplan_core.security.permissions import scopes_grant
LEGACY_TO_MODULE_SCOPES: dict[str, str] = { LEGACY_TO_MODULE_SCOPES: dict[str, str] = {
"admin:users:read": "access:membership:read",
"admin:users:create": "access:membership:create",
"admin:users:update": "access:membership:update",
"admin:users:suspend": "access:membership:update",
"admin:groups:read": "access:group:read",
"admin:groups:write": "access:group:write",
"admin:groups:manage_members": "access:group:manage_members",
"admin:roles:read": "access:role:read",
"admin:roles:write": "access:role:write",
"admin:roles:assign": "access:role:assign",
"admin:api_keys:read": "access:api_key:read",
"admin:api_keys:create": "access:api_key:create",
"admin:api_keys:revoke": "access:api_key:revoke",
"admin:settings:read": "access:setting:read",
"admin:settings:write": "access:setting:write",
"admin:policies:read": "access:policy:read",
"admin:policies:write": "access:policy:write",
"system:tenants:read": "access:tenant:read",
"system:tenants:create": "access:tenant:create",
"system:tenants:update": "access:tenant:update",
"system:tenants:suspend": "access:tenant:suspend",
"system:accounts:read": "access:account:read",
"system:accounts:create": "access:account:create",
"system:accounts:update": "access:account:update",
"system:accounts:suspend": "access:account:suspend",
"system:roles:read": "access:system_role:read",
"system:roles:write": "access:system_role:write",
"system:roles:assign": "access:system_role:assign",
"system:access:read": "access:system_role:read",
"system:access:assign": "access:system_role:assign",
"system:audit:read": "access:audit:read",
"system:settings:read": "access:system_setting:read",
"system:settings:write": "access:system_setting:write",
"system:maintenance:access": "access:maintenance:access",
"system:governance:read": "access:governance:read",
"system:governance:write": "access:governance:write",
"campaign:read": "campaigns:campaign:read", "campaign:read": "campaigns:campaign:read",
"campaign:create": "campaigns:campaign:create", "campaign:create": "campaigns:campaign:create",
"campaign:update": "campaigns:campaign:update", "campaign:update": "campaigns:campaign:update",
@@ -44,14 +80,28 @@ LEGACY_TO_MODULE_SCOPES: dict[str, str] = {
"mail_servers:manage_credentials": "mail:secret:manage", "mail_servers:manage_credentials": "mail:secret:manage",
} }
MODULE_TO_LEGACY_SCOPES = {module: legacy for legacy, module in LEGACY_TO_MODULE_SCOPES.items()} MODULE_TO_LEGACY_SCOPE_ALIASES: dict[str, tuple[str, ...]] = {}
MODULE_TENANT_SCOPES = frozenset(LEGACY_TO_MODULE_SCOPES.values()) for legacy_scope, module_scope in LEGACY_TO_MODULE_SCOPES.items():
MODULE_TO_LEGACY_SCOPE_ALIASES[module_scope] = (
*MODULE_TO_LEGACY_SCOPE_ALIASES.get(module_scope, ()),
legacy_scope,
)
MODULE_TO_LEGACY_SCOPES = {
module_scope: aliases[0]
for module_scope, aliases in MODULE_TO_LEGACY_SCOPE_ALIASES.items()
}
MODULE_SYSTEM_SCOPES = frozenset(
module
for legacy, module in LEGACY_TO_MODULE_SCOPES.items()
if legacy.startswith("system:")
)
MODULE_TENANT_SCOPES = frozenset(LEGACY_TO_MODULE_SCOPES.values()) - MODULE_SYSTEM_SCOPES
def compatible_required_scopes(required: str) -> tuple[str, ...]: def compatible_required_scopes(required: str) -> tuple[str, ...]:
legacy = MODULE_TO_LEGACY_SCOPES.get(required) legacy_aliases = MODULE_TO_LEGACY_SCOPE_ALIASES.get(required)
if legacy: if legacy_aliases:
return (required, legacy) return (required, *legacy_aliases)
module = LEGACY_TO_MODULE_SCOPES.get(required) module = LEGACY_TO_MODULE_SCOPES.get(required)
if module: if module:
return (required, module) return (required, module)
@@ -60,10 +110,18 @@ def compatible_required_scopes(required: str) -> tuple[str, ...]:
def scopes_grant_compatible(scopes: Iterable[str], required: str) -> bool: def scopes_grant_compatible(scopes: Iterable[str], required: str) -> bool:
granted = list(scopes) granted = list(scopes)
if required in MODULE_SYSTEM_SCOPES:
return "*" in granted or "system:*" in granted or any(
scope != "tenant:*" and scopes_grant([scope], alias)
for scope in granted
for alias in compatible_required_scopes(required)
)
if scopes_grant(granted, required): if scopes_grant(granted, required):
return True return True
if "tenant:*" in granted or "*" in granted: if "tenant:*" in granted or "*" in granted:
if required in MODULE_TENANT_SCOPES: if required in MODULE_TENANT_SCOPES:
return True return True
if "system:*" in granted or "*" in granted:
if required in MODULE_SYSTEM_SCOPES:
return True
return any(scopes_grant(granted, alias) for alias in compatible_required_scopes(required) if alias != required) return any(scopes_grant(granted, alias) for alias in compatible_required_scopes(required) if alias != required)

View File

@@ -76,7 +76,8 @@ from govoplan_core.core.campaigns import (
CampaignRetentionProvider, CampaignRetentionProvider,
) )
from govoplan_core.core.modules import ModuleContext, ModuleManifest from govoplan_core.core.modules import ModuleContext, ModuleManifest
from govoplan_core.core.organizations import CAPABILITY_ORGANIZATION_DIRECTORY, OrganizationDirectory as CoreOrganizationDirectory, OrganizationFunctionRef as CoreOrganizationFunctionRef from govoplan_core.core.idm import CAPABILITY_IDM_DIRECTORY, OrganizationFunctionAssignmentRef
from govoplan_core.core.organizations import CAPABILITY_ORGANIZATION_DIRECTORY, OrganizationDirectory as CoreOrganizationDirectory, OrganizationFunctionRef as CoreOrganizationFunctionRef, OrganizationUnitRef as CoreOrganizationUnitRef
from govoplan_core.core.registry import PlatformRegistry from govoplan_core.core.registry import PlatformRegistry
from govoplan_core.core.runtime import clear_runtime, configure_runtime from govoplan_core.core.runtime import clear_runtime, configure_runtime
from govoplan_core.db.base import Base from govoplan_core.db.base import Base
@@ -85,6 +86,7 @@ from govoplan_core.server.config import GovoplanServerConfig
from govoplan_core.tenancy.scope import create_scope_tables from govoplan_core.tenancy.scope import create_scope_tables
from govoplan_access.backend.db.models import ( from govoplan_access.backend.db.models import (
Account, Account,
ExternalFunctionRoleAssignment,
Function, Function,
FunctionAssignment, FunctionAssignment,
FunctionRoleAssignment, FunctionRoleAssignment,
@@ -1031,6 +1033,207 @@ class AccessContractTests(unittest.TestCase):
clear_runtime() clear_runtime()
shutil.rmtree(root, ignore_errors=True) shutil.rmtree(root, ignore_errors=True)
def test_access_explanation_includes_idm_function_role_sources(self) -> None:
root = Path(tempfile.mkdtemp(prefix="govoplan-access-explanation-"))
try:
tenant_id = "tenant-access-explanation"
account_id = "account-access-explanation"
user_id = "user-access-explanation"
role_id = "role-access-explanation"
function_id = "function-access-explanation"
organization_unit_id = "unit-access-explanation"
assignment_id = "assignment-access-explanation"
identity_id = "identity-access-explanation"
assignment = OrganizationFunctionAssignmentRef(
id=assignment_id,
tenant_id=tenant_id,
identity_id=identity_id,
account_id=account_id,
function_id=function_id,
organization_unit_id=organization_unit_id,
applies_to_subunits=True,
source="direct",
)
class FakeIdmDirectory:
def get_organization_function_assignment(self, requested_assignment_id: str):
return assignment if requested_assignment_id == assignment_id else None
def organization_function_assignments_for_identity(self, requested_identity_id: str, *, tenant_id: str | None = None):
if requested_identity_id == identity_id and tenant_id in (None, assignment.tenant_id):
return (assignment,)
return ()
def organization_function_assignments_for_account(self, requested_account_id: str, *, tenant_id: str | None = None):
if requested_account_id == account_id and tenant_id in (None, assignment.tenant_id):
return (assignment,)
return ()
class FakeOrganizationDirectory(CoreOrganizationDirectory):
def get_organization_unit(self, requested_organization_unit_id: str):
if requested_organization_unit_id != organization_unit_id:
return None
return CoreOrganizationUnitRef(
id=organization_unit_id,
tenant_id=tenant_id,
slug="registry",
name="Registry Office",
)
def organization_units_for_tenant(self, requested_tenant_id: str):
if requested_tenant_id != tenant_id:
return ()
unit = self.get_organization_unit(organization_unit_id)
return (unit,) if unit is not None else ()
def get_function(self, requested_function_id: str):
if requested_function_id != function_id:
return None
return CoreOrganizationFunctionRef(
id=function_id,
tenant_id=tenant_id,
organization_unit_id=organization_unit_id,
slug="registry-clerk",
name="Registry Clerk",
)
def functions_for_organization_unit(self, requested_organization_unit_id: str, *, include_subunits: bool = False):
del include_subunits
if requested_organization_unit_id != organization_unit_id:
return ()
function = self.get_function(function_id)
return (function,) if function is not None else ()
idm_directory = FakeIdmDirectory()
organization_directory = FakeOrganizationDirectory()
with temporary_database(f"sqlite:///{root / 'test.db'}") as database:
create_scope_tables(database.engine)
Base.metadata.create_all(bind=database.engine)
with database.session() as session:
tenant = Tenant(id=tenant_id, slug="access-explanation", name="Access Explanation", settings={})
account = Account(
id=account_id,
email="explanation@example.test",
normalized_email="explanation@example.test",
display_name="Access Explanation User",
)
user = User(
id=user_id,
tenant_id=tenant_id,
account_id=account_id,
email=account.email,
display_name=account.display_name,
settings={},
mail_profile_policy={},
)
role = Role(
id=role_id,
tenant_id=tenant_id,
slug="file-reader",
name="File Reader",
permissions=["files:file:read"],
)
mapping = ExternalFunctionRoleAssignment(
tenant_id=tenant_id,
source_module="organizations",
function_id=function_id,
role_id=role_id,
)
session.add_all([tenant, account, user, role, mapping])
session.commit()
from govoplan_access.backend.explanation import SqlAccessExplanationService, build_user_access_explanation
explanation = build_user_access_explanation(
session,
user,
idm_directory=idm_directory,
organization_directory=organization_directory,
)
self.assertEqual(1, len(explanation.function_facts))
self.assertEqual("Registry Clerk", explanation.function_facts[0].function_name)
self.assertEqual((role_id,), explanation.function_facts[0].role_ids)
source = next(item for item in explanation.role_sources if item.source_type == "idm_function_role")
self.assertEqual("Registry Clerk", source.function_name)
self.assertEqual("File Reader", source.role_name)
scope = next(item for item in explanation.scopes if item.scope == "files:file:read")
self.assertEqual(["idm_function_role"], [item.source_type for item in scope.sources])
service = SqlAccessExplanationService(
idm_directory=idm_directory,
organization_directory=organization_directory,
)
provenance = service.explain_scope_provenance(
PrincipalRef(
account_id=account_id,
membership_id=user_id,
tenant_id=tenant_id,
identity_id=identity_id,
scopes=frozenset({"files:file:read"}),
),
"files:file:read",
)
self.assertTrue(any(item.kind == "function" and item.label == "Registry Clerk" for item in provenance))
self.assertTrue(any(item.kind == "role" and item.label == "File Reader" for item in provenance))
registry = PlatformRegistry()
registry.register(
ModuleManifest(
id="idm",
name="IDM",
version="test",
capability_factories={CAPABILITY_IDM_DIRECTORY: lambda context: idm_directory},
)
)
registry.register(
ModuleManifest(
id="organizations",
name="Organizations",
version="test",
capability_factories={CAPABILITY_ORGANIZATION_DIRECTORY: lambda context: organization_directory},
)
)
context = ModuleContext(registry=registry, settings=object())
registry.configure_capability_context(context)
configure_runtime(context)
from govoplan_access.backend.api.v1.routes import router as admin_router
app = FastAPI()
app.include_router(admin_router)
def principal_override() -> ApiPrincipal:
with database.session() as session:
account = session.get(Account, account_id)
user = session.get(User, user_id)
assert account is not None
assert user is not None
return ApiPrincipal(
principal=PrincipalRef(
account_id=account_id,
membership_id=user_id,
tenant_id=tenant_id,
scopes=frozenset({"admin:users:read"}),
),
account=account,
user=user,
)
app.dependency_overrides[get_api_principal] = principal_override
with TestClient(app) as client:
response = client.get(f"/admin/users/{user_id}/access-explanation")
self.assertEqual(200, response.status_code, response.text)
payload = response.json()
self.assertIn("files:file:read", payload["user"]["effective_scopes"])
self.assertEqual("idm_function_role", payload["role_sources"][0]["source_type"])
self.assertEqual("Registry Clerk", payload["function_facts"][0]["function_name"])
finally:
clear_runtime()
shutil.rmtree(root, ignore_errors=True)
def test_api_principal_dependency_uses_access_resolver_capability(self) -> None: def test_api_principal_dependency_uses_access_resolver_capability(self) -> None:
root = Path(tempfile.mkdtemp(prefix="govoplan-access-contract-")) root = Path(tempfile.mkdtemp(prefix="govoplan-access-contract-"))
try: try:

View File

@@ -33,13 +33,14 @@ from govoplan_core.db.bootstrap import bootstrap_dev_data
from govoplan_core.db.session import configure_database, set_database from govoplan_core.db.session import configure_database, set_database
from govoplan_core.core.change_sequence import decode_sequence_watermark, prune_sequence_entries from govoplan_core.core.change_sequence import decode_sequence_watermark, prune_sequence_entries
from govoplan_core.core.pagination import encode_keyset_cursor, keyset_query_fingerprint from govoplan_core.core.pagination import encode_keyset_cursor, keyset_query_fingerprint
from govoplan_core.tenancy.scope import create_scope_tables, scope_registry
from govoplan_access.backend.permissions.catalog import permission_catalog as access_permission_catalog
_database = configure_database(os.environ["DATABASE_URL"]) _database = configure_database(os.environ["DATABASE_URL"])
engine = _database.engine engine = _database.engine
SessionLocal = _database.SessionLocal SessionLocal = _database.SessionLocal
from govoplan_core.server.app import app from govoplan_core.server.app import app
from govoplan_core.security.permissions import SYSTEM_SCOPES
class ApiSmokeTests(unittest.TestCase): class ApiSmokeTests(unittest.TestCase):
@@ -56,7 +57,9 @@ class ApiSmokeTests(unittest.TestCase):
def setUp(self) -> None: def setUp(self) -> None:
set_database(_database) set_database(_database)
self.client.cookies.clear() self.client.cookies.clear()
scope_registry.metadata.drop_all(bind=engine)
Base.metadata.drop_all(bind=engine) Base.metadata.drop_all(bind=engine)
create_scope_tables(engine)
Base.metadata.create_all(bind=engine) Base.metadata.create_all(bind=engine)
shutil.rmtree(_TEST_ROOT / "files", ignore_errors=True) shutil.rmtree(_TEST_ROOT / "files", ignore_errors=True)
shutil.rmtree(_TEST_ROOT / "mock-mailbox", ignore_errors=True) shutil.rmtree(_TEST_ROOT / "mock-mailbox", ignore_errors=True)
@@ -5972,7 +5975,12 @@ class ApiSmokeTests(unittest.TestCase):
auditor = next(item for item in roles.json()["roles"] if item["slug"] == "system_auditor") auditor = next(item for item in roles.json()["roles"] if item["slug"] == "system_auditor")
self.assertTrue(owner["is_builtin"]) self.assertTrue(owner["is_builtin"])
self.assertEqual(owner["user_assignments"], 1) self.assertEqual(owner["user_assignments"], 1)
self.assertEqual(owner["effective_permission_count"], len(SYSTEM_SCOPES)) expected_system_permission_count = sum(
1
for permission in access_permission_catalog(include_legacy=False)
if permission.level == "system"
)
self.assertEqual(owner["effective_permission_count"], expected_system_permission_count)
self.assertEqual(owner["permissions"], ["system:*"]) self.assertEqual(owner["permissions"], ["system:*"])
self.assertFalse(administrator["is_builtin"]) self.assertFalse(administrator["is_builtin"])
self.assertFalse(auditor["is_builtin"]) self.assertFalse(auditor["is_builtin"])

View File

@@ -265,6 +265,18 @@ class ModuleSystemTests(unittest.TestCase):
self.assertTrue(scope_grants("tenant:*", "calendar:event:read")) self.assertTrue(scope_grants("tenant:*", "calendar:event:read"))
self.assertTrue(scopes_grant_compatible(["tenant:*"], "calendar:event:read")) self.assertTrue(scopes_grant_compatible(["tenant:*"], "calendar:event:read"))
self.assertFalse(scope_grants("tenant:*", "system:settings:read")) self.assertFalse(scope_grants("tenant:*", "system:settings:read"))
self.assertTrue(scopes_grant_compatible(["access:membership:read"], "admin:users:read"))
self.assertTrue(scopes_grant_compatible(["admin:users:read"], "access:membership:read"))
self.assertTrue(scopes_grant_compatible(["access:tenant:read"], "system:tenants:read"))
self.assertTrue(scopes_grant_compatible(["system:*"], "access:tenant:read"))
self.assertFalse(scopes_grant_compatible(["tenant:*"], "access:tenant:read"))
def test_core_webui_retired_legacy_admin_api_surface(self) -> None:
webui_src = Path(__file__).resolve().parents[1] / "webui" / "src"
self.assertFalse((webui_src / "api" / "admin.ts").exists())
for path in webui_src.rglob("*.ts*"):
with self.subTest(path=path.relative_to(webui_src)):
self.assertNotIn("api/admin", path.read_text(encoding="utf-8"))
def test_platform_modules_own_live_legacy_model_tables(self) -> None: def test_platform_modules_own_live_legacy_model_tables(self) -> None:
from govoplan_admin.backend.db.models import GovernanceTemplate from govoplan_admin.backend.db.models import GovernanceTemplate
@@ -784,6 +796,26 @@ finally:
self.assertEqual("stable", item.catalog["channel"] if item.catalog else None) self.assertEqual("stable", item.catalog["channel"] if item.catalog else None)
self.assertEqual(8, item.as_dict()["catalog"]["sequence"]) self.assertEqual(8, item.as_dict()["catalog"]["sequence"])
def test_module_install_plan_update_uses_package_install_commands(self) -> None:
item = normalize_module_install_plan_item({
"module_id": "files",
"action": "update",
"python_package": "govoplan-files",
"python_ref": "govoplan-files==0.2.0",
"webui_package": "@govoplan/files-webui",
"webui_ref": "git+ssh://git.example.invalid/add-ideas/govoplan-files.git#v0.2.0",
"data_safety_acknowledged": True,
})
plan = ModuleInstallPlan(items=(item,))
self.assertEqual("update", item.action)
self.assertTrue(item.data_safety_acknowledged)
self.assertTrue(item.as_dict()["data_safety_acknowledged"])
self.assertEqual((
"python -m pip install govoplan-files==0.2.0",
"npm pkg set 'dependencies.@govoplan/files-webui=git+ssh://git.example.invalid/add-ideas/govoplan-files.git#v0.2.0' && npm install",
), module_install_plan_commands(plan))
def test_admin_catalog_install_plan_records_catalog_provenance(self) -> None: def test_admin_catalog_install_plan_records_catalog_provenance(self) -> None:
app, _settings_obj = self._app_for_modules(("tenancy", "admin")) app, _settings_obj = self._app_for_modules(("tenancy", "admin"))
database = get_database() database = get_database()
@@ -832,6 +864,50 @@ finally:
self.assertEqual(11, item["catalog"]["sequence"]) self.assertEqual(11, item["catalog"]["sequence"])
self.assertEqual(str(catalog_path), item["catalog"]["source"]) self.assertEqual(str(catalog_path), item["catalog"]["source"])
def test_admin_catalog_install_plan_marks_installed_module_as_update(self) -> None:
app, _settings_obj = self._app_for_modules(("tenancy", "admin", "files"))
database = get_database()
create_scope_tables(database.engine)
Base.metadata.create_all(bind=database.engine)
with database.session() as session:
bootstrap_dev_data(session, user_password="test-admin")
root = Path(tempfile.mkdtemp(prefix="govoplan-admin-module-catalog-update-", dir=_TEST_ROOT))
catalog_path = root / "catalog.json"
catalog_path.write_text(json.dumps({
"channel": "stable",
"sequence": 12,
"generated_at": "2026-07-10T00:00:00Z",
"modules": [{
"module_id": "files",
"name": "Files",
"version": "0.2.0",
"action": "install",
"python_package": "govoplan-files",
"python_ref": "govoplan-files==0.2.0",
}],
}), encoding="utf-8")
with TestClient(app) as client:
login = client.post(
"/api/v1/auth/login",
json={"email": "admin@example.local", "password": "test-admin"},
)
self.assertEqual(200, login.status_code, login.text)
headers = {"Authorization": f"Bearer {login.json()['access_token']}"}
with patch.dict(os.environ, {
"GOVOPLAN_MODULE_PACKAGE_CATALOG": str(catalog_path),
"GOVOPLAN_MODULE_PACKAGE_CATALOG_REQUIRE_SIGNATURE": "false",
"GOVOPLAN_MODULE_PACKAGE_CATALOG_APPROVED_CHANNELS": "",
}):
planned = client.post("/api/v1/admin/system/modules/install-plan/catalog/files", headers=headers)
self.assertEqual(200, planned.status_code, planned.text)
item = planned.json()["items"][0]
self.assertEqual("files", item["module_id"])
self.assertEqual("update", item["action"])
self.assertEqual("catalog", item["source"])
def test_module_install_plan_rejects_local_dependency_refs(self) -> None: def test_module_install_plan_rejects_local_dependency_refs(self) -> None:
root = Path(tempfile.mkdtemp(prefix="govoplan-module-install-plan-local-", dir=_TEST_ROOT)) root = Path(tempfile.mkdtemp(prefix="govoplan-module-install-plan-local-", dir=_TEST_ROOT))
settings = _settings(root) settings = _settings(root)
@@ -1097,6 +1173,345 @@ finally:
blockers = {issue.code for issue in preflight.issues if issue.severity == "blocker"} blockers = {issue.code for issue in preflight.issues if issue.severity == "blocker"}
self.assertIn("catalog_required_interface_missing", blockers) self.assertIn("catalog_required_interface_missing", blockers)
def test_module_installer_preflight_requires_companion_catalog_provider_update(self) -> None:
available = {
"files": ModuleManifest(
id="files",
name="Files",
version="1.0.0",
provides_interfaces=(ModuleInterfaceProvider(name="files.attachments", version="1.0.0"),),
),
"campaigns": ModuleManifest(id="campaigns", name="Campaigns", version="1.0.0"),
}
plan = ModuleInstallPlan(items=(
ModuleInstallPlanItem(
module_id="campaigns",
action="update",
source="catalog",
python_package="govoplan-campaign",
python_ref="govoplan-campaign==2.0.0",
),
))
with patch(
"govoplan_core.core.module_package_catalog.validate_module_package_catalog",
return_value={
"configured": True,
"valid": True,
"warnings": [],
"modules": [
{
"module_id": "files",
"provides_interfaces": [{"name": "files.attachments", "version": "2.0.0"}],
},
{
"module_id": "campaigns",
"requires_interfaces": [{
"name": "files.attachments",
"version_min": "2.0.0",
"version_max_exclusive": "3.0.0",
}],
},
],
},
):
preflight = module_install_preflight(
plan=plan,
available=available,
current_enabled=(),
desired_enabled=(),
maintenance_mode=True,
)
self.assertFalse(preflight.allowed)
companion_issues = [issue for issue in preflight.issues if issue.code == "companion_update_required"]
self.assertTrue(companion_issues)
self.assertTrue(any("files" in issue.message for issue in companion_issues))
def test_module_installer_preflight_blocks_unacknowledged_forward_only_catalog_update(self) -> None:
available = {"files": ModuleManifest(id="files", name="Files", version="1.0.0")}
plan = ModuleInstallPlan(items=(
ModuleInstallPlanItem(
module_id="files",
action="update",
source="catalog",
python_package="govoplan-files",
python_ref="govoplan-files==2.0.0",
),
))
with patch(
"govoplan_core.core.module_package_catalog.validate_module_package_catalog",
return_value={
"configured": True,
"valid": True,
"warnings": [],
"modules": [{
"module_id": "files",
"version": "2.0.0",
"migration_safety": "forward_only",
"migration_notes": "Database rollback requires restoring the pre-update snapshot.",
}],
},
):
preflight = module_install_preflight(
plan=plan,
available=available,
current_enabled=(),
desired_enabled=(),
maintenance_mode=True,
)
self.assertFalse(preflight.allowed)
blockers = {issue.code for issue in preflight.issues if issue.severity == "blocker"}
self.assertIn("forward_only_migration_acknowledgement_required", blockers)
def test_module_installer_preflight_allows_acknowledged_forward_only_catalog_update(self) -> None:
available = {"files": ModuleManifest(id="files", name="Files", version="1.0.0")}
plan = ModuleInstallPlan(items=(
ModuleInstallPlanItem(
module_id="files",
action="update",
source="catalog",
python_package="govoplan-files",
python_ref="govoplan-files==2.0.0",
data_safety_acknowledged=True,
),
))
with patch(
"govoplan_core.core.module_package_catalog.validate_module_package_catalog",
return_value={
"configured": True,
"valid": True,
"warnings": [],
"modules": [{
"module_id": "files",
"version": "2.0.0",
"migration_safety": "forward_only",
"migration_notes": "Database rollback requires restoring the pre-update snapshot.",
}],
},
):
preflight = module_install_preflight(
plan=plan,
available=available,
current_enabled=(),
desired_enabled=(),
maintenance_mode=True,
)
self.assertTrue(preflight.allowed)
self.assertEqual(("files",), tuple(item.module_id for item in preflight.target_plan))
self.assertEqual("1.0.0", preflight.target_plan[0].current_version)
self.assertEqual("2.0.0", preflight.target_plan[0].target_version)
self.assertEqual("forward_only", preflight.target_plan[0].migration_safety)
self.assertTrue(preflight.target_plan[0].data_safety_acknowledged)
self.assertEqual("2.0.0", preflight.as_dict()["target_plan"][0]["target_version"])
warnings = {issue.code for issue in preflight.issues if issue.severity == "warning"}
self.assertIn("forward_only_migration_acknowledged", warnings)
def test_module_installer_preflight_requires_destructive_catalog_update_plan(self) -> None:
available = {"files": ModuleManifest(id="files", name="Files", version="1.0.0")}
plan = ModuleInstallPlan(items=(
ModuleInstallPlanItem(
module_id="files",
action="update",
source="catalog",
python_package="govoplan-files",
python_ref="govoplan-files==2.0.0",
data_safety_acknowledged=True,
),
))
with patch(
"govoplan_core.core.module_package_catalog.validate_module_package_catalog",
return_value={
"configured": True,
"valid": True,
"warnings": [],
"modules": [{
"module_id": "files",
"migration_safety": "destructive",
}],
},
):
preflight = module_install_preflight(
plan=plan,
available=available,
current_enabled=(),
desired_enabled=(),
maintenance_mode=True,
)
self.assertFalse(preflight.allowed)
blockers = {issue.code for issue in preflight.issues if issue.severity == "blocker"}
self.assertIn("destructive_migration_plan_required", blockers)
def test_module_installer_preflight_allows_acknowledged_destructive_catalog_update_with_plan(self) -> None:
available = {"files": ModuleManifest(id="files", name="Files", version="1.0.0")}
plan = ModuleInstallPlan(items=(
ModuleInstallPlanItem(
module_id="files",
action="update",
source="catalog",
python_package="govoplan-files",
python_ref="govoplan-files==2.0.0",
data_safety_acknowledged=True,
),
))
with patch(
"govoplan_core.core.module_package_catalog.validate_module_package_catalog",
return_value={
"configured": True,
"valid": True,
"warnings": [],
"modules": [{
"module_id": "files",
"migration_safety": "destructive",
"migration_notes": "Drops obsolete cache tables after exporting the retained documents.",
}],
},
):
preflight = module_install_preflight(
plan=plan,
available=available,
current_enabled=(),
desired_enabled=(),
maintenance_mode=True,
)
self.assertTrue(preflight.allowed)
warnings = {issue.code for issue in preflight.issues if issue.severity == "warning"}
self.assertIn("destructive_migration_acknowledged", warnings)
def test_module_installer_preflight_allows_companion_updates_in_same_target_set(self) -> None:
available = {
"files": ModuleManifest(
id="files",
name="Files",
version="1.0.0",
provides_interfaces=(ModuleInterfaceProvider(name="files.attachments", version="1.0.0"),),
),
"campaigns": ModuleManifest(id="campaigns", name="Campaigns", version="1.0.0"),
}
plan = ModuleInstallPlan(items=(
ModuleInstallPlanItem(
module_id="files",
action="update",
source="catalog",
python_package="govoplan-files",
python_ref="govoplan-files==2.0.0",
),
ModuleInstallPlanItem(
module_id="campaigns",
action="update",
source="catalog",
python_package="govoplan-campaign",
python_ref="govoplan-campaign==2.0.0",
),
))
with patch(
"govoplan_core.core.module_package_catalog.validate_module_package_catalog",
return_value={
"configured": True,
"valid": True,
"warnings": [],
"modules": [
{
"module_id": "files",
"provides_interfaces": [{"name": "files.attachments", "version": "2.0.0"}],
},
{
"module_id": "campaigns",
"requires_interfaces": [{
"name": "files.attachments",
"version_min": "2.0.0",
"version_max_exclusive": "3.0.0",
}],
},
],
},
):
preflight = module_install_preflight(
plan=plan,
available=available,
current_enabled=(),
desired_enabled=(),
maintenance_mode=True,
)
self.assertTrue(preflight.allowed)
self.assertNotIn("companion_update_required", {issue.code for issue in preflight.issues})
def test_module_installer_preflight_blocks_provider_update_that_breaks_installed_consumer(self) -> None:
available = {
"files": ModuleManifest(
id="files",
name="Files",
version="1.0.0",
provides_interfaces=(ModuleInterfaceProvider(name="files.attachments", version="1.0.0"),),
),
"campaigns": ModuleManifest(
id="campaigns",
name="Campaigns",
version="1.0.0",
requires_interfaces=(
ModuleInterfaceRequirement(
name="files.attachments",
version_min="1.0.0",
version_max_exclusive="2.0.0",
),
),
),
}
plan = ModuleInstallPlan(items=(
ModuleInstallPlanItem(
module_id="files",
action="update",
source="catalog",
python_package="govoplan-files",
python_ref="govoplan-files==2.0.0",
),
))
with patch(
"govoplan_core.core.module_package_catalog.validate_module_package_catalog",
return_value={
"configured": True,
"valid": True,
"warnings": [],
"modules": [
{
"module_id": "files",
"provides_interfaces": [{"name": "files.attachments", "version": "2.0.0"}],
},
{
"module_id": "campaigns",
"requires_interfaces": [{
"name": "files.attachments",
"version_min": "2.0.0",
"version_max_exclusive": "3.0.0",
}],
},
],
},
):
preflight = module_install_preflight(
plan=plan,
available=available,
current_enabled=(),
desired_enabled=(),
maintenance_mode=True,
)
self.assertFalse(preflight.allowed)
companion_issues = [issue for issue in preflight.issues if issue.code == "companion_update_required"]
self.assertTrue(companion_issues)
self.assertTrue(any("campaigns" in issue.message for issue in companion_issues))
def test_module_installer_reports_interface_contract_issues(self) -> None: def test_module_installer_reports_interface_contract_issues(self) -> None:
available = { available = {
"files": ModuleManifest( "files": ModuleManifest(
@@ -1736,6 +2151,10 @@ finally:
"version": "0.1.4", "version": "0.1.4",
"python_package": "govoplan-files", "python_package": "govoplan-files",
"python_ref": "govoplan-files==0.1.4", "python_ref": "govoplan-files==0.1.4",
"dependencies": ["access"],
"optional_dependencies": ["mail"],
"migration_safety": "forward_only",
"migration_notes": "Requires a tested backup restore path.",
"provides_interfaces": [{"name": "files.spaces", "version": "1.2.0"}], "provides_interfaces": [{"name": "files.spaces", "version": "1.2.0"}],
"requires_interfaces": [{"name": "access.directory", "version_min": "1.0.0", "optional": True}], "requires_interfaces": [{"name": "access.directory", "version_min": "1.0.0", "optional": True}],
"artifact_integrity": { "artifact_integrity": {
@@ -1754,6 +2173,10 @@ finally:
self.assertEqual("files", catalog[0]["module_id"]) self.assertEqual("files", catalog[0]["module_id"])
self.assertEqual("install", catalog[0]["action"]) self.assertEqual("install", catalog[0]["action"])
self.assertEqual(["access"], catalog[0]["dependencies"])
self.assertEqual(["mail"], catalog[0]["optional_dependencies"])
self.assertEqual("forward_only", catalog[0]["migration_safety"])
self.assertEqual("Requires a tested backup restore path.", catalog[0]["migration_notes"])
self.assertEqual(["official"], catalog[0]["tags"]) self.assertEqual(["official"], catalog[0]["tags"])
self.assertEqual([{"name": "files.spaces", "version": "1.2.0"}], catalog[0]["provides_interfaces"]) self.assertEqual([{"name": "files.spaces", "version": "1.2.0"}], catalog[0]["provides_interfaces"])
self.assertEqual( self.assertEqual(
@@ -1807,6 +2230,21 @@ finally:
self.assertFalse(validation["valid"]) self.assertFalse(validation["valid"])
self.assertIn("module_id", str(validation["error"])) self.assertIn("module_id", str(validation["error"]))
def test_module_package_catalog_rejects_invalid_migration_safety(self) -> None:
root = Path(tempfile.mkdtemp(prefix="govoplan-module-package-catalog-invalid-safety-", dir=_TEST_ROOT))
catalog_path = root / "catalog.json"
catalog_path.write_text(json.dumps({
"modules": [{
"module_id": "files",
"migration_safety": "maybe",
}],
}), encoding="utf-8")
validation = validate_module_package_catalog(catalog_path)
self.assertFalse(validation["valid"])
self.assertIn("migration_safety", str(validation["error"]))
def test_module_package_catalog_rejects_invalid_interface_ranges(self) -> None: def test_module_package_catalog_rejects_invalid_interface_ranges(self) -> None:
root = Path(tempfile.mkdtemp(prefix="govoplan-module-package-catalog-invalid-interface-", dir=_TEST_ROOT)) root = Path(tempfile.mkdtemp(prefix="govoplan-module-package-catalog-invalid-interface-", dir=_TEST_ROOT))
catalog_path = root / "catalog.json" catalog_path = root / "catalog.json"
@@ -1928,6 +2366,7 @@ finally:
"version_min": "0.1.0", "version_min": "0.1.0",
"version_max_exclusive": "0.2.0", "version_max_exclusive": "0.2.0",
}, modules["files"]["requires_interfaces"]) }, modules["files"]["requires_interfaces"])
self.assertEqual(["campaigns"], modules["files"]["optional_dependencies"])
self.assertIn({"name": "mail.campaign_delivery", "version": "0.1.6"}, modules["mail"]["provides_interfaces"]) self.assertIn({"name": "mail.campaign_delivery", "version": "0.1.6"}, modules["mail"]["provides_interfaces"])
self.assertIn({ self.assertIn({
"name": "files.campaign_attachments", "name": "files.campaign_attachments",
@@ -1935,6 +2374,9 @@ finally:
"version_min": "0.1.0", "version_min": "0.1.0",
"version_max_exclusive": "0.2.0", "version_max_exclusive": "0.2.0",
}, modules["campaigns"]["requires_interfaces"]) }, modules["campaigns"]["requires_interfaces"])
self.assertEqual(["files", "mail"], modules["campaigns"]["optional_dependencies"])
self.assertEqual("requires_review", modules["files"]["migration_safety"])
self.assertIn("migration", modules["files"]["migration_notes"].lower())
self.assertEqual("0.1.6", modules["files"]["version"]) self.assertEqual("0.1.6", modules["files"]["version"])
self.assertIn("@v0.1.6", modules["files"]["python_ref"]) self.assertIn("@v0.1.6", modules["files"]["python_ref"])

View File

@@ -1,787 +0,0 @@
import type { ApiSettings } from "../types";
import { apiFetch } from "./client";
export type PermissionItem = {
scope: string;
label: string;
description: string;
category: string;
level: "tenant" | "system";
system_template_id?: string | null;
system_required?: boolean;
};
export type AdminOverview = {
active_tenant_id: string;
active_tenant_name: string;
tenant_count?: number | null;
system_account_count?: number | null;
system_group_template_count?: number | null;
system_role_template_count?: number | null;
user_count: number;
active_user_count: number;
group_count: number;
role_count: number;
active_api_key_count: number;
capabilities: string[];
};
export type TenantAdminItem = {
id: string;
slug: string;
name: string;
description?: string | null;
default_locale: string;
settings: Record<string, unknown>;
allow_custom_groups?: boolean | null;
allow_custom_roles?: boolean | null;
allow_api_keys?: boolean | null;
effective_governance: Record<string, boolean>;
is_active: boolean;
counts: Record<string, number>;
created_at: string;
updated_at: string;
};
export type TenantOwnerCandidate = {
account_id: string;
email: string;
display_name?: string | null;
};
export type RoleSummary = {
id: string;
slug: string;
name: string;
description?: string | null;
permissions: string[];
effective_permission_count: number;
is_builtin: boolean;
is_assignable: boolean;
user_assignments: number;
group_assignments: number;
level: "tenant" | "system";
system_template_id?: string | null;
system_required?: boolean;
};
export type GroupSummary = {
id: string;
slug: string;
name: string;
description?: string | null;
is_active: boolean;
member_count: number;
member_ids: string[];
roles: RoleSummary[];
created_at: string;
updated_at: string;
system_template_id?: string | null;
system_required?: boolean;
};
export type UserAdminItem = {
id: string;
account_id: string;
tenant_id: string;
email: string;
display_name?: string | null;
is_active: boolean;
account_is_active: boolean;
password_reset_required: boolean;
last_login_at?: string | null;
groups: GroupSummary[];
roles: RoleSummary[];
effective_scopes: string[];
is_owner: boolean;
is_last_active_owner: boolean;
created_at: string;
updated_at: string;
};
export type SystemAccountItem = {
account_id: string;
email: string;
display_name?: string | null;
is_active: boolean;
memberships: Array<{ tenant_id: string; tenant_name: string; user_id: string; is_active: boolean; role_ids: string[]; group_ids: string[]; is_owner: boolean; is_last_active_owner: boolean }>;
roles: RoleSummary[];
last_login_at?: string | null;
};
export type SystemMembershipDraft = {
tenant_id: string;
is_active: boolean;
role_ids: string[];
group_ids: string[];
is_owner?: boolean;
is_last_active_owner?: boolean;
};
export type PrivacyRetentionPolicyFieldKey =
| "store_raw_campaign_json"
| "raw_campaign_json_retention_days"
| "generated_eml_retention_days"
| "stored_report_detail_retention_days"
| "mock_mailbox_retention_days"
| "audit_detail_retention_days"
| "audit_detail_level";
export type PrivacyRetentionLimitPermissions = Record<PrivacyRetentionPolicyFieldKey, boolean>;
export type PrivacyRetentionLimitPermissionPatch = Partial<PrivacyRetentionLimitPermissions>;
export type PrivacyRetentionPolicy = {
store_raw_campaign_json: boolean;
raw_campaign_json_retention_days?: number | null;
generated_eml_retention_days?: number | null;
stored_report_detail_retention_days?: number | null;
mock_mailbox_retention_days?: number | null;
audit_detail_retention_days?: number | null;
audit_detail_level: "full" | "redacted" | "minimal";
allow_lower_level_limits: PrivacyRetentionLimitPermissions;
};
export type PrivacyRetentionPolicyPatch = Partial<Omit<PrivacyRetentionPolicy, "allow_lower_level_limits">> & {
allow_lower_level_limits?: PrivacyRetentionLimitPermissionPatch;
};
export type PrivacyRetentionPolicyScope = "system" | "tenant" | "user" | "group" | "campaign";
export type PolicySourceStep = {
scope_type: string;
scope_id?: string | null;
path: string;
label: string;
applied_fields?: string[];
policy?: PrivacyRetentionPolicyPatch | PrivacyRetentionPolicy | null;
};
export type PolicyDecision = {
allowed: boolean;
reason?: string | null;
source_path: PolicySourceStep[];
requirements: string[];
details?: Record<string, unknown>;
};
export type PrivacyRetentionPolicyScopeResponse = {
scope_type: PrivacyRetentionPolicyScope;
scope_id?: string | null;
policy: PrivacyRetentionPolicyPatch;
effective_policy: PrivacyRetentionPolicy;
parent_policy?: PrivacyRetentionPolicy | null;
effective_policy_sources?: PolicySourceStep[];
parent_policy_sources?: PolicySourceStep[];
};
export type PrivacyRetentionPolicyExplainResponse = {
scope_type: PrivacyRetentionPolicyScope;
scope_id?: string | null;
decision: PolicyDecision;
effective_policy: PrivacyRetentionPolicy;
parent_policy?: PrivacyRetentionPolicy | null;
effective_policy_sources?: PolicySourceStep[];
parent_policy_sources?: PolicySourceStep[];
blocked_fields: PrivacyRetentionPolicyFieldKey[];
};
export type SystemSettingsItem = {
default_locale: string;
allow_tenant_custom_groups: boolean;
allow_tenant_custom_roles: boolean;
allow_tenant_api_keys: boolean;
privacy_retention_policy: PrivacyRetentionPolicy;
maintenance_mode?: { enabled: boolean; message?: string | null };
available_languages?: LanguagePackage[];
enabled_language_codes?: string[];
settings: Record<string, unknown>;
};
export type LanguagePackage = {
code: string;
label: string;
native_label?: string | null;
};
export type TenantSettingsItem = {
id: string;
slug: string;
name: string;
default_locale: string;
available_languages: LanguagePackage[];
system_enabled_language_codes: string[];
enabled_language_codes: string[];
settings: Record<string, unknown>;
};
export type RetentionRunResponse = {
result: {
dry_run: boolean;
policy: PrivacyRetentionPolicy;
cutoffs: Record<string, string | null>;
effective_policy_scope?: string;
counts: Record<string, Record<string, number>>;
};
};
export type ConfigurationSafetyField = {
key: string;
label: string;
owner_module: string;
scope: "system" | "tenant" | "user" | "group" | "campaign";
storage: string;
ui_managed: boolean;
risk: "low" | "medium" | "high" | "destructive";
secret_handling: "none" | "reference_only" | "env_only";
required_scopes: string[];
dry_run_required: boolean;
validation_required: boolean;
policy_explanation_required: boolean;
audit_event?: string | null;
maintenance_required: boolean;
two_person_approval_required: boolean;
rollback_history_required: boolean;
notes?: string | null;
};
export type ConfigurationChangeSafetyPlan = {
key: string;
allowed: boolean;
field?: ConfigurationSafetyField | null;
risk?: ConfigurationSafetyField["risk"] | null;
missing_scopes: string[];
dry_run_required: boolean;
dry_run_satisfied: boolean;
approval_required: boolean;
approval_satisfied: boolean;
maintenance_required: boolean;
maintenance_satisfied: boolean;
rollback_history_required: boolean;
secret_handling: ConfigurationSafetyField["secret_handling"];
audit_event?: string | null;
policy_explanation?: string | null;
blockers: string[];
warnings: string[];
};
export type ConfigurationChangeRequest = {
id: string;
key: string;
label?: string;
target?: Record<string, unknown>;
dry_run: boolean;
requested_by: string;
requested_at: string;
updated_at: string;
status: "pending_approval" | "approved" | "applied" | "rejected" | string;
approvals: Array<Record<string, unknown>>;
plan: ConfigurationChangeSafetyPlan;
value_preview?: unknown;
};
export type ConfigurationChangeRecord = {
id: string;
version: number;
key: string;
target?: Record<string, unknown>;
actor_user_id: string;
approval_request_id?: string | null;
approval_user_ids: string[];
before?: unknown;
after?: unknown;
rollback_value?: unknown;
status: string;
created_at: string;
};
export type ConfigurationPackageDiagnostic = {
severity: "blocker" | "warning" | "info";
code: string;
message: string;
module_id?: string | null;
object_ref?: string | null;
resolution?: string | null;
};
export type ConfigurationPackageRequiredData = {
key: string;
label: string;
data_type: string;
required: boolean;
secret: boolean;
description?: string | null;
};
export type ConfigurationPackagePlanItem = {
action: "create" | "update" | "bind" | "skip" | "blocked" | "noop";
module_id: string;
fragment_type: string;
fragment_id?: string | null;
summary?: string | null;
};
export type ConfigurationPackageFragment = {
module_id: string;
fragment_type: string;
fragment_id?: string | null;
payload: Record<string, unknown>;
};
export type ConfigurationPackageRunPayload = {
package: Record<string, unknown>;
tenant_id?: string | null;
supplied_data?: Record<string, unknown>;
change_request_id?: string | null;
};
export type GovernanceAssignment = {
tenant_id: string;
mode: "available" | "required";
};
export type GovernanceTemplateItem = {
id: string;
kind: "group" | "role";
slug: string;
name: string;
description?: string | null;
permissions: string[];
effective_permission_count: number;
is_active: boolean;
assignments: GovernanceAssignment[];
created_at: string;
updated_at: string;
};
export type ApiKeyAdminItem = {
id: string;
user_id: string;
user_email: string;
name: string;
prefix: string;
scopes: string[];
expires_at?: string | null;
last_used_at?: string | null;
revoked_at?: string | null;
created_at: string;
};
export type AuditAdminItem = {
id: string;
scope: "tenant" | "system";
tenant_id?: string | null;
actor_email?: string | null;
action: string;
object_type?: string | null;
object_id?: string | null;
details: Record<string, unknown>;
created_at: string;
};
export function fetchAdminOverview(settings: ApiSettings): Promise<AdminOverview> {
return apiFetch(settings, "/api/v1/admin/overview");
}
export async function fetchPermissionCatalog(settings: ApiSettings): Promise<PermissionItem[]> {
const response = await apiFetch<{ permissions: PermissionItem[] }>(settings, "/api/v1/admin/permissions");
return response.permissions;
}
export async function fetchTenants(settings: ApiSettings): Promise<TenantAdminItem[]> {
const response = await apiFetch<{ tenants: TenantAdminItem[] }>(settings, "/api/v1/admin/tenants");
return response.tenants;
}
export async function fetchTenantOwnerCandidates(settings: ApiSettings): Promise<TenantOwnerCandidate[]> {
const response = await apiFetch<{ accounts: TenantOwnerCandidate[] }>(settings, "/api/v1/admin/tenants/owner-candidates");
return response.accounts;
}
export function createTenant(settings: ApiSettings, payload: {
slug: string;
name: string;
owner_account_id?: string | null;
description?: string | null;
default_locale?: string;
settings?: Record<string, unknown>;
allow_custom_groups?: boolean | null;
allow_custom_roles?: boolean | null;
allow_api_keys?: boolean | null;
}): Promise<TenantAdminItem> {
return apiFetch(settings, "/api/v1/admin/tenants", { method: "POST", body: JSON.stringify(payload) });
}
export function updateTenant(settings: ApiSettings, tenantId: string, payload: Partial<{
name: string;
description: string | null;
default_locale: string;
settings: Record<string, unknown>;
allow_custom_groups?: boolean | null;
allow_custom_roles?: boolean | null;
allow_api_keys?: boolean | null;
effective_governance: Record<string, boolean>;
is_active: boolean;
}>): Promise<TenantAdminItem> {
return apiFetch(settings, `/api/v1/admin/tenants/${tenantId}`, { method: "PATCH", body: JSON.stringify(payload) });
}
export function fetchTenantSettings(settings: ApiSettings): Promise<TenantSettingsItem> {
return apiFetch(settings, "/api/v1/admin/tenant/settings");
}
export function updateTenantSettings(settings: ApiSettings, payload: { default_locale: string; enabled_language_codes?: string[] | null }): Promise<TenantSettingsItem> {
return apiFetch(settings, "/api/v1/admin/tenant/settings", { method: "PATCH", body: JSON.stringify(payload) });
}
export async function fetchUsers(settings: ApiSettings): Promise<UserAdminItem[]> {
const response = await apiFetch<{ users: UserAdminItem[] }>(settings, "/api/v1/admin/users");
return response.users;
}
export function createUser(settings: ApiSettings, payload: {
email: string;
display_name?: string | null;
password?: string | null;
password_reset_required?: boolean;
is_active?: boolean;
group_ids?: string[];
role_ids?: string[];
}): Promise<{ user: UserAdminItem; account_created: boolean; temporary_password?: string | null }> {
return apiFetch(settings, "/api/v1/admin/users", { method: "POST", body: JSON.stringify(payload) });
}
export function updateUser(settings: ApiSettings, userId: string, payload: Partial<{
display_name: string | null;
is_active: boolean;
group_ids: string[];
role_ids: string[];
}>): Promise<UserAdminItem> {
return apiFetch(settings, `/api/v1/admin/users/${userId}`, { method: "PATCH", body: JSON.stringify(payload) });
}
export async function fetchGroups(settings: ApiSettings): Promise<GroupSummary[]> {
const response = await apiFetch<{ groups: GroupSummary[] }>(settings, "/api/v1/admin/groups");
return response.groups;
}
export function createGroup(settings: ApiSettings, payload: {
slug: string;
name: string;
description?: string | null;
is_active?: boolean;
member_ids?: string[];
role_ids?: string[];
}): Promise<GroupSummary> {
return apiFetch(settings, "/api/v1/admin/groups", { method: "POST", body: JSON.stringify(payload) });
}
export function updateGroup(settings: ApiSettings, groupId: string, payload: Partial<{
name: string;
description: string | null;
is_active: boolean;
member_ids: string[];
role_ids: string[];
}>): Promise<GroupSummary> {
return apiFetch(settings, `/api/v1/admin/groups/${groupId}`, { method: "PATCH", body: JSON.stringify(payload) });
}
export async function fetchRoles(settings: ApiSettings): Promise<RoleSummary[]> {
const response = await apiFetch<{ roles: RoleSummary[] }>(settings, "/api/v1/admin/roles");
return response.roles;
}
export function createRole(settings: ApiSettings, payload: {
slug: string;
name: string;
description?: string | null;
permissions: string[];
}): Promise<RoleSummary> {
return apiFetch(settings, "/api/v1/admin/roles", { method: "POST", body: JSON.stringify(payload) });
}
export function updateRole(settings: ApiSettings, roleId: string, payload: {
name: string;
description?: string | null;
permissions: string[];
is_assignable: boolean;
}): Promise<RoleSummary> {
return apiFetch(settings, `/api/v1/admin/roles/${roleId}`, { method: "PATCH", body: JSON.stringify(payload) });
}
export function deleteRole(settings: ApiSettings, roleId: string): Promise<void> {
return apiFetch(settings, `/api/v1/admin/roles/${roleId}`, { method: "DELETE" });
}
export async function fetchSystemRoles(settings: ApiSettings): Promise<RoleSummary[]> {
const response = await apiFetch<{ roles: RoleSummary[] }>(settings, "/api/v1/admin/system/roles");
return response.roles;
}
export function createSystemRole(settings: ApiSettings, payload: {
slug: string;
name: string;
description?: string | null;
permissions: string[];
}): Promise<RoleSummary> {
return apiFetch(settings, "/api/v1/admin/system/roles", { method: "POST", body: JSON.stringify(payload) });
}
export function updateSystemRole(settings: ApiSettings, roleId: string, payload: {
name: string;
description?: string | null;
permissions: string[];
is_assignable: boolean;
}): Promise<RoleSummary> {
return apiFetch(settings, `/api/v1/admin/system/roles/${roleId}`, { method: "PATCH", body: JSON.stringify(payload) });
}
export function deleteSystemRole(settings: ApiSettings, roleId: string): Promise<void> {
return apiFetch(settings, `/api/v1/admin/system/roles/${roleId}`, { method: "DELETE" });
}
export async function fetchSystemAccounts(settings: ApiSettings): Promise<{ accounts: SystemAccountItem[]; roles: RoleSummary[] }> {
return apiFetch(settings, "/api/v1/admin/system/accounts");
}
export function updateSystemAccount(settings: ApiSettings, accountId: string, payload: {
display_name?: string | null;
is_active?: boolean;
role_ids?: string[];
}): Promise<SystemAccountItem> {
return apiFetch(settings, `/api/v1/admin/system/accounts/${accountId}`, {
method: "PATCH",
body: JSON.stringify(payload)
});
}
export function updateSystemAccountRoles(settings: ApiSettings, accountId: string, roleIds: string[]): Promise<SystemAccountItem> {
return apiFetch(settings, `/api/v1/admin/system/accounts/${accountId}/roles`, {
method: "PUT",
body: JSON.stringify({ role_ids: roleIds })
});
}
export async function fetchApiKeys(settings: ApiSettings, includeRevoked = false): Promise<ApiKeyAdminItem[]> {
const params = new URLSearchParams();
if (includeRevoked) params.set("include_revoked", "true");
const suffix = params.toString() ? `?${params.toString()}` : "";
const response = await apiFetch<{ api_keys: ApiKeyAdminItem[] }>(settings, `/api/v1/admin/api-keys${suffix}`);
return response.api_keys;
}
export function createApiKey(settings: ApiSettings, payload: {
name: string;
user_id?: string | null;
scopes: string[];
expires_at?: string | null;
}): Promise<ApiKeyAdminItem & { secret: string }> {
return apiFetch(settings, "/api/v1/admin/api-keys", { method: "POST", body: JSON.stringify(payload) });
}
export function revokeApiKey(settings: ApiSettings, keyId: string): Promise<ApiKeyAdminItem> {
return apiFetch(settings, `/api/v1/admin/api-keys/${keyId}/revoke`, { method: "POST" });
}
export type AuditQueryOptions = {
tenantId?: string | null;
allTenants?: boolean;
scope?: "tenant" | "system";
limit?: number;
offset?: number;
page?: number;
pageSize?: number;
sortBy?: "time" | "actor" | "action" | "object" | "tenant";
sortDirection?: "asc" | "desc";
filters?: Partial<Record<"time" | "actor" | "action" | "object" | "tenant", string>>;
};
export async function fetchAdminAudit(settings: ApiSettings, options: AuditQueryOptions = {}): Promise<{ items: AuditAdminItem[]; total: number; page: number; page_size: number; pages: number }> {
const params = new URLSearchParams();
if (options.tenantId) params.set("tenant_id", options.tenantId);
if (options.allTenants) params.set("all_tenants", "true");
if (options.scope) params.set("scope", options.scope);
if (options.limit) params.set("limit", String(options.limit));
if (options.offset) params.set("offset", String(options.offset));
if (options.page) params.set("page", String(options.page));
if (options.pageSize) params.set("page_size", String(options.pageSize));
if (options.sortBy) params.set("sort_by", options.sortBy);
if (options.sortDirection) params.set("sort_direction", options.sortDirection);
for (const [column, value] of Object.entries(options.filters ?? {})) {
if (value?.trim()) params.set(`filter_${column}`, value);
}
const suffix = params.toString() ? `?${params.toString()}` : "";
return apiFetch(settings, `/api/v1/admin/audit${suffix}`);
}
export function createSystemAccount(settings: ApiSettings, payload: {
email: string;
display_name?: string | null;
password?: string | null;
password_reset_required?: boolean;
is_active?: boolean;
role_ids?: string[];
memberships?: SystemMembershipDraft[];
}): Promise<{ account: SystemAccountItem; temporary_password?: string | null }> {
return apiFetch(settings, "/api/v1/admin/system/accounts", { method: "POST", body: JSON.stringify(payload) });
}
export function updateSystemMemberships(settings: ApiSettings, accountId: string, memberships: SystemMembershipDraft[]): Promise<SystemAccountItem> {
return apiFetch(settings, `/api/v1/admin/system/accounts/${accountId}/memberships`, {
method: "PUT", body: JSON.stringify({ memberships })
});
}
export function fetchSystemSettings(settings: ApiSettings): Promise<SystemSettingsItem> {
return apiFetch(settings, "/api/v1/admin/system/settings");
}
export type SystemSettingsUpdatePayload = {
default_locale: string;
allow_tenant_custom_groups: boolean;
allow_tenant_custom_roles: boolean;
allow_tenant_api_keys: boolean;
privacy_retention_policy?: PrivacyRetentionPolicy | null;
maintenance_mode?: { enabled: boolean; message?: string | null } | null;
available_languages?: LanguagePackage[] | null;
enabled_language_codes?: string[] | null;
change_request_id?: string | null;
};
export function updateSystemSettings(settings: ApiSettings, payload: SystemSettingsUpdatePayload): Promise<SystemSettingsItem> {
return apiFetch(settings, "/api/v1/admin/system/settings", { method: "PATCH", body: JSON.stringify(payload) });
}
export function getPrivacyRetentionPolicy(settings: ApiSettings, scope: PrivacyRetentionPolicyScope, scopeId?: string | null): Promise<PrivacyRetentionPolicyScopeResponse> {
const params = new URLSearchParams();
if (scopeId) params.set("scope_id", scopeId);
const suffix = params.toString() ? `?${params.toString()}` : "";
return apiFetch(settings, `/api/v1/admin/privacy-retention/policies/${encodeURIComponent(scope)}${suffix}`);
}
export function explainPrivacyRetentionPolicy(settings: ApiSettings, scope: PrivacyRetentionPolicyScope, scopeId?: string | null): Promise<PrivacyRetentionPolicyExplainResponse> {
const params = new URLSearchParams();
if (scopeId) params.set("scope_id", scopeId);
const suffix = params.toString() ? `?${params.toString()}` : "";
return apiFetch(settings, `/api/v1/admin/privacy-retention/policies/${encodeURIComponent(scope)}/explain${suffix}`);
}
export function updatePrivacyRetentionPolicy(settings: ApiSettings, scope: PrivacyRetentionPolicyScope, policy: PrivacyRetentionPolicyPatch, scopeId?: string | null, changeRequestId?: string | null): Promise<PrivacyRetentionPolicyScopeResponse> {
const params = new URLSearchParams();
if (scopeId) params.set("scope_id", scopeId);
const suffix = params.toString() ? `?${params.toString()}` : "";
return apiFetch(settings, `/api/v1/admin/privacy-retention/policies/${encodeURIComponent(scope)}${suffix}`, { method: "PUT", body: JSON.stringify({ policy, change_request_id: changeRequestId ?? null }) });
}
export function runRetentionPolicy(settings: ApiSettings, dryRun = true): Promise<RetentionRunResponse> {
return apiFetch(settings, "/api/v1/admin/system/retention/run", { method: "POST", body: JSON.stringify({ dry_run: dryRun }) });
}
export async function fetchConfigurationSafetyCatalog(settings: ApiSettings, includeEnvOnly = false): Promise<ConfigurationSafetyField[]> {
const suffix = includeEnvOnly ? "?include_env_only=true" : "";
const response = await apiFetch<{ fields: ConfigurationSafetyField[] }>(settings, `/api/v1/admin/configuration-safety${suffix}`);
return response.fields;
}
export async function planConfigurationChange(settings: ApiSettings, payload: {
key: string;
value?: unknown;
dry_run?: boolean;
maintenance_mode?: boolean;
approval_count?: number;
}): Promise<ConfigurationChangeSafetyPlan> {
const response = await apiFetch<{ plan: ConfigurationChangeSafetyPlan }>(settings, "/api/v1/admin/configuration-safety/plan", {
method: "POST",
body: JSON.stringify(payload)
});
return response.plan;
}
export function fetchConfigurationChanges(settings: ApiSettings): Promise<{ requests: ConfigurationChangeRequest[]; history: ConfigurationChangeRecord[] }> {
return apiFetch(settings, "/api/v1/admin/configuration-changes");
}
export async function createConfigurationChangeRequest(settings: ApiSettings, payload: {
key: string;
value?: unknown;
dry_run?: boolean;
target?: Record<string, unknown>;
reason?: string | null;
}): Promise<ConfigurationChangeRequest> {
const response = await apiFetch<{ request: ConfigurationChangeRequest }>(settings, "/api/v1/admin/configuration-change-requests", {
method: "POST",
body: JSON.stringify(payload)
});
return response.request;
}
export async function approveConfigurationChangeRequest(settings: ApiSettings, requestId: string, reason?: string | null): Promise<ConfigurationChangeRequest> {
const response = await apiFetch<{ request: ConfigurationChangeRequest }>(settings, `/api/v1/admin/configuration-change-requests/${encodeURIComponent(requestId)}/approve`, {
method: "POST",
body: JSON.stringify({ reason: reason ?? null })
});
return response.request;
}
export function fetchConfigurationPackageCatalogValidation(settings: ApiSettings): Promise<{ validation: Record<string, unknown> }> {
return apiFetch(settings, "/api/v1/admin/configuration-packages/catalog");
}
export function dryRunConfigurationPackage(settings: ApiSettings, payload: ConfigurationPackageRunPayload): Promise<{
diagnostics: ConfigurationPackageDiagnostic[];
required_data: ConfigurationPackageRequiredData[];
plan: ConfigurationPackagePlanItem[];
}> {
return apiFetch(settings, "/api/v1/admin/configuration-packages/dry-run", {
method: "POST",
body: JSON.stringify(payload)
});
}
export function applyConfigurationPackage(settings: ApiSettings, payload: ConfigurationPackageRunPayload): Promise<{
diagnostics: ConfigurationPackageDiagnostic[];
created_refs: Record<string, string>;
updated_refs: Record<string, string>;
}> {
return apiFetch(settings, "/api/v1/admin/configuration-packages/apply", {
method: "POST",
body: JSON.stringify(payload)
});
}
export function exportConfigurationPackage(settings: ApiSettings, payload: {
tenant_id?: string | null;
scopes?: string[];
module_ids?: string[];
object_refs?: string[];
}): Promise<{
fragments: ConfigurationPackageFragment[];
data_requirements: ConfigurationPackageRequiredData[];
diagnostics: ConfigurationPackageDiagnostic[];
}> {
return apiFetch(settings, "/api/v1/admin/configuration-packages/export", {
method: "POST",
body: JSON.stringify(payload)
});
}
export async function fetchGovernanceTemplates(settings: ApiSettings, kind?: "group" | "role"): Promise<GovernanceTemplateItem[]> {
const suffix = kind ? `?kind=${encodeURIComponent(kind)}` : "";
const response = await apiFetch<{ templates: GovernanceTemplateItem[] }>(settings, `/api/v1/admin/system/governance-templates${suffix}`);
return response.templates;
}
export function createGovernanceTemplate(settings: ApiSettings, payload: Omit<GovernanceTemplateItem, "id" | "created_at" | "updated_at" | "effective_permission_count"> & { change_request_id?: string | null }): Promise<GovernanceTemplateItem> {
return apiFetch(settings, "/api/v1/admin/system/governance-templates", { method: "POST", body: JSON.stringify(payload) });
}
export function updateGovernanceTemplate(settings: ApiSettings, templateId: string, payload: Omit<GovernanceTemplateItem, "id" | "kind" | "slug" | "created_at" | "updated_at" | "effective_permission_count"> & { change_request_id?: string | null }): Promise<GovernanceTemplateItem> {
return apiFetch(settings, `/api/v1/admin/system/governance-templates/${templateId}`, { method: "PATCH", body: JSON.stringify(payload) });
}
export function deleteGovernanceTemplate(settings: ApiSettings, templateId: string, changeRequestId?: string | null): Promise<void> {
const suffix = changeRequestId ? `?change_request_id=${encodeURIComponent(changeRequestId)}` : "";
return apiFetch(settings, `/api/v1/admin/system/governance-templates/${templateId}${suffix}`, { method: "DELETE" });
}

View File

@@ -0,0 +1,129 @@
import type { ApiSettings } from "../types";
import { apiFetch } from "./client";
export type PrivacyRetentionPolicyFieldKey =
| "store_raw_campaign_json"
| "raw_campaign_json_retention_days"
| "generated_eml_retention_days"
| "stored_report_detail_retention_days"
| "mock_mailbox_retention_days"
| "audit_detail_retention_days"
| "audit_detail_level";
export type PrivacyRetentionLimitPermissions = Record<PrivacyRetentionPolicyFieldKey, boolean>;
export type PrivacyRetentionLimitPermissionPatch = Partial<PrivacyRetentionLimitPermissions>;
export type PrivacyRetentionPolicy = {
store_raw_campaign_json: boolean;
raw_campaign_json_retention_days?: number | null;
generated_eml_retention_days?: number | null;
stored_report_detail_retention_days?: number | null;
mock_mailbox_retention_days?: number | null;
audit_detail_retention_days?: number | null;
audit_detail_level: "full" | "redacted" | "minimal";
allow_lower_level_limits: PrivacyRetentionLimitPermissions;
};
export type PrivacyRetentionPolicyPatch = Partial<Omit<PrivacyRetentionPolicy, "allow_lower_level_limits">> & {
allow_lower_level_limits?: PrivacyRetentionLimitPermissionPatch;
};
export type PrivacyRetentionPolicyScope = "system" | "tenant" | "user" | "group" | "campaign";
export type PolicySourceStep = {
scope_type: string;
scope_id?: string | null;
path: string;
label: string;
applied_fields?: string[];
policy?: PrivacyRetentionPolicyPatch | PrivacyRetentionPolicy | null;
};
export type PolicyDecision = {
allowed: boolean;
reason?: string | null;
source_path: PolicySourceStep[];
requirements: string[];
details?: Record<string, unknown>;
};
export type PrivacyRetentionPolicyScopeResponse = {
scope_type: PrivacyRetentionPolicyScope;
scope_id?: string | null;
policy: PrivacyRetentionPolicyPatch;
effective_policy: PrivacyRetentionPolicy;
parent_policy?: PrivacyRetentionPolicy | null;
effective_policy_sources?: PolicySourceStep[];
parent_policy_sources?: PolicySourceStep[];
};
export type PrivacyRetentionPolicyExplainResponse = {
scope_type: PrivacyRetentionPolicyScope;
scope_id?: string | null;
decision: PolicyDecision;
effective_policy: PrivacyRetentionPolicy;
parent_policy?: PrivacyRetentionPolicy | null;
effective_policy_sources?: PolicySourceStep[];
parent_policy_sources?: PolicySourceStep[];
blocked_fields: PrivacyRetentionPolicyFieldKey[];
};
export type RetentionRunResponse = {
result: {
dry_run: boolean;
policy: PrivacyRetentionPolicy;
cutoffs: Record<string, string | null>;
effective_policy_scope?: string;
counts: Record<string, Record<string, number>>;
};
};
function retentionPolicyQuery(scopeId?: string | null): string {
const params = new URLSearchParams();
if (scopeId) params.set("scope_id", scopeId);
const suffix = params.toString();
return suffix ? `?${suffix}` : "";
}
export function getPrivacyRetentionPolicy(
settings: ApiSettings,
scope: PrivacyRetentionPolicyScope,
scopeId?: string | null
): Promise<PrivacyRetentionPolicyScopeResponse> {
return apiFetch(
settings,
`/api/v1/admin/privacy-retention/policies/${encodeURIComponent(scope)}${retentionPolicyQuery(scopeId)}`
);
}
export function explainPrivacyRetentionPolicy(
settings: ApiSettings,
scope: PrivacyRetentionPolicyScope,
scopeId?: string | null
): Promise<PrivacyRetentionPolicyExplainResponse> {
return apiFetch(
settings,
`/api/v1/admin/privacy-retention/policies/${encodeURIComponent(scope)}/explain${retentionPolicyQuery(scopeId)}`
);
}
export function updatePrivacyRetentionPolicy(
settings: ApiSettings,
scope: PrivacyRetentionPolicyScope,
policy: PrivacyRetentionPolicyPatch,
scopeId?: string | null,
changeRequestId?: string | null
): Promise<PrivacyRetentionPolicyScopeResponse> {
return apiFetch(
settings,
`/api/v1/admin/privacy-retention/policies/${encodeURIComponent(scope)}${retentionPolicyQuery(scopeId)}`,
{ method: "PUT", body: JSON.stringify({ policy, change_request_id: changeRequestId ?? null }) }
);
}
export function runRetentionPolicy(settings: ApiSettings, dryRun = true): Promise<RetentionRunResponse> {
return apiFetch(settings, "/api/v1/admin/system/retention/run", {
method: "POST",
body: JSON.stringify({ dry_run: dryRun })
});
}

View File

@@ -10,7 +10,7 @@ import {
type PrivacyRetentionPolicyFieldKey, type PrivacyRetentionPolicyFieldKey,
type PrivacyRetentionPolicyPatch, type PrivacyRetentionPolicyPatch,
type PrivacyRetentionPolicyScope } from type PrivacyRetentionPolicyScope } from
"../../api/admin"; "../../api/privacyRetention";
import Button from "../../components/Button"; import Button from "../../components/Button";
import Card from "../../components/Card"; import Card from "../../components/Card";
import DismissibleAlert from "../../components/DismissibleAlert"; import DismissibleAlert from "../../components/DismissibleAlert";

View File

@@ -3,6 +3,7 @@ export * from "./types";
export * from "./api/client"; export * from "./api/client";
export * from "./api/auth"; export * from "./api/auth";
export * from "./api/platform"; export * from "./api/platform";
export * from "./api/privacyRetention";
export * from "./platform/modules"; export * from "./platform/modules";
export * from "./platform/ModuleContext"; export * from "./platform/ModuleContext";
export * from "./platform/moduleEvents"; export * from "./platform/moduleEvents";

View File

@@ -1,6 +1,42 @@
import type { AuthInfo } from "../types"; import type { AuthInfo } from "../types";
const legacyToModuleScopes: Record<string, string> = { const legacyToModuleScopes: Record<string, string> = {
"admin:users:read": "access:membership:read",
"admin:users:create": "access:membership:create",
"admin:users:update": "access:membership:update",
"admin:users:suspend": "access:membership:update",
"admin:groups:read": "access:group:read",
"admin:groups:write": "access:group:write",
"admin:groups:manage_members": "access:group:manage_members",
"admin:roles:read": "access:role:read",
"admin:roles:write": "access:role:write",
"admin:roles:assign": "access:role:assign",
"admin:api_keys:read": "access:api_key:read",
"admin:api_keys:create": "access:api_key:create",
"admin:api_keys:revoke": "access:api_key:revoke",
"admin:settings:read": "access:setting:read",
"admin:settings:write": "access:setting:write",
"admin:policies:read": "access:policy:read",
"admin:policies:write": "access:policy:write",
"system:tenants:read": "access:tenant:read",
"system:tenants:create": "access:tenant:create",
"system:tenants:update": "access:tenant:update",
"system:tenants:suspend": "access:tenant:suspend",
"system:accounts:read": "access:account:read",
"system:accounts:create": "access:account:create",
"system:accounts:update": "access:account:update",
"system:accounts:suspend": "access:account:suspend",
"system:roles:read": "access:system_role:read",
"system:roles:write": "access:system_role:write",
"system:roles:assign": "access:system_role:assign",
"system:access:read": "access:system_role:read",
"system:access:assign": "access:system_role:assign",
"system:audit:read": "access:audit:read",
"system:settings:read": "access:system_setting:read",
"system:settings:write": "access:system_setting:write",
"system:maintenance:access": "access:maintenance:access",
"system:governance:read": "access:governance:read",
"system:governance:write": "access:governance:write",
"campaign:read": "campaigns:campaign:read", "campaign:read": "campaigns:campaign:read",
"campaign:create": "campaigns:campaign:create", "campaign:create": "campaigns:campaign:create",
"campaign:update": "campaigns:campaign:update", "campaign:update": "campaigns:campaign:update",
@@ -40,6 +76,16 @@ const legacyToModuleScopes: Record<string, string> = {
}; };
const moduleToLegacyScopes = Object.fromEntries(Object.entries(legacyToModuleScopes).map(([legacy, module]) => [module, legacy])); const moduleToLegacyScopes = Object.fromEntries(Object.entries(legacyToModuleScopes).map(([legacy, module]) => [module, legacy]));
const moduleSystemScopes = new Set(
Object.entries(legacyToModuleScopes)
.filter(([legacy]) => legacy.startsWith("system:"))
.map(([, module]) => module)
);
const moduleTenantScopes = new Set(
Object.entries(legacyToModuleScopes)
.filter(([legacy]) => !legacy.startsWith("system:"))
.map(([, module]) => module)
);
const legacyAliases: Record<string, string[]> = { const legacyAliases: Record<string, string[]> = {
"campaign:write": ["campaign:create", "campaign:update", "campaign:copy", "campaign:archive", "campaign:delete", "campaign:share", "recipients:read", "recipients:write", "recipients:import"], "campaign:write": ["campaign:create", "campaign:update", "campaign:copy", "campaign:archive", "campaign:delete", "campaign:share", "recipients:read", "recipients:write", "recipients:import"],
@@ -63,9 +109,10 @@ function primitiveScopeGrants(granted: string, required: string): boolean {
if (legacyAliases[granted]?.some((alias) => primitiveScopeGrants(alias, required))) return true; if (legacyAliases[granted]?.some((alias) => primitiveScopeGrants(alias, required))) return true;
if (granted === "*") return true; if (granted === "*") return true;
if (granted === "tenant:*") { if (granted === "tenant:*") {
return required === "tenant:*" || !required.startsWith("system:"); if (moduleSystemScopes.has(required)) return false;
return required === "tenant:*" || moduleTenantScopes.has(required) || !required.startsWith("system:");
} }
if (granted === "system:*") return required.startsWith("system:") || required.startsWith("access:"); if (granted === "system:*") return required.startsWith("system:") || moduleSystemScopes.has(required);
if (granted.endsWith(":*") && required.startsWith(granted.slice(0, -1))) return true; if (granted.endsWith(":*") && required.startsWith(granted.slice(0, -1))) return true;
return false; return false;
} }
@@ -111,5 +158,8 @@ export const adminReadScopes = [
"system:governance:read", "system:governance:read",
"access:tenant:read", "access:tenant:read",
"access:account:read", "access:account:read",
"access:system_role:read",
"access:audit:read",
"access:system_setting:read",
"access:governance:read" "access:governance:read"
]; ];