diff --git a/README.md b/README.md index 2d2f79e..57c9aaf 100644 --- a/README.md +++ b/README.md @@ -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) -[![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) +[![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?workflow=dependency-audit.yml&actor=0&status=0) # govoplan-core diff --git a/docs/MODULE_ARCHITECTURE.md b/docs/MODULE_ARCHITECTURE.md index f2b850a..f8433b1 100644 --- a/docs/MODULE_ARCHITECTURE.md +++ b/docs/MODULE_ARCHITECTURE.md @@ -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 planned. Catalog entries may declare `license_features`; core checks those 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: - 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 - named interface contracts in the manifest and catalog entry when the module 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 configured path. - `POST /api/v1/admin/system/modules/install-plan/catalog/{module_id}` saves - a planned install row from a validated catalog entry. Catalog signature and - approved-channel policy are enforced before the row is saved. + a planned install or update row from a validated catalog entry. Installed + 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 planned non-destructive uninstall row for an installed module after it has been disabled. The Python distribution name is resolved from the installed diff --git a/docs/RELEASE_DEPENDENCIES.md b/docs/RELEASE_DEPENDENCIES.md index 7fc4913..ce62c79 100644 --- a/docs/RELEASE_DEPENDENCIES.md +++ b/docs/RELEASE_DEPENDENCIES.md @@ -205,6 +205,12 @@ Each module entry can declare: - WebUI package name and pinned install reference - display metadata and tags - `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 - `requires_interfaces`, named interface contracts and version ranges required 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. Catalog provenance changes preflight severity: -- catalog-sourced installs require a configured, valid package catalog before - activation +- catalog-sourced installs and updates require a configured, valid package + catalog before activation - 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 plans, so operators can still use offline or emergency package refs - valid-catalog warnings, such as intentionally unsigned local catalogs when signature enforcement is disabled, remain warnings - selected catalog entries with unsatisfied non-optional named interface ranges 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 @@ -283,6 +295,16 @@ catalog validation snapshot. The installer may install multiple packages into the environment before activation, then validate the discovered manifests and 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 requirements are solved against the target set, not against each intermediate 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 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 compatibility windows and by publishing bridge releases. A bridge release keeps the old interface while introducing the new one, allowing dependent modules to diff --git a/scripts/generate-release-catalog.py b/scripts/generate-release-catalog.py index 7598c9f..e8e2c0e 100644 --- a/scripts/generate-release-catalog.py +++ b/scripts/generate-release-catalog.py @@ -253,8 +253,8 @@ def _catalog_payload( if module.webui_package: entry["webui_package"] = module.webui_package entry["webui_ref"] = f"{repository_base}/{module.repo}.git#{module_tag}" - manifest_interfaces = _manifest_interface_metadata(manifest) - entry.update(manifest_interfaces) + manifest_metadata = _manifest_catalog_metadata(manifest) + entry.update(manifest_metadata) if module.provides_interfaces: entry["provides_interfaces"] = [dict(item) for item in module.provides_interfaces] if module.requires_interfaces: @@ -292,10 +292,17 @@ def _discovered_catalog_manifests() -> dict[str, ModuleManifest]: 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: return {} 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: payload["provides_interfaces"] = [ {"name": item.name, "version": item.version} diff --git a/src/govoplan_core/core/module_installer.py b/src/govoplan_core/core/module_installer.py index 6a17345..f5ed851 100644 --- a/src/govoplan_core/core/module_installer.py +++ b/src/govoplan_core/core/module_installer.py @@ -41,6 +41,7 @@ from govoplan_core.core.versioning import compare_versions, format_version_range IssueSeverity = Literal["blocker", "warning", "info"] ChecklistStatus = Literal["done", "pending", "blocked", "warning"] SUPPORTED_MANIFEST_CONTRACT_VERSION = "1" +PACKAGE_TARGET_ACTIONS = {"install", "update"} @dataclass(frozen=True, slots=True) @@ -79,6 +80,44 @@ class ModuleInstallChecklistItem: 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) class ModuleInstallerPreflight: allowed: bool @@ -89,6 +128,7 @@ class ModuleInstallerPreflight: rollback_commands: tuple[str, ...] issues: tuple[ModuleInstallerIssue, ...] checklist: tuple[ModuleInstallChecklistItem, ...] = () + target_plan: tuple[ModuleInstallTargetItem, ...] = () artifact_integrity: tuple[dict[str, object], ...] = () def as_dict(self) -> dict[str, object]: @@ -101,6 +141,7 @@ class ModuleInstallerPreflight: "rollback_commands": list(self.rollback_commands), "issues": [issue.as_dict() for issue in self.issues], "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), } @@ -158,6 +199,7 @@ def module_install_preflight( desired = {item for item in desired_enabled} planned_items = tuple(item for item in plan.items if item.status == "planned") dependents = module_dependents(available) + target_plan = _module_install_target_plan(plan, available) if not planned_items: issues.append(ModuleInstallerIssue("warning", "empty_plan", "No planned package changes are present.")) @@ -179,7 +221,14 @@ def module_install_preflight( available=available, 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: issues.append(ModuleInstallerIssue( "info", @@ -243,6 +292,7 @@ def module_install_preflight( rollback_commands=rollback_commands, issues=tuple(issues), checklist=checklist, + target_plan=target_plan, artifact_integrity=artifact_integrity, ) @@ -795,7 +845,7 @@ def structured_install_commands( for item in plan.items: if item.status != "planned": continue - if item.action == "install": + if item.action in PACKAGE_TARGET_ACTIONS: if 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: @@ -955,7 +1005,7 @@ def _module_verify_command(plan: ModuleInstallPlan) -> dict[str, Any] | None: for item in plan.items: if item.status != "planned": 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) elif item.action == "uninstall" and item.python_package: absent.append(item.module_id) @@ -1089,10 +1139,10 @@ def _package_catalog_preflight_issues( plan: ModuleInstallPlan, available: Mapping[str, ModuleManifest], ) -> tuple[ModuleInstallerIssue, ...]: - install_items = tuple(item for item in plan.items if item.status == "planned" and item.action == "install") - if not install_items: + package_items = tuple(item for item in plan.items if item.status == "planned" and item.action in PACKAGE_TARGET_ACTIONS) + if not package_items: 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: from govoplan_core.core.module_package_catalog import validate_module_package_catalog @@ -1132,27 +1182,79 @@ def _package_catalog_preflight_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( catalog_items: tuple[ModuleInstallPlanItem, ...], validation: Mapping[str, object], available: Mapping[str, ModuleManifest], ) -> tuple[ModuleInstallerIssue, ...]: - modules = { - str(item.get("module_id")): item - for item in validation.get("modules", []) - if isinstance(item, Mapping) and item.get("module_id") - } - catalog_provider_versions: dict[str, list[tuple[str, str]]] = defaultdict(list) - 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)) + modules = _catalog_modules_by_id(validation) + planned_ids = {item.module_id for item in catalog_items} + target_provider_versions = _catalog_target_provider_versions( + catalog_items=catalog_items, + modules=modules, + available=available, + ) issues: list[ModuleInstallerIssue] = [] for item in catalog_items: @@ -1165,64 +1267,344 @@ def _selected_catalog_interface_issues( item.module_id, )) continue - for required in _catalog_interface_items(module.get("requires_interfaces")): - 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 + issues.extend(_catalog_dependency_issues( + module_id=item.module_id, + module=module, + planned_ids=planned_ids, + available=available, + modules=modules, + )) + issues.extend(_catalog_data_safety_issues(item=item, module=module)) + 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 = [ - *catalog_provider_versions.get(name, ()), - *installed_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_max_exclusive=version_max_exclusive, - ) - ] - if matching: - continue - version_range = format_version_range( + ): + issues.append(ModuleInstallerIssue( + "blocker", + "companion_update_required", + ( + f"Target package plan would leave installed module {manifest.id!r} " + f"incompatible; add the catalog update for {manifest.id!r} to the same plan." + ), + manifest.id, + )) + continue + issues.extend(requirement_issues) + return tuple(issues) + + +def _catalog_data_safety_issues( + *, + 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_max_exclusive=version_max_exclusive, ) - if not providers: - if optional: - continue - issues.append(ModuleInstallerIssue( - "blocker", - "catalog_required_interface_missing", - ( - f"Catalog-sourced module {item.module_id!r} requires interface " - f"{name!r} ({version_range}), but neither the package catalog nor " - "installed modules provide it." - ), - item.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" + ] + if matching: + continue + version_range = format_version_range( + version_min=version_min, + version_max_exclusive=version_max_exclusive, + ) + suggestions = _catalog_provider_suggestions( + name=name, + version_min=version_min, + version_max_exclusive=version_max_exclusive, + catalog_modules=catalog_modules, + planned_ids=planned_ids, + ) + if suggestions: issues.append(ModuleInstallerIssue( - severity, - "catalog_interface_version_mismatch", + "blocker", + "companion_update_required", ( - f"Catalog-sourced module {item.module_id!r} requires interface " - f"{name!r} ({version_range}), but available providers are {provider_summary}." + f"Module {module_id!r} requires interface {name!r} ({version_range}); " + 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) +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], ...]: if not isinstance(value, list): return () @@ -1547,7 +1929,7 @@ def _verify_artifact_integrity( records: list[dict[str, object]] = [] issues: list[ModuleInstallerIssue] = [] for item in planned_items: - if item.action != "install": + if item.action not in PACKAGE_TARGET_ACTIONS: continue for kind, package_name, package_ref in ( ("python", item.python_package, item.python_ref), diff --git a/src/govoplan_core/core/module_management.py b/src/govoplan_core/core/module_management.py index 11ef79b..13ea7a7 100644 --- a/src/govoplan_core/core/module_management.py +++ b/src/govoplan_core/core/module_management.py @@ -19,7 +19,7 @@ MODULE_SETTINGS_KEY = "module_management" INSTALL_PLAN_KEY = "install_plan" REQUIRED_PLATFORM_MODULES = ("access",) 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_SOURCES = ("manual", "catalog") LOCAL_DEPENDENCY_REF_PREFIXES = ("file:", "path:", "workspace:", "link:") @@ -46,6 +46,7 @@ class ModuleInstallPlanItem: webui_package: str | None = None webui_ref: str | None = None artifact_integrity: Mapping[str, object] | None = None + data_safety_acknowledged: bool = False destroy_data: bool = False status: str = "planned" notes: str | None = None @@ -59,6 +60,8 @@ class ModuleInstallPlanItem: } if self.destroy_data: payload["destroy_data"] = True + if self.data_safety_acknowledged: + payload["data_safety_acknowledged"] = True if self.catalog: payload["catalog"] = dict(self.catalog) 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: if item.status not in included_statuses: continue - if item.action == "install": + if item.action in {"install", "update"}: if item.python_ref: commands.append(f"python -m pip install {shlex.quote(item.python_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"} desired = [module_id for module_id in desired if module_id not in removed] 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)) @@ -305,6 +308,7 @@ def normalize_module_install_plan_item( webui_package = _clean_optional_string(raw.get("webui_package")) 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) + data_safety_acknowledged = _clean_bool(raw.get("data_safety_acknowledged")) destroy_data = _clean_bool(raw.get("destroy_data")) 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}.") if source not in INSTALL_PLAN_SOURCES: 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.") if action == "uninstall" and not python_package: raise ModuleManagementError(f"Uninstall plan item {module_id!r} needs a Python package name.") if action != "uninstall" and destroy_data: 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.") 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.") @@ -339,6 +343,7 @@ def normalize_module_install_plan_item( webui_package=webui_package, webui_ref=webui_ref, artifact_integrity=artifact_integrity, + data_safety_acknowledged=data_safety_acknowledged, destroy_data=destroy_data, status=status, notes=notes, diff --git a/src/govoplan_core/core/module_package_catalog.py b/src/govoplan_core/core/module_package_catalog.py index 7d2b529..67a00d0 100644 --- a/src/govoplan_core/core/module_package_catalog.py +++ b/src/govoplan_core/core/module_package_catalog.py @@ -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 _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( @@ -598,7 +599,7 @@ def _normalize_catalog_item(value: Any) -> dict[str, object]: raise ValueError("Module package catalog entries must be objects.") module_id = _required_str(value, "module_id") 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}") item = { "module_id": module_id, @@ -606,6 +607,10 @@ def _normalize_catalog_item(value: Any) -> dict[str, object]: "description": _optional_str(value, "description"), "version": _optional_str(value, "version"), "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_ref": _optional_str(value, "python_ref"), "webui_package": _optional_str(value, "webui_package"), @@ -622,6 +627,16 @@ def _normalize_catalog_item(value: Any) -> dict[str, object]: 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: item = _optional_str(value, key) if not item: diff --git a/src/govoplan_core/security/module_permissions.py b/src/govoplan_core/security/module_permissions.py index 83ac1fd..8991982 100644 --- a/src/govoplan_core/security/module_permissions.py +++ b/src/govoplan_core/security/module_permissions.py @@ -6,6 +6,42 @@ from govoplan_core.security.permissions import scopes_grant 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:create": "campaigns:campaign:create", "campaign:update": "campaigns:campaign:update", @@ -44,14 +80,28 @@ LEGACY_TO_MODULE_SCOPES: dict[str, str] = { "mail_servers:manage_credentials": "mail:secret:manage", } -MODULE_TO_LEGACY_SCOPES = {module: legacy for legacy, module in LEGACY_TO_MODULE_SCOPES.items()} -MODULE_TENANT_SCOPES = frozenset(LEGACY_TO_MODULE_SCOPES.values()) +MODULE_TO_LEGACY_SCOPE_ALIASES: dict[str, tuple[str, ...]] = {} +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, ...]: - legacy = MODULE_TO_LEGACY_SCOPES.get(required) - if legacy: - return (required, legacy) + legacy_aliases = MODULE_TO_LEGACY_SCOPE_ALIASES.get(required) + if legacy_aliases: + return (required, *legacy_aliases) module = LEGACY_TO_MODULE_SCOPES.get(required) if 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: 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): return True if "tenant:*" in granted or "*" in granted: if required in MODULE_TENANT_SCOPES: 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) - diff --git a/tests/test_access_contracts.py b/tests/test_access_contracts.py index f35d861..73d8935 100644 --- a/tests/test_access_contracts.py +++ b/tests/test_access_contracts.py @@ -76,7 +76,8 @@ from govoplan_core.core.campaigns import ( CampaignRetentionProvider, ) 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.runtime import clear_runtime, configure_runtime 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_access.backend.db.models import ( Account, + ExternalFunctionRoleAssignment, Function, FunctionAssignment, FunctionRoleAssignment, @@ -1031,6 +1033,207 @@ class AccessContractTests(unittest.TestCase): clear_runtime() 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: root = Path(tempfile.mkdtemp(prefix="govoplan-access-contract-")) try: diff --git a/tests/test_api_smoke.py b/tests/test_api_smoke.py index d1aa9a8..2ecb49b 100644 --- a/tests/test_api_smoke.py +++ b/tests/test_api_smoke.py @@ -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.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.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"]) engine = _database.engine SessionLocal = _database.SessionLocal from govoplan_core.server.app import app -from govoplan_core.security.permissions import SYSTEM_SCOPES class ApiSmokeTests(unittest.TestCase): @@ -56,7 +57,9 @@ class ApiSmokeTests(unittest.TestCase): def setUp(self) -> None: set_database(_database) self.client.cookies.clear() + scope_registry.metadata.drop_all(bind=engine) Base.metadata.drop_all(bind=engine) + create_scope_tables(engine) Base.metadata.create_all(bind=engine) shutil.rmtree(_TEST_ROOT / "files", 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") self.assertTrue(owner["is_builtin"]) 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.assertFalse(administrator["is_builtin"]) self.assertFalse(auditor["is_builtin"]) diff --git a/tests/test_module_system.py b/tests/test_module_system.py index acbe5f7..ba0bb63 100644 --- a/tests/test_module_system.py +++ b/tests/test_module_system.py @@ -265,6 +265,18 @@ class ModuleSystemTests(unittest.TestCase): self.assertTrue(scope_grants("tenant:*", "calendar:event:read")) self.assertTrue(scopes_grant_compatible(["tenant:*"], "calendar:event: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: 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(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: app, _settings_obj = self._app_for_modules(("tenancy", "admin")) database = get_database() @@ -832,6 +864,50 @@ finally: self.assertEqual(11, item["catalog"]["sequence"]) 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: root = Path(tempfile.mkdtemp(prefix="govoplan-module-install-plan-local-", dir=_TEST_ROOT)) settings = _settings(root) @@ -1097,6 +1173,345 @@ finally: blockers = {issue.code for issue in preflight.issues if issue.severity == "blocker"} 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: available = { "files": ModuleManifest( @@ -1736,6 +2151,10 @@ finally: "version": "0.1.4", "python_package": "govoplan-files", "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"}], "requires_interfaces": [{"name": "access.directory", "version_min": "1.0.0", "optional": True}], "artifact_integrity": { @@ -1754,6 +2173,10 @@ finally: self.assertEqual("files", catalog[0]["module_id"]) 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([{"name": "files.spaces", "version": "1.2.0"}], catalog[0]["provides_interfaces"]) self.assertEqual( @@ -1807,6 +2230,21 @@ finally: self.assertFalse(validation["valid"]) 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: root = Path(tempfile.mkdtemp(prefix="govoplan-module-package-catalog-invalid-interface-", dir=_TEST_ROOT)) catalog_path = root / "catalog.json" @@ -1928,6 +2366,7 @@ finally: "version_min": "0.1.0", "version_max_exclusive": "0.2.0", }, 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": "files.campaign_attachments", @@ -1935,6 +2374,9 @@ finally: "version_min": "0.1.0", "version_max_exclusive": "0.2.0", }, 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.assertIn("@v0.1.6", modules["files"]["python_ref"]) diff --git a/webui/src/api/admin.ts b/webui/src/api/admin.ts deleted file mode 100644 index 1fe145e..0000000 --- a/webui/src/api/admin.ts +++ /dev/null @@ -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; - allow_custom_groups?: boolean | null; - allow_custom_roles?: boolean | null; - allow_api_keys?: boolean | null; - effective_governance: Record; - is_active: boolean; - counts: Record; - 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; -export type PrivacyRetentionLimitPermissionPatch = Partial; - -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> & { - 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; -}; - -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; -}; - -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; -}; - -export type RetentionRunResponse = { - result: { - dry_run: boolean; - policy: PrivacyRetentionPolicy; - cutoffs: Record; - effective_policy_scope?: string; - counts: Record>; - }; -}; - -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; - dry_run: boolean; - requested_by: string; - requested_at: string; - updated_at: string; - status: "pending_approval" | "approved" | "applied" | "rejected" | string; - approvals: Array>; - plan: ConfigurationChangeSafetyPlan; - value_preview?: unknown; -}; - -export type ConfigurationChangeRecord = { - id: string; - version: number; - key: string; - target?: Record; - 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; -}; - -export type ConfigurationPackageRunPayload = { - package: Record; - tenant_id?: string | null; - supplied_data?: Record; - 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; - created_at: string; -}; - - - -export function fetchAdminOverview(settings: ApiSettings): Promise { - return apiFetch(settings, "/api/v1/admin/overview"); -} - -export async function fetchPermissionCatalog(settings: ApiSettings): Promise { - const response = await apiFetch<{ permissions: PermissionItem[] }>(settings, "/api/v1/admin/permissions"); - return response.permissions; -} - -export async function fetchTenants(settings: ApiSettings): Promise { - const response = await apiFetch<{ tenants: TenantAdminItem[] }>(settings, "/api/v1/admin/tenants"); - return response.tenants; -} - -export async function fetchTenantOwnerCandidates(settings: ApiSettings): Promise { - 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; - allow_custom_groups?: boolean | null; - allow_custom_roles?: boolean | null; - allow_api_keys?: boolean | null; -}): Promise { - 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; - allow_custom_groups?: boolean | null; - allow_custom_roles?: boolean | null; - allow_api_keys?: boolean | null; - effective_governance: Record; - is_active: boolean; -}>): Promise { - return apiFetch(settings, `/api/v1/admin/tenants/${tenantId}`, { method: "PATCH", body: JSON.stringify(payload) }); -} - -export function fetchTenantSettings(settings: ApiSettings): Promise { - return apiFetch(settings, "/api/v1/admin/tenant/settings"); -} - -export function updateTenantSettings(settings: ApiSettings, payload: { default_locale: string; enabled_language_codes?: string[] | null }): Promise { - return apiFetch(settings, "/api/v1/admin/tenant/settings", { method: "PATCH", body: JSON.stringify(payload) }); -} - -export async function fetchUsers(settings: ApiSettings): Promise { - 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 { - return apiFetch(settings, `/api/v1/admin/users/${userId}`, { method: "PATCH", body: JSON.stringify(payload) }); -} - -export async function fetchGroups(settings: ApiSettings): Promise { - 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 { - 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 { - return apiFetch(settings, `/api/v1/admin/groups/${groupId}`, { method: "PATCH", body: JSON.stringify(payload) }); -} - -export async function fetchRoles(settings: ApiSettings): Promise { - 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 { - 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 { - return apiFetch(settings, `/api/v1/admin/roles/${roleId}`, { method: "PATCH", body: JSON.stringify(payload) }); -} - -export function deleteRole(settings: ApiSettings, roleId: string): Promise { - return apiFetch(settings, `/api/v1/admin/roles/${roleId}`, { method: "DELETE" }); -} - -export async function fetchSystemRoles(settings: ApiSettings): Promise { - 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 { - 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 { - return apiFetch(settings, `/api/v1/admin/system/roles/${roleId}`, { method: "PATCH", body: JSON.stringify(payload) }); -} - -export function deleteSystemRole(settings: ApiSettings, roleId: string): Promise { - 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 { - 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 { - 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 { - 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 { - return apiFetch(settings, "/api/v1/admin/api-keys", { method: "POST", body: JSON.stringify(payload) }); -} - -export function revokeApiKey(settings: ApiSettings, keyId: string): Promise { - 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>; -}; - -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 { - return apiFetch(settings, `/api/v1/admin/system/accounts/${accountId}/memberships`, { - method: "PUT", body: JSON.stringify({ memberships }) - }); -} - -export function fetchSystemSettings(settings: ApiSettings): Promise { - 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 { - 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 { - 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 { - 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 { - 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 { - 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 { - 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 { - 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; - reason?: string | null; -}): Promise { - 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 { - 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 }> { - 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; - updated_refs: Record; -}> { - 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 { - 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 & { change_request_id?: string | null }): Promise { - return apiFetch(settings, "/api/v1/admin/system/governance-templates", { method: "POST", body: JSON.stringify(payload) }); -} - -export function updateGovernanceTemplate(settings: ApiSettings, templateId: string, payload: Omit & { change_request_id?: string | null }): Promise { - 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 { - const suffix = changeRequestId ? `?change_request_id=${encodeURIComponent(changeRequestId)}` : ""; - return apiFetch(settings, `/api/v1/admin/system/governance-templates/${templateId}${suffix}`, { method: "DELETE" }); -} diff --git a/webui/src/api/privacyRetention.ts b/webui/src/api/privacyRetention.ts new file mode 100644 index 0000000..6d5fc82 --- /dev/null +++ b/webui/src/api/privacyRetention.ts @@ -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; +export type PrivacyRetentionLimitPermissionPatch = Partial; + +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> & { + 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; +}; + +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; + effective_policy_scope?: string; + counts: Record>; + }; +}; + +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 { + return apiFetch( + settings, + `/api/v1/admin/privacy-retention/policies/${encodeURIComponent(scope)}${retentionPolicyQuery(scopeId)}` + ); +} + +export function explainPrivacyRetentionPolicy( + settings: ApiSettings, + scope: PrivacyRetentionPolicyScope, + scopeId?: string | null +): Promise { + 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 { + 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 { + return apiFetch(settings, "/api/v1/admin/system/retention/run", { + method: "POST", + body: JSON.stringify({ dry_run: dryRun }) + }); +} diff --git a/webui/src/features/privacy/RetentionPolicyManagement.tsx b/webui/src/features/privacy/RetentionPolicyManagement.tsx index 07eaa7c..0277c9c 100644 --- a/webui/src/features/privacy/RetentionPolicyManagement.tsx +++ b/webui/src/features/privacy/RetentionPolicyManagement.tsx @@ -10,7 +10,7 @@ import { type PrivacyRetentionPolicyFieldKey, type PrivacyRetentionPolicyPatch, type PrivacyRetentionPolicyScope } from -"../../api/admin"; +"../../api/privacyRetention"; import Button from "../../components/Button"; import Card from "../../components/Card"; import DismissibleAlert from "../../components/DismissibleAlert"; diff --git a/webui/src/index.ts b/webui/src/index.ts index 03588f5..fdaa121 100644 --- a/webui/src/index.ts +++ b/webui/src/index.ts @@ -3,6 +3,7 @@ export * from "./types"; export * from "./api/client"; export * from "./api/auth"; export * from "./api/platform"; +export * from "./api/privacyRetention"; export * from "./platform/modules"; export * from "./platform/ModuleContext"; export * from "./platform/moduleEvents"; diff --git a/webui/src/utils/permissions.ts b/webui/src/utils/permissions.ts index 1ef719e..19094a3 100644 --- a/webui/src/utils/permissions.ts +++ b/webui/src/utils/permissions.ts @@ -1,6 +1,42 @@ import type { AuthInfo } from "../types"; const legacyToModuleScopes: Record = { + "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:create": "campaigns:campaign:create", "campaign:update": "campaigns:campaign:update", @@ -40,6 +76,16 @@ const legacyToModuleScopes: Record = { }; 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 = { "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 (granted === "*") return true; 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; return false; } @@ -111,5 +158,8 @@ export const adminReadScopes = [ "system:governance:read", "access:tenant:read", "access:account:read", + "access:system_role:read", + "access:audit:read", + "access:system_setting:read", "access:governance:read" ];