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