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

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

View File

@@ -1,5 +1,5 @@
[![Module Matrix](https://git.add-ideas.de/add-ideas/govoplan-core/actions/workflows/module-matrix.yml/badge.svg?branch=main)](https://git.add-ideas.de/add-ideas/govoplan-core/actions/workflows/module-matrix.yml)
[![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

View File

@@ -489,11 +489,16 @@ does not trust the website by location alone. A catalog must pass the configured
signature, channel, freshness, and replay rules before a catalog entry can be
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

View File

@@ -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

View File

@@ -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}

View File

@@ -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,7 +1267,208 @@ def _selected_catalog_interface_issues(
item.module_id,
))
continue
for required in _catalog_interface_items(module.get("requires_interfaces")):
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,
)
):
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
@@ -1176,10 +1479,7 @@ def _selected_catalog_interface_issues(
else None
)
optional = required.get("optional") is True
providers = [
*catalog_provider_versions.get(name, ()),
*installed_provider_versions.get(name, ()),
]
providers = target_provider_versions.get(name, ())
matching = [
(provider_module_id, version)
for provider_module_id, version in providers
@@ -1195,34 +1495,116 @@ def _selected_catalog_interface_issues(
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(
"blocker",
"companion_update_required",
(
f"Module {module_id!r} requires interface {name!r} ({version_range}); "
f"add companion catalog update(s) to the same plan: {', '.join(suggestions)}."
),
module_id,
))
continue
if not providers:
if optional:
continue
issues.append(ModuleInstallerIssue(
"blocker",
"catalog_required_interface_missing",
f"{code_prefix}_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."
f"Module {module_id!r} requires interface {name!r} ({version_range}), "
"but the target package plan has no provider."
),
item.module_id,
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,
"catalog_interface_version_mismatch",
f"{code_prefix}_interface_version_mismatch",
(
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"but target providers are {provider_summary}."
),
item.module_id,
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),

View File

@@ -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,

View File

@@ -19,6 +19,7 @@ from cryptography.hazmat.primitives.asymmetric.ed25519 import Ed25519PrivateKey,
from govoplan_core.core.versioning import format_version_range, version_range_is_valid, version_satisfies_range
_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:

View File

@@ -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)

View File

@@ -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:

View File

@@ -33,13 +33,14 @@ from govoplan_core.db.bootstrap import bootstrap_dev_data
from govoplan_core.db.session import configure_database, set_database
from govoplan_core.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"])

View File

@@ -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"])

View File

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

View File

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

View File

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

View File

@@ -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";

View File

@@ -1,6 +1,42 @@
import type { AuthInfo } from "../types";
const legacyToModuleScopes: Record<string, string> = {
"admin:users:read": "access:membership:read",
"admin:users:create": "access:membership:create",
"admin:users:update": "access:membership:update",
"admin:users:suspend": "access:membership:update",
"admin:groups:read": "access:group:read",
"admin:groups:write": "access:group:write",
"admin:groups:manage_members": "access:group:manage_members",
"admin:roles:read": "access:role:read",
"admin:roles:write": "access:role:write",
"admin:roles:assign": "access:role:assign",
"admin:api_keys:read": "access:api_key:read",
"admin:api_keys:create": "access:api_key:create",
"admin:api_keys:revoke": "access:api_key:revoke",
"admin:settings:read": "access:setting:read",
"admin:settings:write": "access:setting:write",
"admin:policies:read": "access:policy:read",
"admin:policies:write": "access:policy:write",
"system:tenants:read": "access:tenant:read",
"system:tenants:create": "access:tenant:create",
"system:tenants:update": "access:tenant:update",
"system:tenants:suspend": "access:tenant:suspend",
"system:accounts:read": "access:account:read",
"system:accounts:create": "access:account:create",
"system:accounts:update": "access:account:update",
"system:accounts:suspend": "access:account:suspend",
"system:roles:read": "access:system_role:read",
"system:roles:write": "access:system_role:write",
"system:roles:assign": "access:system_role:assign",
"system:access:read": "access:system_role:read",
"system:access:assign": "access:system_role:assign",
"system:audit:read": "access:audit:read",
"system:settings:read": "access:system_setting:read",
"system:settings:write": "access:system_setting:write",
"system:maintenance:access": "access:maintenance:access",
"system:governance:read": "access:governance:read",
"system:governance:write": "access:governance:write",
"campaign:read": "campaigns:campaign:read",
"campaign:create": "campaigns:campaign:create",
"campaign:update": "campaigns:campaign:update",
@@ -40,6 +76,16 @@ const legacyToModuleScopes: Record<string, string> = {
};
const moduleToLegacyScopes = Object.fromEntries(Object.entries(legacyToModuleScopes).map(([legacy, module]) => [module, legacy]));
const moduleSystemScopes = new Set(
Object.entries(legacyToModuleScopes)
.filter(([legacy]) => legacy.startsWith("system:"))
.map(([, module]) => module)
);
const moduleTenantScopes = new Set(
Object.entries(legacyToModuleScopes)
.filter(([legacy]) => !legacy.startsWith("system:"))
.map(([, module]) => module)
);
const legacyAliases: Record<string, string[]> = {
"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"
];