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?workflow=module-matrix.yml&actor=0&status=0)
|
||||||
[](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=dependency-audit.yml&actor=0&status=0)
|
||||||
|
|
||||||
# govoplan-core
|
# 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
|
signature, channel, freshness, and replay rules before a catalog entry can be
|
||||||
planned. Catalog entries may declare `license_features`; core checks those
|
planned. Catalog entries may declare `license_features`; core checks those
|
||||||
against the configured offline license before adding the entry to the install
|
against the configured offline license before adding the entry to the install
|
||||||
plan.
|
plan. Catalog entries may also declare `migration_safety` as `automatic`,
|
||||||
|
`requires_review`, `forward_only`, or `destructive`; forward-only and
|
||||||
|
destructive entries require explicit operator acknowledgement in the install
|
||||||
|
plan before installer preflight allows activation.
|
||||||
|
|
||||||
Modules should provide:
|
Modules should provide:
|
||||||
|
|
||||||
- pinned backend and WebUI package refs for official catalog entries
|
- pinned backend and WebUI package refs for official catalog entries
|
||||||
|
- module dependency metadata for catalog target-state planning
|
||||||
|
- migration-safety metadata for catalog update planning
|
||||||
- compatibility metadata in the module manifest
|
- compatibility metadata in the module manifest
|
||||||
- named interface contracts in the manifest and catalog entry when the module
|
- named interface contracts in the manifest and catalog entry when the module
|
||||||
provides or consumes cross-module APIs
|
provides or consumes cross-module APIs
|
||||||
@@ -977,8 +982,15 @@ The package install-plan API records operator intent only:
|
|||||||
also reports catalog validity, channel, signature, trust state, and the
|
also reports catalog validity, channel, signature, trust state, and the
|
||||||
configured path.
|
configured path.
|
||||||
- `POST /api/v1/admin/system/modules/install-plan/catalog/{module_id}` saves
|
- `POST /api/v1/admin/system/modules/install-plan/catalog/{module_id}` saves
|
||||||
a planned install row from a validated catalog entry. Catalog signature and
|
a planned install or update row from a validated catalog entry. Installed
|
||||||
approved-channel policy are enforced before the row is saved.
|
modules are planned as updates. Catalog signature and approved-channel policy
|
||||||
|
are enforced before the row is saved. The saved plan row can also carry a
|
||||||
|
data-safety acknowledgement used by preflight for forward-only or destructive
|
||||||
|
catalog entries.
|
||||||
|
- Install-plan preflight returns a structured `target_plan` summary so the
|
||||||
|
admin UI can show current version, target version, package refs,
|
||||||
|
migration-safety level, and acknowledgement state without requiring JSON
|
||||||
|
editing.
|
||||||
- `POST /api/v1/admin/system/modules/{module_id}/uninstall-plan` saves a
|
- `POST /api/v1/admin/system/modules/{module_id}/uninstall-plan` saves a
|
||||||
planned non-destructive uninstall row for an installed module after it has
|
planned non-destructive uninstall row for an installed module after it has
|
||||||
been disabled. The Python distribution name is resolved from the installed
|
been disabled. The Python distribution name is resolved from the installed
|
||||||
|
|||||||
@@ -205,6 +205,12 @@ Each module entry can declare:
|
|||||||
- WebUI package name and pinned install reference
|
- WebUI package name and pinned install reference
|
||||||
- display metadata and tags
|
- display metadata and tags
|
||||||
- `license_features`, the feature entitlements required to plan that install
|
- `license_features`, the feature entitlements required to plan that install
|
||||||
|
- `dependencies` and `optional_dependencies`, the module ids expected in the
|
||||||
|
target module set
|
||||||
|
- `migration_safety`, one of `automatic`, `requires_review`, `forward_only`,
|
||||||
|
or `destructive`
|
||||||
|
- `migration_notes`, operator-facing data/migration guidance for review,
|
||||||
|
forward-only, or destructive changes
|
||||||
- `provides_interfaces`, named interface contracts exported by this module
|
- `provides_interfaces`, named interface contracts exported by this module
|
||||||
- `requires_interfaces`, named interface contracts and version ranges required
|
- `requires_interfaces`, named interface contracts and version ranges required
|
||||||
by this module
|
by this module
|
||||||
@@ -264,16 +270,22 @@ source/path, source type, cache path, channel, sequence, generated/validity
|
|||||||
timestamps, signature state, trusted key id, and cache state where available.
|
timestamps, signature state, trusted key id, and cache state where available.
|
||||||
Catalog provenance changes preflight severity:
|
Catalog provenance changes preflight severity:
|
||||||
|
|
||||||
- catalog-sourced installs require a configured, valid package catalog before
|
- catalog-sourced installs and updates require a configured, valid package
|
||||||
activation
|
catalog before activation
|
||||||
- invalid, untrusted, expired, not-yet-valid, replayed, or unapproved-channel
|
- invalid, untrusted, expired, not-yet-valid, replayed, or unapproved-channel
|
||||||
catalogs block catalog-sourced installs
|
catalogs block catalog-sourced installs and updates
|
||||||
- the same catalog validation failures remain warnings for manual install
|
- the same catalog validation failures remain warnings for manual install
|
||||||
plans, so operators can still use offline or emergency package refs
|
plans, so operators can still use offline or emergency package refs
|
||||||
- valid-catalog warnings, such as intentionally unsigned local catalogs when
|
- valid-catalog warnings, such as intentionally unsigned local catalogs when
|
||||||
signature enforcement is disabled, remain warnings
|
signature enforcement is disabled, remain warnings
|
||||||
- selected catalog entries with unsatisfied non-optional named interface ranges
|
- selected catalog entries with unsatisfied non-optional named interface ranges
|
||||||
block activation before the installer runs
|
block activation before the installer runs
|
||||||
|
- selected catalog entries whose target dependencies are neither installed nor
|
||||||
|
planned block activation before the installer runs
|
||||||
|
- catalog entries marked `forward_only` or `destructive` block activation until
|
||||||
|
the plan row has an explicit data-safety acknowledgement
|
||||||
|
- catalog entries marked `destructive` also require catalog migration notes or
|
||||||
|
operator notes describing the cleanup or retirement plan
|
||||||
|
|
||||||
### Update Paths
|
### Update Paths
|
||||||
|
|
||||||
@@ -283,6 +295,16 @@ catalog validation snapshot. The installer may install multiple packages into
|
|||||||
the environment before activation, then validate the discovered manifests and
|
the environment before activation, then validate the discovered manifests and
|
||||||
activate the resulting set together.
|
activate the resulting set together.
|
||||||
|
|
||||||
|
Install-plan rows support explicit `install`, `update`, and `uninstall`
|
||||||
|
actions. Catalog planning writes `update` when the module is already installed.
|
||||||
|
Preflight resolves the target set from installed manifests plus the planned
|
||||||
|
catalog entries. Unplanned catalog entries are not treated as installed; when
|
||||||
|
they would satisfy a missing dependency or named interface, preflight blocks
|
||||||
|
activation with a companion-update issue so the operator can add them to the
|
||||||
|
same plan. The preflight response also includes a structured `target_plan`
|
||||||
|
summary with each planned module's action, current version, catalog target
|
||||||
|
version, package refs, migration-safety level, and acknowledgement state.
|
||||||
|
|
||||||
This avoids circular "upgrade A first / upgrade B first" traps: named interface
|
This avoids circular "upgrade A first / upgrade B first" traps: named interface
|
||||||
requirements are solved against the target set, not against each intermediate
|
requirements are solved against the target set, not against each intermediate
|
||||||
package-install moment. If the target set cannot satisfy all non-optional
|
package-install moment. If the target set cannot satisfy all non-optional
|
||||||
@@ -303,6 +325,15 @@ Live data upgrades need an even stricter rule:
|
|||||||
must publish an intermediate compatibility release rather than a circular
|
must publish an intermediate compatibility release rather than a circular
|
||||||
update chain
|
update chain
|
||||||
|
|
||||||
|
The release catalog is the first safety gate for this. Generated catalogs mark
|
||||||
|
modules with registered migrations as `requires_review` by default. Release
|
||||||
|
authors should keep that value for ordinary reversible migrations, raise it to
|
||||||
|
`forward_only` when database rollback requires restoring a snapshot, and raise
|
||||||
|
it to `destructive` when the update removes or irreversibly rewrites persisted
|
||||||
|
data. The admin install-plan UI exposes the safety level and lets operators
|
||||||
|
record an explicit acknowledgement; preflight keeps acknowledged
|
||||||
|
forward-only/destructive changes visible as warnings.
|
||||||
|
|
||||||
In practice, circular dependencies are avoided by designing interfaces with
|
In practice, circular dependencies are avoided by designing interfaces with
|
||||||
compatibility windows and by publishing bridge releases. A bridge release keeps
|
compatibility windows and by publishing bridge releases. A bridge release keeps
|
||||||
the old interface while introducing the new one, allowing dependent modules to
|
the old interface while introducing the new one, allowing dependent modules to
|
||||||
|
|||||||
@@ -253,8 +253,8 @@ def _catalog_payload(
|
|||||||
if module.webui_package:
|
if module.webui_package:
|
||||||
entry["webui_package"] = module.webui_package
|
entry["webui_package"] = module.webui_package
|
||||||
entry["webui_ref"] = f"{repository_base}/{module.repo}.git#{module_tag}"
|
entry["webui_ref"] = f"{repository_base}/{module.repo}.git#{module_tag}"
|
||||||
manifest_interfaces = _manifest_interface_metadata(manifest)
|
manifest_metadata = _manifest_catalog_metadata(manifest)
|
||||||
entry.update(manifest_interfaces)
|
entry.update(manifest_metadata)
|
||||||
if module.provides_interfaces:
|
if module.provides_interfaces:
|
||||||
entry["provides_interfaces"] = [dict(item) for item in module.provides_interfaces]
|
entry["provides_interfaces"] = [dict(item) for item in module.provides_interfaces]
|
||||||
if module.requires_interfaces:
|
if module.requires_interfaces:
|
||||||
@@ -292,10 +292,17 @@ def _discovered_catalog_manifests() -> dict[str, ModuleManifest]:
|
|||||||
return {}
|
return {}
|
||||||
|
|
||||||
|
|
||||||
def _manifest_interface_metadata(manifest: ModuleManifest | None) -> dict[str, object]:
|
def _manifest_catalog_metadata(manifest: ModuleManifest | None) -> dict[str, object]:
|
||||||
if manifest is None:
|
if manifest is None:
|
||||||
return {}
|
return {}
|
||||||
payload: dict[str, object] = {}
|
payload: dict[str, object] = {}
|
||||||
|
if manifest.dependencies:
|
||||||
|
payload["dependencies"] = list(manifest.dependencies)
|
||||||
|
if manifest.optional_dependencies:
|
||||||
|
payload["optional_dependencies"] = list(manifest.optional_dependencies)
|
||||||
|
if manifest.migration_spec is not None:
|
||||||
|
payload["migration_safety"] = "requires_review"
|
||||||
|
payload["migration_notes"] = "Module owns database migrations; review release notes and migration output before activation."
|
||||||
if manifest.provides_interfaces:
|
if manifest.provides_interfaces:
|
||||||
payload["provides_interfaces"] = [
|
payload["provides_interfaces"] = [
|
||||||
{"name": item.name, "version": item.version}
|
{"name": item.name, "version": item.version}
|
||||||
|
|||||||
@@ -41,6 +41,7 @@ from govoplan_core.core.versioning import compare_versions, format_version_range
|
|||||||
IssueSeverity = Literal["blocker", "warning", "info"]
|
IssueSeverity = Literal["blocker", "warning", "info"]
|
||||||
ChecklistStatus = Literal["done", "pending", "blocked", "warning"]
|
ChecklistStatus = Literal["done", "pending", "blocked", "warning"]
|
||||||
SUPPORTED_MANIFEST_CONTRACT_VERSION = "1"
|
SUPPORTED_MANIFEST_CONTRACT_VERSION = "1"
|
||||||
|
PACKAGE_TARGET_ACTIONS = {"install", "update"}
|
||||||
|
|
||||||
|
|
||||||
@dataclass(frozen=True, slots=True)
|
@dataclass(frozen=True, slots=True)
|
||||||
@@ -79,6 +80,44 @@ class ModuleInstallChecklistItem:
|
|||||||
return payload
|
return payload
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(frozen=True, slots=True)
|
||||||
|
class ModuleInstallTargetItem:
|
||||||
|
module_id: str
|
||||||
|
action: str
|
||||||
|
source: str
|
||||||
|
current_version: str | None = None
|
||||||
|
target_version: str | None = None
|
||||||
|
python_package: str | None = None
|
||||||
|
python_ref: str | None = None
|
||||||
|
webui_package: str | None = None
|
||||||
|
webui_ref: str | None = None
|
||||||
|
migration_safety: str = "automatic"
|
||||||
|
migration_notes: str | None = None
|
||||||
|
data_safety_acknowledged: bool = False
|
||||||
|
|
||||||
|
def as_dict(self) -> dict[str, object]:
|
||||||
|
payload: dict[str, object] = {
|
||||||
|
"module_id": self.module_id,
|
||||||
|
"action": self.action,
|
||||||
|
"source": self.source,
|
||||||
|
"migration_safety": self.migration_safety,
|
||||||
|
"data_safety_acknowledged": self.data_safety_acknowledged,
|
||||||
|
}
|
||||||
|
for key in (
|
||||||
|
"current_version",
|
||||||
|
"target_version",
|
||||||
|
"python_package",
|
||||||
|
"python_ref",
|
||||||
|
"webui_package",
|
||||||
|
"webui_ref",
|
||||||
|
"migration_notes",
|
||||||
|
):
|
||||||
|
value = getattr(self, key)
|
||||||
|
if value:
|
||||||
|
payload[key] = value
|
||||||
|
return payload
|
||||||
|
|
||||||
|
|
||||||
@dataclass(frozen=True, slots=True)
|
@dataclass(frozen=True, slots=True)
|
||||||
class ModuleInstallerPreflight:
|
class ModuleInstallerPreflight:
|
||||||
allowed: bool
|
allowed: bool
|
||||||
@@ -89,6 +128,7 @@ class ModuleInstallerPreflight:
|
|||||||
rollback_commands: tuple[str, ...]
|
rollback_commands: tuple[str, ...]
|
||||||
issues: tuple[ModuleInstallerIssue, ...]
|
issues: tuple[ModuleInstallerIssue, ...]
|
||||||
checklist: tuple[ModuleInstallChecklistItem, ...] = ()
|
checklist: tuple[ModuleInstallChecklistItem, ...] = ()
|
||||||
|
target_plan: tuple[ModuleInstallTargetItem, ...] = ()
|
||||||
artifact_integrity: tuple[dict[str, object], ...] = ()
|
artifact_integrity: tuple[dict[str, object], ...] = ()
|
||||||
|
|
||||||
def as_dict(self) -> dict[str, object]:
|
def as_dict(self) -> dict[str, object]:
|
||||||
@@ -101,6 +141,7 @@ class ModuleInstallerPreflight:
|
|||||||
"rollback_commands": list(self.rollback_commands),
|
"rollback_commands": list(self.rollback_commands),
|
||||||
"issues": [issue.as_dict() for issue in self.issues],
|
"issues": [issue.as_dict() for issue in self.issues],
|
||||||
"checklist": [item.as_dict() for item in self.checklist],
|
"checklist": [item.as_dict() for item in self.checklist],
|
||||||
|
"target_plan": [item.as_dict() for item in self.target_plan],
|
||||||
"artifact_integrity": list(self.artifact_integrity),
|
"artifact_integrity": list(self.artifact_integrity),
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -158,6 +199,7 @@ def module_install_preflight(
|
|||||||
desired = {item for item in desired_enabled}
|
desired = {item for item in desired_enabled}
|
||||||
planned_items = tuple(item for item in plan.items if item.status == "planned")
|
planned_items = tuple(item for item in plan.items if item.status == "planned")
|
||||||
dependents = module_dependents(available)
|
dependents = module_dependents(available)
|
||||||
|
target_plan = _module_install_target_plan(plan, available)
|
||||||
|
|
||||||
if not planned_items:
|
if not planned_items:
|
||||||
issues.append(ModuleInstallerIssue("warning", "empty_plan", "No planned package changes are present."))
|
issues.append(ModuleInstallerIssue("warning", "empty_plan", "No planned package changes are present."))
|
||||||
@@ -179,7 +221,14 @@ def module_install_preflight(
|
|||||||
available=available,
|
available=available,
|
||||||
session=session,
|
session=session,
|
||||||
))
|
))
|
||||||
if item.action == "install":
|
if item.action in PACKAGE_TARGET_ACTIONS:
|
||||||
|
if item.action == "update" and item.module_id not in available:
|
||||||
|
issues.append(ModuleInstallerIssue(
|
||||||
|
"blocker",
|
||||||
|
"update_module_missing",
|
||||||
|
"Package updates require the module to be installed; use install for new modules.",
|
||||||
|
item.module_id,
|
||||||
|
))
|
||||||
if item.module_id not in available:
|
if item.module_id not in available:
|
||||||
issues.append(ModuleInstallerIssue(
|
issues.append(ModuleInstallerIssue(
|
||||||
"info",
|
"info",
|
||||||
@@ -243,6 +292,7 @@ def module_install_preflight(
|
|||||||
rollback_commands=rollback_commands,
|
rollback_commands=rollback_commands,
|
||||||
issues=tuple(issues),
|
issues=tuple(issues),
|
||||||
checklist=checklist,
|
checklist=checklist,
|
||||||
|
target_plan=target_plan,
|
||||||
artifact_integrity=artifact_integrity,
|
artifact_integrity=artifact_integrity,
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -795,7 +845,7 @@ def structured_install_commands(
|
|||||||
for item in plan.items:
|
for item in plan.items:
|
||||||
if item.status != "planned":
|
if item.status != "planned":
|
||||||
continue
|
continue
|
||||||
if item.action == "install":
|
if item.action in PACKAGE_TARGET_ACTIONS:
|
||||||
if item.python_ref:
|
if item.python_ref:
|
||||||
commands.append(_structured_command([sys.executable, "-m", "pip", "install", item.python_ref]))
|
commands.append(_structured_command([sys.executable, "-m", "pip", "install", item.python_ref]))
|
||||||
if item.webui_package and item.webui_ref and webui_root is not None:
|
if item.webui_package and item.webui_ref and webui_root is not None:
|
||||||
@@ -955,7 +1005,7 @@ def _module_verify_command(plan: ModuleInstallPlan) -> dict[str, Any] | None:
|
|||||||
for item in plan.items:
|
for item in plan.items:
|
||||||
if item.status != "planned":
|
if item.status != "planned":
|
||||||
continue
|
continue
|
||||||
if item.action == "install" and item.python_ref:
|
if item.action in PACKAGE_TARGET_ACTIONS and item.python_ref:
|
||||||
installed.append(item.module_id)
|
installed.append(item.module_id)
|
||||||
elif item.action == "uninstall" and item.python_package:
|
elif item.action == "uninstall" and item.python_package:
|
||||||
absent.append(item.module_id)
|
absent.append(item.module_id)
|
||||||
@@ -1089,10 +1139,10 @@ def _package_catalog_preflight_issues(
|
|||||||
plan: ModuleInstallPlan,
|
plan: ModuleInstallPlan,
|
||||||
available: Mapping[str, ModuleManifest],
|
available: Mapping[str, ModuleManifest],
|
||||||
) -> tuple[ModuleInstallerIssue, ...]:
|
) -> tuple[ModuleInstallerIssue, ...]:
|
||||||
install_items = tuple(item for item in plan.items if item.status == "planned" and item.action == "install")
|
package_items = tuple(item for item in plan.items if item.status == "planned" and item.action in PACKAGE_TARGET_ACTIONS)
|
||||||
if not install_items:
|
if not package_items:
|
||||||
return ()
|
return ()
|
||||||
catalog_items = tuple(item for item in install_items if item.source == "catalog")
|
catalog_items = tuple(item for item in package_items if item.source == "catalog")
|
||||||
try:
|
try:
|
||||||
from govoplan_core.core.module_package_catalog import validate_module_package_catalog
|
from govoplan_core.core.module_package_catalog import validate_module_package_catalog
|
||||||
|
|
||||||
@@ -1132,27 +1182,79 @@ def _package_catalog_preflight_issues(
|
|||||||
return tuple(issues)
|
return tuple(issues)
|
||||||
|
|
||||||
|
|
||||||
|
def _module_install_target_plan(
|
||||||
|
plan: ModuleInstallPlan,
|
||||||
|
available: Mapping[str, ModuleManifest],
|
||||||
|
) -> tuple[ModuleInstallTargetItem, ...]:
|
||||||
|
planned_items = tuple(item for item in plan.items if item.status == "planned")
|
||||||
|
if not planned_items:
|
||||||
|
return ()
|
||||||
|
catalog_modules = _catalog_modules_for_target_plan(planned_items)
|
||||||
|
targets: list[ModuleInstallTargetItem] = []
|
||||||
|
for item in planned_items:
|
||||||
|
manifest = available.get(item.module_id)
|
||||||
|
catalog_module = catalog_modules.get(item.module_id) if item.source == "catalog" else None
|
||||||
|
target_version = None if item.action == "uninstall" else _catalog_optional_string(catalog_module, "version")
|
||||||
|
migration_safety = "automatic"
|
||||||
|
migration_notes = None
|
||||||
|
if catalog_module is not None and item.action in PACKAGE_TARGET_ACTIONS:
|
||||||
|
migration_safety = _catalog_optional_string(catalog_module, "migration_safety") or "automatic"
|
||||||
|
migration_notes = _catalog_optional_string(catalog_module, "migration_notes")
|
||||||
|
targets.append(ModuleInstallTargetItem(
|
||||||
|
module_id=item.module_id,
|
||||||
|
action=item.action,
|
||||||
|
source=item.source,
|
||||||
|
current_version=manifest.version if manifest is not None else None,
|
||||||
|
target_version=target_version,
|
||||||
|
python_package=item.python_package or _catalog_optional_string(catalog_module, "python_package"),
|
||||||
|
python_ref=item.python_ref or _catalog_optional_string(catalog_module, "python_ref"),
|
||||||
|
webui_package=item.webui_package or _catalog_optional_string(catalog_module, "webui_package"),
|
||||||
|
webui_ref=item.webui_ref or _catalog_optional_string(catalog_module, "webui_ref"),
|
||||||
|
migration_safety=migration_safety,
|
||||||
|
migration_notes=migration_notes,
|
||||||
|
data_safety_acknowledged=item.data_safety_acknowledged,
|
||||||
|
))
|
||||||
|
return tuple(targets)
|
||||||
|
|
||||||
|
|
||||||
|
def _catalog_modules_for_target_plan(
|
||||||
|
planned_items: tuple[ModuleInstallPlanItem, ...],
|
||||||
|
) -> dict[str, Mapping[str, object]]:
|
||||||
|
if not any(item.source == "catalog" and item.action in PACKAGE_TARGET_ACTIONS for item in planned_items):
|
||||||
|
return {}
|
||||||
|
try:
|
||||||
|
from govoplan_core.core.module_package_catalog import validate_module_package_catalog
|
||||||
|
|
||||||
|
result = validate_module_package_catalog()
|
||||||
|
except Exception:
|
||||||
|
return {}
|
||||||
|
if result.get("valid") is not True:
|
||||||
|
return {}
|
||||||
|
return _catalog_modules_by_id(result)
|
||||||
|
|
||||||
|
|
||||||
|
def _catalog_optional_string(module: Mapping[str, object] | None, key: str) -> str | None:
|
||||||
|
if module is None:
|
||||||
|
return None
|
||||||
|
value = module.get(key)
|
||||||
|
if isinstance(value, str):
|
||||||
|
cleaned = value.strip()
|
||||||
|
return cleaned or None
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
def _selected_catalog_interface_issues(
|
def _selected_catalog_interface_issues(
|
||||||
catalog_items: tuple[ModuleInstallPlanItem, ...],
|
catalog_items: tuple[ModuleInstallPlanItem, ...],
|
||||||
validation: Mapping[str, object],
|
validation: Mapping[str, object],
|
||||||
available: Mapping[str, ModuleManifest],
|
available: Mapping[str, ModuleManifest],
|
||||||
) -> tuple[ModuleInstallerIssue, ...]:
|
) -> tuple[ModuleInstallerIssue, ...]:
|
||||||
modules = {
|
modules = _catalog_modules_by_id(validation)
|
||||||
str(item.get("module_id")): item
|
planned_ids = {item.module_id for item in catalog_items}
|
||||||
for item in validation.get("modules", [])
|
target_provider_versions = _catalog_target_provider_versions(
|
||||||
if isinstance(item, Mapping) and item.get("module_id")
|
catalog_items=catalog_items,
|
||||||
}
|
modules=modules,
|
||||||
catalog_provider_versions: dict[str, list[tuple[str, str]]] = defaultdict(list)
|
available=available,
|
||||||
for module_id, module in modules.items():
|
)
|
||||||
for provided in _catalog_interface_items(module.get("provides_interfaces")):
|
|
||||||
name = provided.get("name") if isinstance(provided.get("name"), str) else None
|
|
||||||
version = provided.get("version") if isinstance(provided.get("version"), str) else None
|
|
||||||
if name and version:
|
|
||||||
catalog_provider_versions[name].append((module_id, version))
|
|
||||||
installed_provider_versions: dict[str, list[tuple[str, str]]] = defaultdict(list)
|
|
||||||
for manifest in available.values():
|
|
||||||
for provided in manifest.provides_interfaces:
|
|
||||||
installed_provider_versions[provided.name].append((manifest.id, provided.version))
|
|
||||||
|
|
||||||
issues: list[ModuleInstallerIssue] = []
|
issues: list[ModuleInstallerIssue] = []
|
||||||
for item in catalog_items:
|
for item in catalog_items:
|
||||||
@@ -1165,64 +1267,344 @@ def _selected_catalog_interface_issues(
|
|||||||
item.module_id,
|
item.module_id,
|
||||||
))
|
))
|
||||||
continue
|
continue
|
||||||
for required in _catalog_interface_items(module.get("requires_interfaces")):
|
issues.extend(_catalog_dependency_issues(
|
||||||
name = required.get("name") if isinstance(required.get("name"), str) else None
|
module_id=item.module_id,
|
||||||
if not name:
|
module=module,
|
||||||
continue
|
planned_ids=planned_ids,
|
||||||
version_min = required.get("version_min") if isinstance(required.get("version_min"), str) else None
|
available=available,
|
||||||
version_max_exclusive = (
|
modules=modules,
|
||||||
required.get("version_max_exclusive")
|
))
|
||||||
if isinstance(required.get("version_max_exclusive"), str)
|
issues.extend(_catalog_data_safety_issues(item=item, module=module))
|
||||||
else None
|
issues.extend(_catalog_requirement_issues(
|
||||||
|
module_id=item.module_id,
|
||||||
|
requirements=_catalog_interface_items(module.get("requires_interfaces")),
|
||||||
|
target_provider_versions=target_provider_versions,
|
||||||
|
catalog_modules=modules,
|
||||||
|
planned_ids=planned_ids,
|
||||||
|
code_prefix="catalog",
|
||||||
|
))
|
||||||
|
|
||||||
|
for manifest in available.values():
|
||||||
|
if manifest.id in planned_ids:
|
||||||
|
continue
|
||||||
|
requirements = tuple(
|
||||||
|
{
|
||||||
|
"name": requirement.name,
|
||||||
|
"version_min": requirement.version_min,
|
||||||
|
"version_max_exclusive": requirement.version_max_exclusive,
|
||||||
|
"optional": requirement.optional,
|
||||||
|
}
|
||||||
|
for requirement in manifest.requires_interfaces
|
||||||
|
)
|
||||||
|
requirement_issues = _catalog_requirement_issues(
|
||||||
|
module_id=manifest.id,
|
||||||
|
requirements=requirements,
|
||||||
|
target_provider_versions=target_provider_versions,
|
||||||
|
catalog_modules=modules,
|
||||||
|
planned_ids=planned_ids,
|
||||||
|
code_prefix="target",
|
||||||
|
)
|
||||||
|
candidate_update = modules.get(manifest.id)
|
||||||
|
if (
|
||||||
|
any(issue.severity == "blocker" for issue in requirement_issues)
|
||||||
|
and candidate_update is not None
|
||||||
|
and _catalog_requirements_satisfied(
|
||||||
|
_catalog_interface_items(candidate_update.get("requires_interfaces")),
|
||||||
|
target_provider_versions,
|
||||||
)
|
)
|
||||||
optional = required.get("optional") is True
|
):
|
||||||
providers = [
|
issues.append(ModuleInstallerIssue(
|
||||||
*catalog_provider_versions.get(name, ()),
|
"blocker",
|
||||||
*installed_provider_versions.get(name, ()),
|
"companion_update_required",
|
||||||
]
|
(
|
||||||
matching = [
|
f"Target package plan would leave installed module {manifest.id!r} "
|
||||||
(provider_module_id, version)
|
f"incompatible; add the catalog update for {manifest.id!r} to the same plan."
|
||||||
for provider_module_id, version in providers
|
),
|
||||||
if version_satisfies_range(
|
manifest.id,
|
||||||
version,
|
))
|
||||||
version_min=version_min,
|
continue
|
||||||
version_max_exclusive=version_max_exclusive,
|
issues.extend(requirement_issues)
|
||||||
)
|
return tuple(issues)
|
||||||
]
|
|
||||||
if matching:
|
|
||||||
continue
|
def _catalog_data_safety_issues(
|
||||||
version_range = format_version_range(
|
*,
|
||||||
|
item: ModuleInstallPlanItem,
|
||||||
|
module: Mapping[str, object],
|
||||||
|
) -> tuple[ModuleInstallerIssue, ...]:
|
||||||
|
raw_safety = module.get("migration_safety")
|
||||||
|
safety = raw_safety if isinstance(raw_safety, str) and raw_safety else "automatic"
|
||||||
|
raw_notes = module.get("migration_notes")
|
||||||
|
notes = raw_notes.strip() if isinstance(raw_notes, str) and raw_notes.strip() else None
|
||||||
|
detail = f" Catalog note: {notes}" if notes else ""
|
||||||
|
if safety == "automatic":
|
||||||
|
return ()
|
||||||
|
if safety == "requires_review":
|
||||||
|
severity: IssueSeverity = "info" if item.data_safety_acknowledged else "warning"
|
||||||
|
code = "migration_review_acknowledged" if item.data_safety_acknowledged else "migration_review_required"
|
||||||
|
message = "Catalog marks this package change as requiring migration review before activation." + detail
|
||||||
|
return (ModuleInstallerIssue(severity, code, message, item.module_id),)
|
||||||
|
if safety == "forward_only":
|
||||||
|
if not item.data_safety_acknowledged:
|
||||||
|
return (ModuleInstallerIssue(
|
||||||
|
"blocker",
|
||||||
|
"forward_only_migration_acknowledgement_required",
|
||||||
|
"Catalog marks this package change as forward-only; acknowledge the data safety review before activation." + detail,
|
||||||
|
item.module_id,
|
||||||
|
),)
|
||||||
|
return (ModuleInstallerIssue(
|
||||||
|
"warning",
|
||||||
|
"forward_only_migration_acknowledged",
|
||||||
|
"Forward-only package change has been acknowledged by the operator." + detail,
|
||||||
|
item.module_id,
|
||||||
|
),)
|
||||||
|
if safety == "destructive":
|
||||||
|
issues: list[ModuleInstallerIssue] = []
|
||||||
|
if not notes and not item.notes:
|
||||||
|
issues.append(ModuleInstallerIssue(
|
||||||
|
"blocker",
|
||||||
|
"destructive_migration_plan_required",
|
||||||
|
"Catalog marks this package change as destructive; add catalog migration notes or operator notes describing the cleanup/retirement plan.",
|
||||||
|
item.module_id,
|
||||||
|
))
|
||||||
|
if not item.data_safety_acknowledged:
|
||||||
|
issues.append(ModuleInstallerIssue(
|
||||||
|
"blocker",
|
||||||
|
"destructive_migration_acknowledgement_required",
|
||||||
|
"Catalog marks this package change as destructive; acknowledge the data safety review before activation." + detail,
|
||||||
|
item.module_id,
|
||||||
|
))
|
||||||
|
if not issues:
|
||||||
|
issues.append(ModuleInstallerIssue(
|
||||||
|
"warning",
|
||||||
|
"destructive_migration_acknowledged",
|
||||||
|
"Destructive package change has been acknowledged by the operator." + detail,
|
||||||
|
item.module_id,
|
||||||
|
))
|
||||||
|
return tuple(issues)
|
||||||
|
return (ModuleInstallerIssue(
|
||||||
|
"blocker",
|
||||||
|
"catalog_migration_safety_invalid",
|
||||||
|
f"Catalog entry has unsupported migration_safety value {safety!r}.",
|
||||||
|
item.module_id,
|
||||||
|
),)
|
||||||
|
|
||||||
|
|
||||||
|
def _catalog_modules_by_id(validation: Mapping[str, object]) -> dict[str, Mapping[str, object]]:
|
||||||
|
return {
|
||||||
|
str(item.get("module_id")): item
|
||||||
|
for item in validation.get("modules", [])
|
||||||
|
if isinstance(item, Mapping) and item.get("module_id")
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def _catalog_target_provider_versions(
|
||||||
|
*,
|
||||||
|
catalog_items: tuple[ModuleInstallPlanItem, ...],
|
||||||
|
modules: Mapping[str, Mapping[str, object]],
|
||||||
|
available: Mapping[str, ModuleManifest],
|
||||||
|
) -> dict[str, list[tuple[str, str]]]:
|
||||||
|
planned_ids = {item.module_id for item in catalog_items}
|
||||||
|
providers: dict[str, list[tuple[str, str]]] = defaultdict(list)
|
||||||
|
for manifest in available.values():
|
||||||
|
if manifest.id in planned_ids:
|
||||||
|
continue
|
||||||
|
for provided in manifest.provides_interfaces:
|
||||||
|
providers[provided.name].append((manifest.id, provided.version))
|
||||||
|
for item in catalog_items:
|
||||||
|
module = modules.get(item.module_id)
|
||||||
|
if module is None:
|
||||||
|
continue
|
||||||
|
for provided in _catalog_interface_items(module.get("provides_interfaces")):
|
||||||
|
name = provided.get("name") if isinstance(provided.get("name"), str) else None
|
||||||
|
version = provided.get("version") if isinstance(provided.get("version"), str) else None
|
||||||
|
if name and version:
|
||||||
|
providers[name].append((item.module_id, version))
|
||||||
|
return providers
|
||||||
|
|
||||||
|
|
||||||
|
def _catalog_dependency_issues(
|
||||||
|
*,
|
||||||
|
module_id: str,
|
||||||
|
module: Mapping[str, object],
|
||||||
|
planned_ids: set[str],
|
||||||
|
available: Mapping[str, ModuleManifest],
|
||||||
|
modules: Mapping[str, Mapping[str, object]],
|
||||||
|
) -> tuple[ModuleInstallerIssue, ...]:
|
||||||
|
issues: list[ModuleInstallerIssue] = []
|
||||||
|
for dependency_id in _catalog_string_list(module.get("dependencies")):
|
||||||
|
if dependency_id in planned_ids or dependency_id in available:
|
||||||
|
continue
|
||||||
|
if dependency_id in modules:
|
||||||
|
issues.append(ModuleInstallerIssue(
|
||||||
|
"blocker",
|
||||||
|
"companion_update_required",
|
||||||
|
(
|
||||||
|
f"Catalog-sourced module {module_id!r} depends on {dependency_id!r}; "
|
||||||
|
"add that catalog entry to the same package plan."
|
||||||
|
),
|
||||||
|
module_id,
|
||||||
|
))
|
||||||
|
continue
|
||||||
|
issues.append(ModuleInstallerIssue(
|
||||||
|
"blocker",
|
||||||
|
"catalog_required_dependency_missing",
|
||||||
|
(
|
||||||
|
f"Catalog-sourced module {module_id!r} depends on {dependency_id!r}, "
|
||||||
|
"but it is neither installed nor present in the package catalog."
|
||||||
|
),
|
||||||
|
module_id,
|
||||||
|
))
|
||||||
|
return tuple(issues)
|
||||||
|
|
||||||
|
|
||||||
|
def _catalog_requirement_issues(
|
||||||
|
*,
|
||||||
|
module_id: str,
|
||||||
|
requirements: tuple[Mapping[str, object], ...],
|
||||||
|
target_provider_versions: Mapping[str, list[tuple[str, str]]],
|
||||||
|
catalog_modules: Mapping[str, Mapping[str, object]],
|
||||||
|
planned_ids: set[str],
|
||||||
|
code_prefix: str,
|
||||||
|
) -> tuple[ModuleInstallerIssue, ...]:
|
||||||
|
issues: list[ModuleInstallerIssue] = []
|
||||||
|
for required in requirements:
|
||||||
|
name = required.get("name") if isinstance(required.get("name"), str) else None
|
||||||
|
if not name:
|
||||||
|
continue
|
||||||
|
version_min = required.get("version_min") if isinstance(required.get("version_min"), str) else None
|
||||||
|
version_max_exclusive = (
|
||||||
|
required.get("version_max_exclusive")
|
||||||
|
if isinstance(required.get("version_max_exclusive"), str)
|
||||||
|
else None
|
||||||
|
)
|
||||||
|
optional = required.get("optional") is True
|
||||||
|
providers = target_provider_versions.get(name, ())
|
||||||
|
matching = [
|
||||||
|
(provider_module_id, version)
|
||||||
|
for provider_module_id, version in providers
|
||||||
|
if version_satisfies_range(
|
||||||
|
version,
|
||||||
version_min=version_min,
|
version_min=version_min,
|
||||||
version_max_exclusive=version_max_exclusive,
|
version_max_exclusive=version_max_exclusive,
|
||||||
)
|
)
|
||||||
if not providers:
|
]
|
||||||
if optional:
|
if matching:
|
||||||
continue
|
continue
|
||||||
issues.append(ModuleInstallerIssue(
|
version_range = format_version_range(
|
||||||
"blocker",
|
version_min=version_min,
|
||||||
"catalog_required_interface_missing",
|
version_max_exclusive=version_max_exclusive,
|
||||||
(
|
)
|
||||||
f"Catalog-sourced module {item.module_id!r} requires interface "
|
suggestions = _catalog_provider_suggestions(
|
||||||
f"{name!r} ({version_range}), but neither the package catalog nor "
|
name=name,
|
||||||
"installed modules provide it."
|
version_min=version_min,
|
||||||
),
|
version_max_exclusive=version_max_exclusive,
|
||||||
item.module_id,
|
catalog_modules=catalog_modules,
|
||||||
))
|
planned_ids=planned_ids,
|
||||||
continue
|
)
|
||||||
provider_summary = ", ".join(f"{provider_module_id}@{version}" for provider_module_id, version in providers)
|
if suggestions:
|
||||||
severity: IssueSeverity = "warning" if optional else "blocker"
|
|
||||||
issues.append(ModuleInstallerIssue(
|
issues.append(ModuleInstallerIssue(
|
||||||
severity,
|
"blocker",
|
||||||
"catalog_interface_version_mismatch",
|
"companion_update_required",
|
||||||
(
|
(
|
||||||
f"Catalog-sourced module {item.module_id!r} requires interface "
|
f"Module {module_id!r} requires interface {name!r} ({version_range}); "
|
||||||
f"{name!r} ({version_range}), but available providers are {provider_summary}."
|
f"add companion catalog update(s) to the same plan: {', '.join(suggestions)}."
|
||||||
),
|
),
|
||||||
item.module_id,
|
module_id,
|
||||||
))
|
))
|
||||||
|
continue
|
||||||
|
if not providers:
|
||||||
|
if optional:
|
||||||
|
continue
|
||||||
|
issues.append(ModuleInstallerIssue(
|
||||||
|
"blocker",
|
||||||
|
f"{code_prefix}_required_interface_missing",
|
||||||
|
(
|
||||||
|
f"Module {module_id!r} requires interface {name!r} ({version_range}), "
|
||||||
|
"but the target package plan has no provider."
|
||||||
|
),
|
||||||
|
module_id,
|
||||||
|
))
|
||||||
|
continue
|
||||||
|
provider_summary = ", ".join(f"{provider_module_id}@{version}" for provider_module_id, version in providers)
|
||||||
|
severity: IssueSeverity = "warning" if optional else "blocker"
|
||||||
|
issues.append(ModuleInstallerIssue(
|
||||||
|
severity,
|
||||||
|
f"{code_prefix}_interface_version_mismatch",
|
||||||
|
(
|
||||||
|
f"Module {module_id!r} requires interface {name!r} ({version_range}), "
|
||||||
|
f"but target providers are {provider_summary}."
|
||||||
|
),
|
||||||
|
module_id,
|
||||||
|
))
|
||||||
return tuple(issues)
|
return tuple(issues)
|
||||||
|
|
||||||
|
|
||||||
|
def _catalog_provider_suggestions(
|
||||||
|
*,
|
||||||
|
name: str,
|
||||||
|
version_min: str | None,
|
||||||
|
version_max_exclusive: str | None,
|
||||||
|
catalog_modules: Mapping[str, Mapping[str, object]],
|
||||||
|
planned_ids: set[str],
|
||||||
|
) -> tuple[str, ...]:
|
||||||
|
suggestions: list[str] = []
|
||||||
|
for module_id, module in catalog_modules.items():
|
||||||
|
if module_id in planned_ids:
|
||||||
|
continue
|
||||||
|
for provided in _catalog_interface_items(module.get("provides_interfaces")):
|
||||||
|
provided_name = provided.get("name") if isinstance(provided.get("name"), str) else None
|
||||||
|
version = provided.get("version") if isinstance(provided.get("version"), str) else None
|
||||||
|
if provided_name != name or not version:
|
||||||
|
continue
|
||||||
|
if version_satisfies_range(
|
||||||
|
version,
|
||||||
|
version_min=version_min,
|
||||||
|
version_max_exclusive=version_max_exclusive,
|
||||||
|
):
|
||||||
|
suggestions.append(module_id)
|
||||||
|
break
|
||||||
|
return tuple(sorted(dict.fromkeys(suggestions)))
|
||||||
|
|
||||||
|
|
||||||
|
def _catalog_requirements_satisfied(
|
||||||
|
requirements: tuple[Mapping[str, object], ...],
|
||||||
|
target_provider_versions: Mapping[str, list[tuple[str, str]]],
|
||||||
|
) -> bool:
|
||||||
|
for required in requirements:
|
||||||
|
name = required.get("name") if isinstance(required.get("name"), str) else None
|
||||||
|
if not name:
|
||||||
|
continue
|
||||||
|
version_min = required.get("version_min") if isinstance(required.get("version_min"), str) else None
|
||||||
|
version_max_exclusive = (
|
||||||
|
required.get("version_max_exclusive")
|
||||||
|
if isinstance(required.get("version_max_exclusive"), str)
|
||||||
|
else None
|
||||||
|
)
|
||||||
|
providers = target_provider_versions.get(name, ())
|
||||||
|
matching = [
|
||||||
|
version
|
||||||
|
for _provider_module_id, version in providers
|
||||||
|
if version_satisfies_range(
|
||||||
|
version,
|
||||||
|
version_min=version_min,
|
||||||
|
version_max_exclusive=version_max_exclusive,
|
||||||
|
)
|
||||||
|
]
|
||||||
|
if matching:
|
||||||
|
continue
|
||||||
|
if required.get("optional") is True and not providers:
|
||||||
|
continue
|
||||||
|
return False
|
||||||
|
return True
|
||||||
|
|
||||||
|
|
||||||
|
def _catalog_string_list(value: object) -> tuple[str, ...]:
|
||||||
|
if not isinstance(value, list):
|
||||||
|
return ()
|
||||||
|
return tuple(str(item).strip() for item in value if str(item).strip())
|
||||||
|
|
||||||
|
|
||||||
def _catalog_interface_items(value: object) -> tuple[Mapping[str, object], ...]:
|
def _catalog_interface_items(value: object) -> tuple[Mapping[str, object], ...]:
|
||||||
if not isinstance(value, list):
|
if not isinstance(value, list):
|
||||||
return ()
|
return ()
|
||||||
@@ -1547,7 +1929,7 @@ def _verify_artifact_integrity(
|
|||||||
records: list[dict[str, object]] = []
|
records: list[dict[str, object]] = []
|
||||||
issues: list[ModuleInstallerIssue] = []
|
issues: list[ModuleInstallerIssue] = []
|
||||||
for item in planned_items:
|
for item in planned_items:
|
||||||
if item.action != "install":
|
if item.action not in PACKAGE_TARGET_ACTIONS:
|
||||||
continue
|
continue
|
||||||
for kind, package_name, package_ref in (
|
for kind, package_name, package_ref in (
|
||||||
("python", item.python_package, item.python_ref),
|
("python", item.python_package, item.python_ref),
|
||||||
|
|||||||
@@ -19,7 +19,7 @@ MODULE_SETTINGS_KEY = "module_management"
|
|||||||
INSTALL_PLAN_KEY = "install_plan"
|
INSTALL_PLAN_KEY = "install_plan"
|
||||||
REQUIRED_PLATFORM_MODULES = ("access",)
|
REQUIRED_PLATFORM_MODULES = ("access",)
|
||||||
PROTECTED_MODULES = (*REQUIRED_PLATFORM_MODULES, "admin")
|
PROTECTED_MODULES = (*REQUIRED_PLATFORM_MODULES, "admin")
|
||||||
INSTALL_PLAN_ACTIONS = ("install", "uninstall")
|
INSTALL_PLAN_ACTIONS = ("install", "update", "uninstall")
|
||||||
INSTALL_PLAN_STATUSES = ("planned", "applied", "blocked")
|
INSTALL_PLAN_STATUSES = ("planned", "applied", "blocked")
|
||||||
INSTALL_PLAN_SOURCES = ("manual", "catalog")
|
INSTALL_PLAN_SOURCES = ("manual", "catalog")
|
||||||
LOCAL_DEPENDENCY_REF_PREFIXES = ("file:", "path:", "workspace:", "link:")
|
LOCAL_DEPENDENCY_REF_PREFIXES = ("file:", "path:", "workspace:", "link:")
|
||||||
@@ -46,6 +46,7 @@ class ModuleInstallPlanItem:
|
|||||||
webui_package: str | None = None
|
webui_package: str | None = None
|
||||||
webui_ref: str | None = None
|
webui_ref: str | None = None
|
||||||
artifact_integrity: Mapping[str, object] | None = None
|
artifact_integrity: Mapping[str, object] | None = None
|
||||||
|
data_safety_acknowledged: bool = False
|
||||||
destroy_data: bool = False
|
destroy_data: bool = False
|
||||||
status: str = "planned"
|
status: str = "planned"
|
||||||
notes: str | None = None
|
notes: str | None = None
|
||||||
@@ -59,6 +60,8 @@ class ModuleInstallPlanItem:
|
|||||||
}
|
}
|
||||||
if self.destroy_data:
|
if self.destroy_data:
|
||||||
payload["destroy_data"] = True
|
payload["destroy_data"] = True
|
||||||
|
if self.data_safety_acknowledged:
|
||||||
|
payload["data_safety_acknowledged"] = True
|
||||||
if self.catalog:
|
if self.catalog:
|
||||||
payload["catalog"] = dict(self.catalog)
|
payload["catalog"] = dict(self.catalog)
|
||||||
for key in ("python_package", "python_ref", "webui_package", "webui_ref", "notes"):
|
for key in ("python_package", "python_ref", "webui_package", "webui_ref", "notes"):
|
||||||
@@ -206,7 +209,7 @@ def module_install_plan_commands(
|
|||||||
for item in items:
|
for item in items:
|
||||||
if item.status not in included_statuses:
|
if item.status not in included_statuses:
|
||||||
continue
|
continue
|
||||||
if item.action == "install":
|
if item.action in {"install", "update"}:
|
||||||
if item.python_ref:
|
if item.python_ref:
|
||||||
commands.append(f"python -m pip install {shlex.quote(item.python_ref)}")
|
commands.append(f"python -m pip install {shlex.quote(item.python_ref)}")
|
||||||
if item.webui_package and item.webui_ref:
|
if item.webui_package and item.webui_ref:
|
||||||
@@ -281,7 +284,7 @@ def desired_modules_after_package_plan(
|
|||||||
removed = {item.module_id for item in planned_items if item.action == "uninstall"}
|
removed = {item.module_id for item in planned_items if item.action == "uninstall"}
|
||||||
desired = [module_id for module_id in desired if module_id not in removed]
|
desired = [module_id for module_id in desired if module_id not in removed]
|
||||||
if activate_installed_modules:
|
if activate_installed_modules:
|
||||||
desired.extend(item.module_id for item in planned_items if item.action == "install")
|
desired.extend(item.module_id for item in planned_items if item.action in {"install", "update"})
|
||||||
return tuple(dict.fromkeys(desired))
|
return tuple(dict.fromkeys(desired))
|
||||||
|
|
||||||
|
|
||||||
@@ -305,6 +308,7 @@ def normalize_module_install_plan_item(
|
|||||||
webui_package = _clean_optional_string(raw.get("webui_package"))
|
webui_package = _clean_optional_string(raw.get("webui_package"))
|
||||||
webui_ref = _clean_optional_string(raw.get("webui_ref"))
|
webui_ref = _clean_optional_string(raw.get("webui_ref"))
|
||||||
artifact_integrity = _clean_optional_mapping(raw.get("artifact_integrity"), field="artifact_integrity", module_id=module_id)
|
artifact_integrity = _clean_optional_mapping(raw.get("artifact_integrity"), field="artifact_integrity", module_id=module_id)
|
||||||
|
data_safety_acknowledged = _clean_bool(raw.get("data_safety_acknowledged"))
|
||||||
destroy_data = _clean_bool(raw.get("destroy_data"))
|
destroy_data = _clean_bool(raw.get("destroy_data"))
|
||||||
notes = _clean_optional_string(raw.get("notes"))
|
notes = _clean_optional_string(raw.get("notes"))
|
||||||
|
|
||||||
@@ -314,13 +318,13 @@ def normalize_module_install_plan_item(
|
|||||||
raise ModuleManagementError(f"Unsupported install plan status for {module_id!r}: {status!r}.")
|
raise ModuleManagementError(f"Unsupported install plan status for {module_id!r}: {status!r}.")
|
||||||
if source not in INSTALL_PLAN_SOURCES:
|
if source not in INSTALL_PLAN_SOURCES:
|
||||||
raise ModuleManagementError(f"Unsupported install plan source for {module_id!r}: {source!r}.")
|
raise ModuleManagementError(f"Unsupported install plan source for {module_id!r}: {source!r}.")
|
||||||
if action == "install" and not python_ref:
|
if action in {"install", "update"} and not python_ref:
|
||||||
raise ModuleManagementError(f"Install plan item {module_id!r} needs a Python package reference.")
|
raise ModuleManagementError(f"Install plan item {module_id!r} needs a Python package reference.")
|
||||||
if action == "uninstall" and not python_package:
|
if action == "uninstall" and not python_package:
|
||||||
raise ModuleManagementError(f"Uninstall plan item {module_id!r} needs a Python package name.")
|
raise ModuleManagementError(f"Uninstall plan item {module_id!r} needs a Python package name.")
|
||||||
if action != "uninstall" and destroy_data:
|
if action != "uninstall" and destroy_data:
|
||||||
raise ModuleManagementError(f"Install plan item {module_id!r} can only destroy data during uninstall.")
|
raise ModuleManagementError(f"Install plan item {module_id!r} can only destroy data during uninstall.")
|
||||||
if action == "install" and bool(webui_package) != bool(webui_ref):
|
if action in {"install", "update"} and bool(webui_package) != bool(webui_ref):
|
||||||
raise ModuleManagementError(f"Install plan item {module_id!r} needs both WebUI package and WebUI reference, or neither.")
|
raise ModuleManagementError(f"Install plan item {module_id!r} needs both WebUI package and WebUI reference, or neither.")
|
||||||
if action == "uninstall" and webui_ref and not webui_package:
|
if action == "uninstall" and webui_ref and not webui_package:
|
||||||
raise ModuleManagementError(f"Uninstall plan item {module_id!r} has a WebUI reference but no WebUI package.")
|
raise ModuleManagementError(f"Uninstall plan item {module_id!r} has a WebUI reference but no WebUI package.")
|
||||||
@@ -339,6 +343,7 @@ def normalize_module_install_plan_item(
|
|||||||
webui_package=webui_package,
|
webui_package=webui_package,
|
||||||
webui_ref=webui_ref,
|
webui_ref=webui_ref,
|
||||||
artifact_integrity=artifact_integrity,
|
artifact_integrity=artifact_integrity,
|
||||||
|
data_safety_acknowledged=data_safety_acknowledged,
|
||||||
destroy_data=destroy_data,
|
destroy_data=destroy_data,
|
||||||
status=status,
|
status=status,
|
||||||
notes=notes,
|
notes=notes,
|
||||||
|
|||||||
@@ -19,6 +19,7 @@ from cryptography.hazmat.primitives.asymmetric.ed25519 import Ed25519PrivateKey,
|
|||||||
from govoplan_core.core.versioning import format_version_range, version_range_is_valid, version_satisfies_range
|
from govoplan_core.core.versioning import format_version_range, version_range_is_valid, version_satisfies_range
|
||||||
|
|
||||||
_INTERFACE_NAME_RE = re.compile(r"^[a-z][a-z0-9_]*(?:\.[a-z][a-z0-9_]*)+$")
|
_INTERFACE_NAME_RE = re.compile(r"^[a-z][a-z0-9_]*(?:\.[a-z][a-z0-9_]*)+$")
|
||||||
|
CATALOG_MIGRATION_SAFETY = ("automatic", "requires_review", "forward_only", "destructive")
|
||||||
|
|
||||||
|
|
||||||
def module_package_catalog(
|
def module_package_catalog(
|
||||||
@@ -598,7 +599,7 @@ def _normalize_catalog_item(value: Any) -> dict[str, object]:
|
|||||||
raise ValueError("Module package catalog entries must be objects.")
|
raise ValueError("Module package catalog entries must be objects.")
|
||||||
module_id = _required_str(value, "module_id")
|
module_id = _required_str(value, "module_id")
|
||||||
action = str(value.get("action") or "install")
|
action = str(value.get("action") or "install")
|
||||||
if action not in {"install", "uninstall"}:
|
if action not in {"install", "update", "uninstall"}:
|
||||||
raise ValueError(f"Unsupported catalog action for {module_id!r}: {action!r}")
|
raise ValueError(f"Unsupported catalog action for {module_id!r}: {action!r}")
|
||||||
item = {
|
item = {
|
||||||
"module_id": module_id,
|
"module_id": module_id,
|
||||||
@@ -606,6 +607,10 @@ def _normalize_catalog_item(value: Any) -> dict[str, object]:
|
|||||||
"description": _optional_str(value, "description"),
|
"description": _optional_str(value, "description"),
|
||||||
"version": _optional_str(value, "version"),
|
"version": _optional_str(value, "version"),
|
||||||
"action": action,
|
"action": action,
|
||||||
|
"dependencies": _string_list(value.get("dependencies")),
|
||||||
|
"optional_dependencies": _string_list(value.get("optional_dependencies")),
|
||||||
|
"migration_safety": _catalog_migration_safety(value.get("migration_safety"), module_id=module_id),
|
||||||
|
"migration_notes": _optional_str(value, "migration_notes"),
|
||||||
"python_package": _optional_str(value, "python_package"),
|
"python_package": _optional_str(value, "python_package"),
|
||||||
"python_ref": _optional_str(value, "python_ref"),
|
"python_ref": _optional_str(value, "python_ref"),
|
||||||
"webui_package": _optional_str(value, "webui_package"),
|
"webui_package": _optional_str(value, "webui_package"),
|
||||||
@@ -622,6 +627,16 @@ def _normalize_catalog_item(value: Any) -> dict[str, object]:
|
|||||||
return item
|
return item
|
||||||
|
|
||||||
|
|
||||||
|
def _catalog_migration_safety(value: Any, *, module_id: str) -> str:
|
||||||
|
if value is None:
|
||||||
|
return "automatic"
|
||||||
|
safety = str(value).strip()
|
||||||
|
if safety not in CATALOG_MIGRATION_SAFETY:
|
||||||
|
allowed = ", ".join(CATALOG_MIGRATION_SAFETY)
|
||||||
|
raise ValueError(f"Unsupported migration_safety for {module_id!r}: {safety!r}; expected one of {allowed}.")
|
||||||
|
return safety
|
||||||
|
|
||||||
|
|
||||||
def _required_str(value: dict[str, Any], key: str) -> str:
|
def _required_str(value: dict[str, Any], key: str) -> str:
|
||||||
item = _optional_str(value, key)
|
item = _optional_str(value, key)
|
||||||
if not item:
|
if not item:
|
||||||
|
|||||||
@@ -6,6 +6,42 @@ from govoplan_core.security.permissions import scopes_grant
|
|||||||
|
|
||||||
|
|
||||||
LEGACY_TO_MODULE_SCOPES: dict[str, str] = {
|
LEGACY_TO_MODULE_SCOPES: dict[str, str] = {
|
||||||
|
"admin:users:read": "access:membership:read",
|
||||||
|
"admin:users:create": "access:membership:create",
|
||||||
|
"admin:users:update": "access:membership:update",
|
||||||
|
"admin:users:suspend": "access:membership:update",
|
||||||
|
"admin:groups:read": "access:group:read",
|
||||||
|
"admin:groups:write": "access:group:write",
|
||||||
|
"admin:groups:manage_members": "access:group:manage_members",
|
||||||
|
"admin:roles:read": "access:role:read",
|
||||||
|
"admin:roles:write": "access:role:write",
|
||||||
|
"admin:roles:assign": "access:role:assign",
|
||||||
|
"admin:api_keys:read": "access:api_key:read",
|
||||||
|
"admin:api_keys:create": "access:api_key:create",
|
||||||
|
"admin:api_keys:revoke": "access:api_key:revoke",
|
||||||
|
"admin:settings:read": "access:setting:read",
|
||||||
|
"admin:settings:write": "access:setting:write",
|
||||||
|
"admin:policies:read": "access:policy:read",
|
||||||
|
"admin:policies:write": "access:policy:write",
|
||||||
|
"system:tenants:read": "access:tenant:read",
|
||||||
|
"system:tenants:create": "access:tenant:create",
|
||||||
|
"system:tenants:update": "access:tenant:update",
|
||||||
|
"system:tenants:suspend": "access:tenant:suspend",
|
||||||
|
"system:accounts:read": "access:account:read",
|
||||||
|
"system:accounts:create": "access:account:create",
|
||||||
|
"system:accounts:update": "access:account:update",
|
||||||
|
"system:accounts:suspend": "access:account:suspend",
|
||||||
|
"system:roles:read": "access:system_role:read",
|
||||||
|
"system:roles:write": "access:system_role:write",
|
||||||
|
"system:roles:assign": "access:system_role:assign",
|
||||||
|
"system:access:read": "access:system_role:read",
|
||||||
|
"system:access:assign": "access:system_role:assign",
|
||||||
|
"system:audit:read": "access:audit:read",
|
||||||
|
"system:settings:read": "access:system_setting:read",
|
||||||
|
"system:settings:write": "access:system_setting:write",
|
||||||
|
"system:maintenance:access": "access:maintenance:access",
|
||||||
|
"system:governance:read": "access:governance:read",
|
||||||
|
"system:governance:write": "access:governance:write",
|
||||||
"campaign:read": "campaigns:campaign:read",
|
"campaign:read": "campaigns:campaign:read",
|
||||||
"campaign:create": "campaigns:campaign:create",
|
"campaign:create": "campaigns:campaign:create",
|
||||||
"campaign:update": "campaigns:campaign:update",
|
"campaign:update": "campaigns:campaign:update",
|
||||||
@@ -44,14 +80,28 @@ LEGACY_TO_MODULE_SCOPES: dict[str, str] = {
|
|||||||
"mail_servers:manage_credentials": "mail:secret:manage",
|
"mail_servers:manage_credentials": "mail:secret:manage",
|
||||||
}
|
}
|
||||||
|
|
||||||
MODULE_TO_LEGACY_SCOPES = {module: legacy for legacy, module in LEGACY_TO_MODULE_SCOPES.items()}
|
MODULE_TO_LEGACY_SCOPE_ALIASES: dict[str, tuple[str, ...]] = {}
|
||||||
MODULE_TENANT_SCOPES = frozenset(LEGACY_TO_MODULE_SCOPES.values())
|
for legacy_scope, module_scope in LEGACY_TO_MODULE_SCOPES.items():
|
||||||
|
MODULE_TO_LEGACY_SCOPE_ALIASES[module_scope] = (
|
||||||
|
*MODULE_TO_LEGACY_SCOPE_ALIASES.get(module_scope, ()),
|
||||||
|
legacy_scope,
|
||||||
|
)
|
||||||
|
MODULE_TO_LEGACY_SCOPES = {
|
||||||
|
module_scope: aliases[0]
|
||||||
|
for module_scope, aliases in MODULE_TO_LEGACY_SCOPE_ALIASES.items()
|
||||||
|
}
|
||||||
|
MODULE_SYSTEM_SCOPES = frozenset(
|
||||||
|
module
|
||||||
|
for legacy, module in LEGACY_TO_MODULE_SCOPES.items()
|
||||||
|
if legacy.startswith("system:")
|
||||||
|
)
|
||||||
|
MODULE_TENANT_SCOPES = frozenset(LEGACY_TO_MODULE_SCOPES.values()) - MODULE_SYSTEM_SCOPES
|
||||||
|
|
||||||
|
|
||||||
def compatible_required_scopes(required: str) -> tuple[str, ...]:
|
def compatible_required_scopes(required: str) -> tuple[str, ...]:
|
||||||
legacy = MODULE_TO_LEGACY_SCOPES.get(required)
|
legacy_aliases = MODULE_TO_LEGACY_SCOPE_ALIASES.get(required)
|
||||||
if legacy:
|
if legacy_aliases:
|
||||||
return (required, legacy)
|
return (required, *legacy_aliases)
|
||||||
module = LEGACY_TO_MODULE_SCOPES.get(required)
|
module = LEGACY_TO_MODULE_SCOPES.get(required)
|
||||||
if module:
|
if module:
|
||||||
return (required, module)
|
return (required, module)
|
||||||
@@ -60,10 +110,18 @@ def compatible_required_scopes(required: str) -> tuple[str, ...]:
|
|||||||
|
|
||||||
def scopes_grant_compatible(scopes: Iterable[str], required: str) -> bool:
|
def scopes_grant_compatible(scopes: Iterable[str], required: str) -> bool:
|
||||||
granted = list(scopes)
|
granted = list(scopes)
|
||||||
|
if required in MODULE_SYSTEM_SCOPES:
|
||||||
|
return "*" in granted or "system:*" in granted or any(
|
||||||
|
scope != "tenant:*" and scopes_grant([scope], alias)
|
||||||
|
for scope in granted
|
||||||
|
for alias in compatible_required_scopes(required)
|
||||||
|
)
|
||||||
if scopes_grant(granted, required):
|
if scopes_grant(granted, required):
|
||||||
return True
|
return True
|
||||||
if "tenant:*" in granted or "*" in granted:
|
if "tenant:*" in granted or "*" in granted:
|
||||||
if required in MODULE_TENANT_SCOPES:
|
if required in MODULE_TENANT_SCOPES:
|
||||||
return True
|
return True
|
||||||
|
if "system:*" in granted or "*" in granted:
|
||||||
|
if required in MODULE_SYSTEM_SCOPES:
|
||||||
|
return True
|
||||||
return any(scopes_grant(granted, alias) for alias in compatible_required_scopes(required) if alias != required)
|
return any(scopes_grant(granted, alias) for alias in compatible_required_scopes(required) if alias != required)
|
||||||
|
|
||||||
|
|||||||
@@ -76,7 +76,8 @@ from govoplan_core.core.campaigns import (
|
|||||||
CampaignRetentionProvider,
|
CampaignRetentionProvider,
|
||||||
)
|
)
|
||||||
from govoplan_core.core.modules import ModuleContext, ModuleManifest
|
from govoplan_core.core.modules import ModuleContext, ModuleManifest
|
||||||
from govoplan_core.core.organizations import CAPABILITY_ORGANIZATION_DIRECTORY, OrganizationDirectory as CoreOrganizationDirectory, OrganizationFunctionRef as CoreOrganizationFunctionRef
|
from govoplan_core.core.idm import CAPABILITY_IDM_DIRECTORY, OrganizationFunctionAssignmentRef
|
||||||
|
from govoplan_core.core.organizations import CAPABILITY_ORGANIZATION_DIRECTORY, OrganizationDirectory as CoreOrganizationDirectory, OrganizationFunctionRef as CoreOrganizationFunctionRef, OrganizationUnitRef as CoreOrganizationUnitRef
|
||||||
from govoplan_core.core.registry import PlatformRegistry
|
from govoplan_core.core.registry import PlatformRegistry
|
||||||
from govoplan_core.core.runtime import clear_runtime, configure_runtime
|
from govoplan_core.core.runtime import clear_runtime, configure_runtime
|
||||||
from govoplan_core.db.base import Base
|
from govoplan_core.db.base import Base
|
||||||
@@ -85,6 +86,7 @@ from govoplan_core.server.config import GovoplanServerConfig
|
|||||||
from govoplan_core.tenancy.scope import create_scope_tables
|
from govoplan_core.tenancy.scope import create_scope_tables
|
||||||
from govoplan_access.backend.db.models import (
|
from govoplan_access.backend.db.models import (
|
||||||
Account,
|
Account,
|
||||||
|
ExternalFunctionRoleAssignment,
|
||||||
Function,
|
Function,
|
||||||
FunctionAssignment,
|
FunctionAssignment,
|
||||||
FunctionRoleAssignment,
|
FunctionRoleAssignment,
|
||||||
@@ -1031,6 +1033,207 @@ class AccessContractTests(unittest.TestCase):
|
|||||||
clear_runtime()
|
clear_runtime()
|
||||||
shutil.rmtree(root, ignore_errors=True)
|
shutil.rmtree(root, ignore_errors=True)
|
||||||
|
|
||||||
|
def test_access_explanation_includes_idm_function_role_sources(self) -> None:
|
||||||
|
root = Path(tempfile.mkdtemp(prefix="govoplan-access-explanation-"))
|
||||||
|
try:
|
||||||
|
tenant_id = "tenant-access-explanation"
|
||||||
|
account_id = "account-access-explanation"
|
||||||
|
user_id = "user-access-explanation"
|
||||||
|
role_id = "role-access-explanation"
|
||||||
|
function_id = "function-access-explanation"
|
||||||
|
organization_unit_id = "unit-access-explanation"
|
||||||
|
assignment_id = "assignment-access-explanation"
|
||||||
|
identity_id = "identity-access-explanation"
|
||||||
|
|
||||||
|
assignment = OrganizationFunctionAssignmentRef(
|
||||||
|
id=assignment_id,
|
||||||
|
tenant_id=tenant_id,
|
||||||
|
identity_id=identity_id,
|
||||||
|
account_id=account_id,
|
||||||
|
function_id=function_id,
|
||||||
|
organization_unit_id=organization_unit_id,
|
||||||
|
applies_to_subunits=True,
|
||||||
|
source="direct",
|
||||||
|
)
|
||||||
|
|
||||||
|
class FakeIdmDirectory:
|
||||||
|
def get_organization_function_assignment(self, requested_assignment_id: str):
|
||||||
|
return assignment if requested_assignment_id == assignment_id else None
|
||||||
|
|
||||||
|
def organization_function_assignments_for_identity(self, requested_identity_id: str, *, tenant_id: str | None = None):
|
||||||
|
if requested_identity_id == identity_id and tenant_id in (None, assignment.tenant_id):
|
||||||
|
return (assignment,)
|
||||||
|
return ()
|
||||||
|
|
||||||
|
def organization_function_assignments_for_account(self, requested_account_id: str, *, tenant_id: str | None = None):
|
||||||
|
if requested_account_id == account_id and tenant_id in (None, assignment.tenant_id):
|
||||||
|
return (assignment,)
|
||||||
|
return ()
|
||||||
|
|
||||||
|
class FakeOrganizationDirectory(CoreOrganizationDirectory):
|
||||||
|
def get_organization_unit(self, requested_organization_unit_id: str):
|
||||||
|
if requested_organization_unit_id != organization_unit_id:
|
||||||
|
return None
|
||||||
|
return CoreOrganizationUnitRef(
|
||||||
|
id=organization_unit_id,
|
||||||
|
tenant_id=tenant_id,
|
||||||
|
slug="registry",
|
||||||
|
name="Registry Office",
|
||||||
|
)
|
||||||
|
|
||||||
|
def organization_units_for_tenant(self, requested_tenant_id: str):
|
||||||
|
if requested_tenant_id != tenant_id:
|
||||||
|
return ()
|
||||||
|
unit = self.get_organization_unit(organization_unit_id)
|
||||||
|
return (unit,) if unit is not None else ()
|
||||||
|
|
||||||
|
def get_function(self, requested_function_id: str):
|
||||||
|
if requested_function_id != function_id:
|
||||||
|
return None
|
||||||
|
return CoreOrganizationFunctionRef(
|
||||||
|
id=function_id,
|
||||||
|
tenant_id=tenant_id,
|
||||||
|
organization_unit_id=organization_unit_id,
|
||||||
|
slug="registry-clerk",
|
||||||
|
name="Registry Clerk",
|
||||||
|
)
|
||||||
|
|
||||||
|
def functions_for_organization_unit(self, requested_organization_unit_id: str, *, include_subunits: bool = False):
|
||||||
|
del include_subunits
|
||||||
|
if requested_organization_unit_id != organization_unit_id:
|
||||||
|
return ()
|
||||||
|
function = self.get_function(function_id)
|
||||||
|
return (function,) if function is not None else ()
|
||||||
|
|
||||||
|
idm_directory = FakeIdmDirectory()
|
||||||
|
organization_directory = FakeOrganizationDirectory()
|
||||||
|
|
||||||
|
with temporary_database(f"sqlite:///{root / 'test.db'}") as database:
|
||||||
|
create_scope_tables(database.engine)
|
||||||
|
Base.metadata.create_all(bind=database.engine)
|
||||||
|
with database.session() as session:
|
||||||
|
tenant = Tenant(id=tenant_id, slug="access-explanation", name="Access Explanation", settings={})
|
||||||
|
account = Account(
|
||||||
|
id=account_id,
|
||||||
|
email="explanation@example.test",
|
||||||
|
normalized_email="explanation@example.test",
|
||||||
|
display_name="Access Explanation User",
|
||||||
|
)
|
||||||
|
user = User(
|
||||||
|
id=user_id,
|
||||||
|
tenant_id=tenant_id,
|
||||||
|
account_id=account_id,
|
||||||
|
email=account.email,
|
||||||
|
display_name=account.display_name,
|
||||||
|
settings={},
|
||||||
|
mail_profile_policy={},
|
||||||
|
)
|
||||||
|
role = Role(
|
||||||
|
id=role_id,
|
||||||
|
tenant_id=tenant_id,
|
||||||
|
slug="file-reader",
|
||||||
|
name="File Reader",
|
||||||
|
permissions=["files:file:read"],
|
||||||
|
)
|
||||||
|
mapping = ExternalFunctionRoleAssignment(
|
||||||
|
tenant_id=tenant_id,
|
||||||
|
source_module="organizations",
|
||||||
|
function_id=function_id,
|
||||||
|
role_id=role_id,
|
||||||
|
)
|
||||||
|
session.add_all([tenant, account, user, role, mapping])
|
||||||
|
session.commit()
|
||||||
|
|
||||||
|
from govoplan_access.backend.explanation import SqlAccessExplanationService, build_user_access_explanation
|
||||||
|
|
||||||
|
explanation = build_user_access_explanation(
|
||||||
|
session,
|
||||||
|
user,
|
||||||
|
idm_directory=idm_directory,
|
||||||
|
organization_directory=organization_directory,
|
||||||
|
)
|
||||||
|
|
||||||
|
self.assertEqual(1, len(explanation.function_facts))
|
||||||
|
self.assertEqual("Registry Clerk", explanation.function_facts[0].function_name)
|
||||||
|
self.assertEqual((role_id,), explanation.function_facts[0].role_ids)
|
||||||
|
source = next(item for item in explanation.role_sources if item.source_type == "idm_function_role")
|
||||||
|
self.assertEqual("Registry Clerk", source.function_name)
|
||||||
|
self.assertEqual("File Reader", source.role_name)
|
||||||
|
scope = next(item for item in explanation.scopes if item.scope == "files:file:read")
|
||||||
|
self.assertEqual(["idm_function_role"], [item.source_type for item in scope.sources])
|
||||||
|
|
||||||
|
service = SqlAccessExplanationService(
|
||||||
|
idm_directory=idm_directory,
|
||||||
|
organization_directory=organization_directory,
|
||||||
|
)
|
||||||
|
provenance = service.explain_scope_provenance(
|
||||||
|
PrincipalRef(
|
||||||
|
account_id=account_id,
|
||||||
|
membership_id=user_id,
|
||||||
|
tenant_id=tenant_id,
|
||||||
|
identity_id=identity_id,
|
||||||
|
scopes=frozenset({"files:file:read"}),
|
||||||
|
),
|
||||||
|
"files:file:read",
|
||||||
|
)
|
||||||
|
self.assertTrue(any(item.kind == "function" and item.label == "Registry Clerk" for item in provenance))
|
||||||
|
self.assertTrue(any(item.kind == "role" and item.label == "File Reader" for item in provenance))
|
||||||
|
|
||||||
|
registry = PlatformRegistry()
|
||||||
|
registry.register(
|
||||||
|
ModuleManifest(
|
||||||
|
id="idm",
|
||||||
|
name="IDM",
|
||||||
|
version="test",
|
||||||
|
capability_factories={CAPABILITY_IDM_DIRECTORY: lambda context: idm_directory},
|
||||||
|
)
|
||||||
|
)
|
||||||
|
registry.register(
|
||||||
|
ModuleManifest(
|
||||||
|
id="organizations",
|
||||||
|
name="Organizations",
|
||||||
|
version="test",
|
||||||
|
capability_factories={CAPABILITY_ORGANIZATION_DIRECTORY: lambda context: organization_directory},
|
||||||
|
)
|
||||||
|
)
|
||||||
|
context = ModuleContext(registry=registry, settings=object())
|
||||||
|
registry.configure_capability_context(context)
|
||||||
|
configure_runtime(context)
|
||||||
|
|
||||||
|
from govoplan_access.backend.api.v1.routes import router as admin_router
|
||||||
|
|
||||||
|
app = FastAPI()
|
||||||
|
app.include_router(admin_router)
|
||||||
|
|
||||||
|
def principal_override() -> ApiPrincipal:
|
||||||
|
with database.session() as session:
|
||||||
|
account = session.get(Account, account_id)
|
||||||
|
user = session.get(User, user_id)
|
||||||
|
assert account is not None
|
||||||
|
assert user is not None
|
||||||
|
return ApiPrincipal(
|
||||||
|
principal=PrincipalRef(
|
||||||
|
account_id=account_id,
|
||||||
|
membership_id=user_id,
|
||||||
|
tenant_id=tenant_id,
|
||||||
|
scopes=frozenset({"admin:users:read"}),
|
||||||
|
),
|
||||||
|
account=account,
|
||||||
|
user=user,
|
||||||
|
)
|
||||||
|
|
||||||
|
app.dependency_overrides[get_api_principal] = principal_override
|
||||||
|
with TestClient(app) as client:
|
||||||
|
response = client.get(f"/admin/users/{user_id}/access-explanation")
|
||||||
|
self.assertEqual(200, response.status_code, response.text)
|
||||||
|
payload = response.json()
|
||||||
|
self.assertIn("files:file:read", payload["user"]["effective_scopes"])
|
||||||
|
self.assertEqual("idm_function_role", payload["role_sources"][0]["source_type"])
|
||||||
|
self.assertEqual("Registry Clerk", payload["function_facts"][0]["function_name"])
|
||||||
|
finally:
|
||||||
|
clear_runtime()
|
||||||
|
shutil.rmtree(root, ignore_errors=True)
|
||||||
|
|
||||||
def test_api_principal_dependency_uses_access_resolver_capability(self) -> None:
|
def test_api_principal_dependency_uses_access_resolver_capability(self) -> None:
|
||||||
root = Path(tempfile.mkdtemp(prefix="govoplan-access-contract-"))
|
root = Path(tempfile.mkdtemp(prefix="govoplan-access-contract-"))
|
||||||
try:
|
try:
|
||||||
|
|||||||
@@ -33,13 +33,14 @@ from govoplan_core.db.bootstrap import bootstrap_dev_data
|
|||||||
from govoplan_core.db.session import configure_database, set_database
|
from govoplan_core.db.session import configure_database, set_database
|
||||||
from govoplan_core.core.change_sequence import decode_sequence_watermark, prune_sequence_entries
|
from govoplan_core.core.change_sequence import decode_sequence_watermark, prune_sequence_entries
|
||||||
from govoplan_core.core.pagination import encode_keyset_cursor, keyset_query_fingerprint
|
from govoplan_core.core.pagination import encode_keyset_cursor, keyset_query_fingerprint
|
||||||
|
from govoplan_core.tenancy.scope import create_scope_tables, scope_registry
|
||||||
|
from govoplan_access.backend.permissions.catalog import permission_catalog as access_permission_catalog
|
||||||
|
|
||||||
_database = configure_database(os.environ["DATABASE_URL"])
|
_database = configure_database(os.environ["DATABASE_URL"])
|
||||||
engine = _database.engine
|
engine = _database.engine
|
||||||
SessionLocal = _database.SessionLocal
|
SessionLocal = _database.SessionLocal
|
||||||
|
|
||||||
from govoplan_core.server.app import app
|
from govoplan_core.server.app import app
|
||||||
from govoplan_core.security.permissions import SYSTEM_SCOPES
|
|
||||||
|
|
||||||
|
|
||||||
class ApiSmokeTests(unittest.TestCase):
|
class ApiSmokeTests(unittest.TestCase):
|
||||||
@@ -56,7 +57,9 @@ class ApiSmokeTests(unittest.TestCase):
|
|||||||
def setUp(self) -> None:
|
def setUp(self) -> None:
|
||||||
set_database(_database)
|
set_database(_database)
|
||||||
self.client.cookies.clear()
|
self.client.cookies.clear()
|
||||||
|
scope_registry.metadata.drop_all(bind=engine)
|
||||||
Base.metadata.drop_all(bind=engine)
|
Base.metadata.drop_all(bind=engine)
|
||||||
|
create_scope_tables(engine)
|
||||||
Base.metadata.create_all(bind=engine)
|
Base.metadata.create_all(bind=engine)
|
||||||
shutil.rmtree(_TEST_ROOT / "files", ignore_errors=True)
|
shutil.rmtree(_TEST_ROOT / "files", ignore_errors=True)
|
||||||
shutil.rmtree(_TEST_ROOT / "mock-mailbox", ignore_errors=True)
|
shutil.rmtree(_TEST_ROOT / "mock-mailbox", ignore_errors=True)
|
||||||
@@ -5972,7 +5975,12 @@ class ApiSmokeTests(unittest.TestCase):
|
|||||||
auditor = next(item for item in roles.json()["roles"] if item["slug"] == "system_auditor")
|
auditor = next(item for item in roles.json()["roles"] if item["slug"] == "system_auditor")
|
||||||
self.assertTrue(owner["is_builtin"])
|
self.assertTrue(owner["is_builtin"])
|
||||||
self.assertEqual(owner["user_assignments"], 1)
|
self.assertEqual(owner["user_assignments"], 1)
|
||||||
self.assertEqual(owner["effective_permission_count"], len(SYSTEM_SCOPES))
|
expected_system_permission_count = sum(
|
||||||
|
1
|
||||||
|
for permission in access_permission_catalog(include_legacy=False)
|
||||||
|
if permission.level == "system"
|
||||||
|
)
|
||||||
|
self.assertEqual(owner["effective_permission_count"], expected_system_permission_count)
|
||||||
self.assertEqual(owner["permissions"], ["system:*"])
|
self.assertEqual(owner["permissions"], ["system:*"])
|
||||||
self.assertFalse(administrator["is_builtin"])
|
self.assertFalse(administrator["is_builtin"])
|
||||||
self.assertFalse(auditor["is_builtin"])
|
self.assertFalse(auditor["is_builtin"])
|
||||||
|
|||||||
@@ -265,6 +265,18 @@ class ModuleSystemTests(unittest.TestCase):
|
|||||||
self.assertTrue(scope_grants("tenant:*", "calendar:event:read"))
|
self.assertTrue(scope_grants("tenant:*", "calendar:event:read"))
|
||||||
self.assertTrue(scopes_grant_compatible(["tenant:*"], "calendar:event:read"))
|
self.assertTrue(scopes_grant_compatible(["tenant:*"], "calendar:event:read"))
|
||||||
self.assertFalse(scope_grants("tenant:*", "system:settings:read"))
|
self.assertFalse(scope_grants("tenant:*", "system:settings:read"))
|
||||||
|
self.assertTrue(scopes_grant_compatible(["access:membership:read"], "admin:users:read"))
|
||||||
|
self.assertTrue(scopes_grant_compatible(["admin:users:read"], "access:membership:read"))
|
||||||
|
self.assertTrue(scopes_grant_compatible(["access:tenant:read"], "system:tenants:read"))
|
||||||
|
self.assertTrue(scopes_grant_compatible(["system:*"], "access:tenant:read"))
|
||||||
|
self.assertFalse(scopes_grant_compatible(["tenant:*"], "access:tenant:read"))
|
||||||
|
|
||||||
|
def test_core_webui_retired_legacy_admin_api_surface(self) -> None:
|
||||||
|
webui_src = Path(__file__).resolve().parents[1] / "webui" / "src"
|
||||||
|
self.assertFalse((webui_src / "api" / "admin.ts").exists())
|
||||||
|
for path in webui_src.rglob("*.ts*"):
|
||||||
|
with self.subTest(path=path.relative_to(webui_src)):
|
||||||
|
self.assertNotIn("api/admin", path.read_text(encoding="utf-8"))
|
||||||
|
|
||||||
def test_platform_modules_own_live_legacy_model_tables(self) -> None:
|
def test_platform_modules_own_live_legacy_model_tables(self) -> None:
|
||||||
from govoplan_admin.backend.db.models import GovernanceTemplate
|
from govoplan_admin.backend.db.models import GovernanceTemplate
|
||||||
@@ -784,6 +796,26 @@ finally:
|
|||||||
self.assertEqual("stable", item.catalog["channel"] if item.catalog else None)
|
self.assertEqual("stable", item.catalog["channel"] if item.catalog else None)
|
||||||
self.assertEqual(8, item.as_dict()["catalog"]["sequence"])
|
self.assertEqual(8, item.as_dict()["catalog"]["sequence"])
|
||||||
|
|
||||||
|
def test_module_install_plan_update_uses_package_install_commands(self) -> None:
|
||||||
|
item = normalize_module_install_plan_item({
|
||||||
|
"module_id": "files",
|
||||||
|
"action": "update",
|
||||||
|
"python_package": "govoplan-files",
|
||||||
|
"python_ref": "govoplan-files==0.2.0",
|
||||||
|
"webui_package": "@govoplan/files-webui",
|
||||||
|
"webui_ref": "git+ssh://git.example.invalid/add-ideas/govoplan-files.git#v0.2.0",
|
||||||
|
"data_safety_acknowledged": True,
|
||||||
|
})
|
||||||
|
plan = ModuleInstallPlan(items=(item,))
|
||||||
|
|
||||||
|
self.assertEqual("update", item.action)
|
||||||
|
self.assertTrue(item.data_safety_acknowledged)
|
||||||
|
self.assertTrue(item.as_dict()["data_safety_acknowledged"])
|
||||||
|
self.assertEqual((
|
||||||
|
"python -m pip install govoplan-files==0.2.0",
|
||||||
|
"npm pkg set 'dependencies.@govoplan/files-webui=git+ssh://git.example.invalid/add-ideas/govoplan-files.git#v0.2.0' && npm install",
|
||||||
|
), module_install_plan_commands(plan))
|
||||||
|
|
||||||
def test_admin_catalog_install_plan_records_catalog_provenance(self) -> None:
|
def test_admin_catalog_install_plan_records_catalog_provenance(self) -> None:
|
||||||
app, _settings_obj = self._app_for_modules(("tenancy", "admin"))
|
app, _settings_obj = self._app_for_modules(("tenancy", "admin"))
|
||||||
database = get_database()
|
database = get_database()
|
||||||
@@ -832,6 +864,50 @@ finally:
|
|||||||
self.assertEqual(11, item["catalog"]["sequence"])
|
self.assertEqual(11, item["catalog"]["sequence"])
|
||||||
self.assertEqual(str(catalog_path), item["catalog"]["source"])
|
self.assertEqual(str(catalog_path), item["catalog"]["source"])
|
||||||
|
|
||||||
|
def test_admin_catalog_install_plan_marks_installed_module_as_update(self) -> None:
|
||||||
|
app, _settings_obj = self._app_for_modules(("tenancy", "admin", "files"))
|
||||||
|
database = get_database()
|
||||||
|
create_scope_tables(database.engine)
|
||||||
|
Base.metadata.create_all(bind=database.engine)
|
||||||
|
with database.session() as session:
|
||||||
|
bootstrap_dev_data(session, user_password="test-admin")
|
||||||
|
|
||||||
|
root = Path(tempfile.mkdtemp(prefix="govoplan-admin-module-catalog-update-", dir=_TEST_ROOT))
|
||||||
|
catalog_path = root / "catalog.json"
|
||||||
|
catalog_path.write_text(json.dumps({
|
||||||
|
"channel": "stable",
|
||||||
|
"sequence": 12,
|
||||||
|
"generated_at": "2026-07-10T00:00:00Z",
|
||||||
|
"modules": [{
|
||||||
|
"module_id": "files",
|
||||||
|
"name": "Files",
|
||||||
|
"version": "0.2.0",
|
||||||
|
"action": "install",
|
||||||
|
"python_package": "govoplan-files",
|
||||||
|
"python_ref": "govoplan-files==0.2.0",
|
||||||
|
}],
|
||||||
|
}), encoding="utf-8")
|
||||||
|
|
||||||
|
with TestClient(app) as client:
|
||||||
|
login = client.post(
|
||||||
|
"/api/v1/auth/login",
|
||||||
|
json={"email": "admin@example.local", "password": "test-admin"},
|
||||||
|
)
|
||||||
|
self.assertEqual(200, login.status_code, login.text)
|
||||||
|
headers = {"Authorization": f"Bearer {login.json()['access_token']}"}
|
||||||
|
with patch.dict(os.environ, {
|
||||||
|
"GOVOPLAN_MODULE_PACKAGE_CATALOG": str(catalog_path),
|
||||||
|
"GOVOPLAN_MODULE_PACKAGE_CATALOG_REQUIRE_SIGNATURE": "false",
|
||||||
|
"GOVOPLAN_MODULE_PACKAGE_CATALOG_APPROVED_CHANNELS": "",
|
||||||
|
}):
|
||||||
|
planned = client.post("/api/v1/admin/system/modules/install-plan/catalog/files", headers=headers)
|
||||||
|
|
||||||
|
self.assertEqual(200, planned.status_code, planned.text)
|
||||||
|
item = planned.json()["items"][0]
|
||||||
|
self.assertEqual("files", item["module_id"])
|
||||||
|
self.assertEqual("update", item["action"])
|
||||||
|
self.assertEqual("catalog", item["source"])
|
||||||
|
|
||||||
def test_module_install_plan_rejects_local_dependency_refs(self) -> None:
|
def test_module_install_plan_rejects_local_dependency_refs(self) -> None:
|
||||||
root = Path(tempfile.mkdtemp(prefix="govoplan-module-install-plan-local-", dir=_TEST_ROOT))
|
root = Path(tempfile.mkdtemp(prefix="govoplan-module-install-plan-local-", dir=_TEST_ROOT))
|
||||||
settings = _settings(root)
|
settings = _settings(root)
|
||||||
@@ -1097,6 +1173,345 @@ finally:
|
|||||||
blockers = {issue.code for issue in preflight.issues if issue.severity == "blocker"}
|
blockers = {issue.code for issue in preflight.issues if issue.severity == "blocker"}
|
||||||
self.assertIn("catalog_required_interface_missing", blockers)
|
self.assertIn("catalog_required_interface_missing", blockers)
|
||||||
|
|
||||||
|
def test_module_installer_preflight_requires_companion_catalog_provider_update(self) -> None:
|
||||||
|
available = {
|
||||||
|
"files": ModuleManifest(
|
||||||
|
id="files",
|
||||||
|
name="Files",
|
||||||
|
version="1.0.0",
|
||||||
|
provides_interfaces=(ModuleInterfaceProvider(name="files.attachments", version="1.0.0"),),
|
||||||
|
),
|
||||||
|
"campaigns": ModuleManifest(id="campaigns", name="Campaigns", version="1.0.0"),
|
||||||
|
}
|
||||||
|
plan = ModuleInstallPlan(items=(
|
||||||
|
ModuleInstallPlanItem(
|
||||||
|
module_id="campaigns",
|
||||||
|
action="update",
|
||||||
|
source="catalog",
|
||||||
|
python_package="govoplan-campaign",
|
||||||
|
python_ref="govoplan-campaign==2.0.0",
|
||||||
|
),
|
||||||
|
))
|
||||||
|
|
||||||
|
with patch(
|
||||||
|
"govoplan_core.core.module_package_catalog.validate_module_package_catalog",
|
||||||
|
return_value={
|
||||||
|
"configured": True,
|
||||||
|
"valid": True,
|
||||||
|
"warnings": [],
|
||||||
|
"modules": [
|
||||||
|
{
|
||||||
|
"module_id": "files",
|
||||||
|
"provides_interfaces": [{"name": "files.attachments", "version": "2.0.0"}],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"module_id": "campaigns",
|
||||||
|
"requires_interfaces": [{
|
||||||
|
"name": "files.attachments",
|
||||||
|
"version_min": "2.0.0",
|
||||||
|
"version_max_exclusive": "3.0.0",
|
||||||
|
}],
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
):
|
||||||
|
preflight = module_install_preflight(
|
||||||
|
plan=plan,
|
||||||
|
available=available,
|
||||||
|
current_enabled=(),
|
||||||
|
desired_enabled=(),
|
||||||
|
maintenance_mode=True,
|
||||||
|
)
|
||||||
|
|
||||||
|
self.assertFalse(preflight.allowed)
|
||||||
|
companion_issues = [issue for issue in preflight.issues if issue.code == "companion_update_required"]
|
||||||
|
self.assertTrue(companion_issues)
|
||||||
|
self.assertTrue(any("files" in issue.message for issue in companion_issues))
|
||||||
|
|
||||||
|
def test_module_installer_preflight_blocks_unacknowledged_forward_only_catalog_update(self) -> None:
|
||||||
|
available = {"files": ModuleManifest(id="files", name="Files", version="1.0.0")}
|
||||||
|
plan = ModuleInstallPlan(items=(
|
||||||
|
ModuleInstallPlanItem(
|
||||||
|
module_id="files",
|
||||||
|
action="update",
|
||||||
|
source="catalog",
|
||||||
|
python_package="govoplan-files",
|
||||||
|
python_ref="govoplan-files==2.0.0",
|
||||||
|
),
|
||||||
|
))
|
||||||
|
|
||||||
|
with patch(
|
||||||
|
"govoplan_core.core.module_package_catalog.validate_module_package_catalog",
|
||||||
|
return_value={
|
||||||
|
"configured": True,
|
||||||
|
"valid": True,
|
||||||
|
"warnings": [],
|
||||||
|
"modules": [{
|
||||||
|
"module_id": "files",
|
||||||
|
"version": "2.0.0",
|
||||||
|
"migration_safety": "forward_only",
|
||||||
|
"migration_notes": "Database rollback requires restoring the pre-update snapshot.",
|
||||||
|
}],
|
||||||
|
},
|
||||||
|
):
|
||||||
|
preflight = module_install_preflight(
|
||||||
|
plan=plan,
|
||||||
|
available=available,
|
||||||
|
current_enabled=(),
|
||||||
|
desired_enabled=(),
|
||||||
|
maintenance_mode=True,
|
||||||
|
)
|
||||||
|
|
||||||
|
self.assertFalse(preflight.allowed)
|
||||||
|
blockers = {issue.code for issue in preflight.issues if issue.severity == "blocker"}
|
||||||
|
self.assertIn("forward_only_migration_acknowledgement_required", blockers)
|
||||||
|
|
||||||
|
def test_module_installer_preflight_allows_acknowledged_forward_only_catalog_update(self) -> None:
|
||||||
|
available = {"files": ModuleManifest(id="files", name="Files", version="1.0.0")}
|
||||||
|
plan = ModuleInstallPlan(items=(
|
||||||
|
ModuleInstallPlanItem(
|
||||||
|
module_id="files",
|
||||||
|
action="update",
|
||||||
|
source="catalog",
|
||||||
|
python_package="govoplan-files",
|
||||||
|
python_ref="govoplan-files==2.0.0",
|
||||||
|
data_safety_acknowledged=True,
|
||||||
|
),
|
||||||
|
))
|
||||||
|
|
||||||
|
with patch(
|
||||||
|
"govoplan_core.core.module_package_catalog.validate_module_package_catalog",
|
||||||
|
return_value={
|
||||||
|
"configured": True,
|
||||||
|
"valid": True,
|
||||||
|
"warnings": [],
|
||||||
|
"modules": [{
|
||||||
|
"module_id": "files",
|
||||||
|
"version": "2.0.0",
|
||||||
|
"migration_safety": "forward_only",
|
||||||
|
"migration_notes": "Database rollback requires restoring the pre-update snapshot.",
|
||||||
|
}],
|
||||||
|
},
|
||||||
|
):
|
||||||
|
preflight = module_install_preflight(
|
||||||
|
plan=plan,
|
||||||
|
available=available,
|
||||||
|
current_enabled=(),
|
||||||
|
desired_enabled=(),
|
||||||
|
maintenance_mode=True,
|
||||||
|
)
|
||||||
|
|
||||||
|
self.assertTrue(preflight.allowed)
|
||||||
|
self.assertEqual(("files",), tuple(item.module_id for item in preflight.target_plan))
|
||||||
|
self.assertEqual("1.0.0", preflight.target_plan[0].current_version)
|
||||||
|
self.assertEqual("2.0.0", preflight.target_plan[0].target_version)
|
||||||
|
self.assertEqual("forward_only", preflight.target_plan[0].migration_safety)
|
||||||
|
self.assertTrue(preflight.target_plan[0].data_safety_acknowledged)
|
||||||
|
self.assertEqual("2.0.0", preflight.as_dict()["target_plan"][0]["target_version"])
|
||||||
|
warnings = {issue.code for issue in preflight.issues if issue.severity == "warning"}
|
||||||
|
self.assertIn("forward_only_migration_acknowledged", warnings)
|
||||||
|
|
||||||
|
def test_module_installer_preflight_requires_destructive_catalog_update_plan(self) -> None:
|
||||||
|
available = {"files": ModuleManifest(id="files", name="Files", version="1.0.0")}
|
||||||
|
plan = ModuleInstallPlan(items=(
|
||||||
|
ModuleInstallPlanItem(
|
||||||
|
module_id="files",
|
||||||
|
action="update",
|
||||||
|
source="catalog",
|
||||||
|
python_package="govoplan-files",
|
||||||
|
python_ref="govoplan-files==2.0.0",
|
||||||
|
data_safety_acknowledged=True,
|
||||||
|
),
|
||||||
|
))
|
||||||
|
|
||||||
|
with patch(
|
||||||
|
"govoplan_core.core.module_package_catalog.validate_module_package_catalog",
|
||||||
|
return_value={
|
||||||
|
"configured": True,
|
||||||
|
"valid": True,
|
||||||
|
"warnings": [],
|
||||||
|
"modules": [{
|
||||||
|
"module_id": "files",
|
||||||
|
"migration_safety": "destructive",
|
||||||
|
}],
|
||||||
|
},
|
||||||
|
):
|
||||||
|
preflight = module_install_preflight(
|
||||||
|
plan=plan,
|
||||||
|
available=available,
|
||||||
|
current_enabled=(),
|
||||||
|
desired_enabled=(),
|
||||||
|
maintenance_mode=True,
|
||||||
|
)
|
||||||
|
|
||||||
|
self.assertFalse(preflight.allowed)
|
||||||
|
blockers = {issue.code for issue in preflight.issues if issue.severity == "blocker"}
|
||||||
|
self.assertIn("destructive_migration_plan_required", blockers)
|
||||||
|
|
||||||
|
def test_module_installer_preflight_allows_acknowledged_destructive_catalog_update_with_plan(self) -> None:
|
||||||
|
available = {"files": ModuleManifest(id="files", name="Files", version="1.0.0")}
|
||||||
|
plan = ModuleInstallPlan(items=(
|
||||||
|
ModuleInstallPlanItem(
|
||||||
|
module_id="files",
|
||||||
|
action="update",
|
||||||
|
source="catalog",
|
||||||
|
python_package="govoplan-files",
|
||||||
|
python_ref="govoplan-files==2.0.0",
|
||||||
|
data_safety_acknowledged=True,
|
||||||
|
),
|
||||||
|
))
|
||||||
|
|
||||||
|
with patch(
|
||||||
|
"govoplan_core.core.module_package_catalog.validate_module_package_catalog",
|
||||||
|
return_value={
|
||||||
|
"configured": True,
|
||||||
|
"valid": True,
|
||||||
|
"warnings": [],
|
||||||
|
"modules": [{
|
||||||
|
"module_id": "files",
|
||||||
|
"migration_safety": "destructive",
|
||||||
|
"migration_notes": "Drops obsolete cache tables after exporting the retained documents.",
|
||||||
|
}],
|
||||||
|
},
|
||||||
|
):
|
||||||
|
preflight = module_install_preflight(
|
||||||
|
plan=plan,
|
||||||
|
available=available,
|
||||||
|
current_enabled=(),
|
||||||
|
desired_enabled=(),
|
||||||
|
maintenance_mode=True,
|
||||||
|
)
|
||||||
|
|
||||||
|
self.assertTrue(preflight.allowed)
|
||||||
|
warnings = {issue.code for issue in preflight.issues if issue.severity == "warning"}
|
||||||
|
self.assertIn("destructive_migration_acknowledged", warnings)
|
||||||
|
|
||||||
|
def test_module_installer_preflight_allows_companion_updates_in_same_target_set(self) -> None:
|
||||||
|
available = {
|
||||||
|
"files": ModuleManifest(
|
||||||
|
id="files",
|
||||||
|
name="Files",
|
||||||
|
version="1.0.0",
|
||||||
|
provides_interfaces=(ModuleInterfaceProvider(name="files.attachments", version="1.0.0"),),
|
||||||
|
),
|
||||||
|
"campaigns": ModuleManifest(id="campaigns", name="Campaigns", version="1.0.0"),
|
||||||
|
}
|
||||||
|
plan = ModuleInstallPlan(items=(
|
||||||
|
ModuleInstallPlanItem(
|
||||||
|
module_id="files",
|
||||||
|
action="update",
|
||||||
|
source="catalog",
|
||||||
|
python_package="govoplan-files",
|
||||||
|
python_ref="govoplan-files==2.0.0",
|
||||||
|
),
|
||||||
|
ModuleInstallPlanItem(
|
||||||
|
module_id="campaigns",
|
||||||
|
action="update",
|
||||||
|
source="catalog",
|
||||||
|
python_package="govoplan-campaign",
|
||||||
|
python_ref="govoplan-campaign==2.0.0",
|
||||||
|
),
|
||||||
|
))
|
||||||
|
|
||||||
|
with patch(
|
||||||
|
"govoplan_core.core.module_package_catalog.validate_module_package_catalog",
|
||||||
|
return_value={
|
||||||
|
"configured": True,
|
||||||
|
"valid": True,
|
||||||
|
"warnings": [],
|
||||||
|
"modules": [
|
||||||
|
{
|
||||||
|
"module_id": "files",
|
||||||
|
"provides_interfaces": [{"name": "files.attachments", "version": "2.0.0"}],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"module_id": "campaigns",
|
||||||
|
"requires_interfaces": [{
|
||||||
|
"name": "files.attachments",
|
||||||
|
"version_min": "2.0.0",
|
||||||
|
"version_max_exclusive": "3.0.0",
|
||||||
|
}],
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
):
|
||||||
|
preflight = module_install_preflight(
|
||||||
|
plan=plan,
|
||||||
|
available=available,
|
||||||
|
current_enabled=(),
|
||||||
|
desired_enabled=(),
|
||||||
|
maintenance_mode=True,
|
||||||
|
)
|
||||||
|
|
||||||
|
self.assertTrue(preflight.allowed)
|
||||||
|
self.assertNotIn("companion_update_required", {issue.code for issue in preflight.issues})
|
||||||
|
|
||||||
|
def test_module_installer_preflight_blocks_provider_update_that_breaks_installed_consumer(self) -> None:
|
||||||
|
available = {
|
||||||
|
"files": ModuleManifest(
|
||||||
|
id="files",
|
||||||
|
name="Files",
|
||||||
|
version="1.0.0",
|
||||||
|
provides_interfaces=(ModuleInterfaceProvider(name="files.attachments", version="1.0.0"),),
|
||||||
|
),
|
||||||
|
"campaigns": ModuleManifest(
|
||||||
|
id="campaigns",
|
||||||
|
name="Campaigns",
|
||||||
|
version="1.0.0",
|
||||||
|
requires_interfaces=(
|
||||||
|
ModuleInterfaceRequirement(
|
||||||
|
name="files.attachments",
|
||||||
|
version_min="1.0.0",
|
||||||
|
version_max_exclusive="2.0.0",
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
}
|
||||||
|
plan = ModuleInstallPlan(items=(
|
||||||
|
ModuleInstallPlanItem(
|
||||||
|
module_id="files",
|
||||||
|
action="update",
|
||||||
|
source="catalog",
|
||||||
|
python_package="govoplan-files",
|
||||||
|
python_ref="govoplan-files==2.0.0",
|
||||||
|
),
|
||||||
|
))
|
||||||
|
|
||||||
|
with patch(
|
||||||
|
"govoplan_core.core.module_package_catalog.validate_module_package_catalog",
|
||||||
|
return_value={
|
||||||
|
"configured": True,
|
||||||
|
"valid": True,
|
||||||
|
"warnings": [],
|
||||||
|
"modules": [
|
||||||
|
{
|
||||||
|
"module_id": "files",
|
||||||
|
"provides_interfaces": [{"name": "files.attachments", "version": "2.0.0"}],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"module_id": "campaigns",
|
||||||
|
"requires_interfaces": [{
|
||||||
|
"name": "files.attachments",
|
||||||
|
"version_min": "2.0.0",
|
||||||
|
"version_max_exclusive": "3.0.0",
|
||||||
|
}],
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
):
|
||||||
|
preflight = module_install_preflight(
|
||||||
|
plan=plan,
|
||||||
|
available=available,
|
||||||
|
current_enabled=(),
|
||||||
|
desired_enabled=(),
|
||||||
|
maintenance_mode=True,
|
||||||
|
)
|
||||||
|
|
||||||
|
self.assertFalse(preflight.allowed)
|
||||||
|
companion_issues = [issue for issue in preflight.issues if issue.code == "companion_update_required"]
|
||||||
|
self.assertTrue(companion_issues)
|
||||||
|
self.assertTrue(any("campaigns" in issue.message for issue in companion_issues))
|
||||||
|
|
||||||
def test_module_installer_reports_interface_contract_issues(self) -> None:
|
def test_module_installer_reports_interface_contract_issues(self) -> None:
|
||||||
available = {
|
available = {
|
||||||
"files": ModuleManifest(
|
"files": ModuleManifest(
|
||||||
@@ -1736,6 +2151,10 @@ finally:
|
|||||||
"version": "0.1.4",
|
"version": "0.1.4",
|
||||||
"python_package": "govoplan-files",
|
"python_package": "govoplan-files",
|
||||||
"python_ref": "govoplan-files==0.1.4",
|
"python_ref": "govoplan-files==0.1.4",
|
||||||
|
"dependencies": ["access"],
|
||||||
|
"optional_dependencies": ["mail"],
|
||||||
|
"migration_safety": "forward_only",
|
||||||
|
"migration_notes": "Requires a tested backup restore path.",
|
||||||
"provides_interfaces": [{"name": "files.spaces", "version": "1.2.0"}],
|
"provides_interfaces": [{"name": "files.spaces", "version": "1.2.0"}],
|
||||||
"requires_interfaces": [{"name": "access.directory", "version_min": "1.0.0", "optional": True}],
|
"requires_interfaces": [{"name": "access.directory", "version_min": "1.0.0", "optional": True}],
|
||||||
"artifact_integrity": {
|
"artifact_integrity": {
|
||||||
@@ -1754,6 +2173,10 @@ finally:
|
|||||||
|
|
||||||
self.assertEqual("files", catalog[0]["module_id"])
|
self.assertEqual("files", catalog[0]["module_id"])
|
||||||
self.assertEqual("install", catalog[0]["action"])
|
self.assertEqual("install", catalog[0]["action"])
|
||||||
|
self.assertEqual(["access"], catalog[0]["dependencies"])
|
||||||
|
self.assertEqual(["mail"], catalog[0]["optional_dependencies"])
|
||||||
|
self.assertEqual("forward_only", catalog[0]["migration_safety"])
|
||||||
|
self.assertEqual("Requires a tested backup restore path.", catalog[0]["migration_notes"])
|
||||||
self.assertEqual(["official"], catalog[0]["tags"])
|
self.assertEqual(["official"], catalog[0]["tags"])
|
||||||
self.assertEqual([{"name": "files.spaces", "version": "1.2.0"}], catalog[0]["provides_interfaces"])
|
self.assertEqual([{"name": "files.spaces", "version": "1.2.0"}], catalog[0]["provides_interfaces"])
|
||||||
self.assertEqual(
|
self.assertEqual(
|
||||||
@@ -1807,6 +2230,21 @@ finally:
|
|||||||
self.assertFalse(validation["valid"])
|
self.assertFalse(validation["valid"])
|
||||||
self.assertIn("module_id", str(validation["error"]))
|
self.assertIn("module_id", str(validation["error"]))
|
||||||
|
|
||||||
|
def test_module_package_catalog_rejects_invalid_migration_safety(self) -> None:
|
||||||
|
root = Path(tempfile.mkdtemp(prefix="govoplan-module-package-catalog-invalid-safety-", dir=_TEST_ROOT))
|
||||||
|
catalog_path = root / "catalog.json"
|
||||||
|
catalog_path.write_text(json.dumps({
|
||||||
|
"modules": [{
|
||||||
|
"module_id": "files",
|
||||||
|
"migration_safety": "maybe",
|
||||||
|
}],
|
||||||
|
}), encoding="utf-8")
|
||||||
|
|
||||||
|
validation = validate_module_package_catalog(catalog_path)
|
||||||
|
|
||||||
|
self.assertFalse(validation["valid"])
|
||||||
|
self.assertIn("migration_safety", str(validation["error"]))
|
||||||
|
|
||||||
def test_module_package_catalog_rejects_invalid_interface_ranges(self) -> None:
|
def test_module_package_catalog_rejects_invalid_interface_ranges(self) -> None:
|
||||||
root = Path(tempfile.mkdtemp(prefix="govoplan-module-package-catalog-invalid-interface-", dir=_TEST_ROOT))
|
root = Path(tempfile.mkdtemp(prefix="govoplan-module-package-catalog-invalid-interface-", dir=_TEST_ROOT))
|
||||||
catalog_path = root / "catalog.json"
|
catalog_path = root / "catalog.json"
|
||||||
@@ -1928,6 +2366,7 @@ finally:
|
|||||||
"version_min": "0.1.0",
|
"version_min": "0.1.0",
|
||||||
"version_max_exclusive": "0.2.0",
|
"version_max_exclusive": "0.2.0",
|
||||||
}, modules["files"]["requires_interfaces"])
|
}, modules["files"]["requires_interfaces"])
|
||||||
|
self.assertEqual(["campaigns"], modules["files"]["optional_dependencies"])
|
||||||
self.assertIn({"name": "mail.campaign_delivery", "version": "0.1.6"}, modules["mail"]["provides_interfaces"])
|
self.assertIn({"name": "mail.campaign_delivery", "version": "0.1.6"}, modules["mail"]["provides_interfaces"])
|
||||||
self.assertIn({
|
self.assertIn({
|
||||||
"name": "files.campaign_attachments",
|
"name": "files.campaign_attachments",
|
||||||
@@ -1935,6 +2374,9 @@ finally:
|
|||||||
"version_min": "0.1.0",
|
"version_min": "0.1.0",
|
||||||
"version_max_exclusive": "0.2.0",
|
"version_max_exclusive": "0.2.0",
|
||||||
}, modules["campaigns"]["requires_interfaces"])
|
}, modules["campaigns"]["requires_interfaces"])
|
||||||
|
self.assertEqual(["files", "mail"], modules["campaigns"]["optional_dependencies"])
|
||||||
|
self.assertEqual("requires_review", modules["files"]["migration_safety"])
|
||||||
|
self.assertIn("migration", modules["files"]["migration_notes"].lower())
|
||||||
self.assertEqual("0.1.6", modules["files"]["version"])
|
self.assertEqual("0.1.6", modules["files"]["version"])
|
||||||
self.assertIn("@v0.1.6", modules["files"]["python_ref"])
|
self.assertIn("@v0.1.6", modules["files"]["python_ref"])
|
||||||
|
|
||||||
|
|||||||
@@ -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 PrivacyRetentionPolicyFieldKey,
|
||||||
type PrivacyRetentionPolicyPatch,
|
type PrivacyRetentionPolicyPatch,
|
||||||
type PrivacyRetentionPolicyScope } from
|
type PrivacyRetentionPolicyScope } from
|
||||||
"../../api/admin";
|
"../../api/privacyRetention";
|
||||||
import Button from "../../components/Button";
|
import Button from "../../components/Button";
|
||||||
import Card from "../../components/Card";
|
import Card from "../../components/Card";
|
||||||
import DismissibleAlert from "../../components/DismissibleAlert";
|
import DismissibleAlert from "../../components/DismissibleAlert";
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ export * from "./types";
|
|||||||
export * from "./api/client";
|
export * from "./api/client";
|
||||||
export * from "./api/auth";
|
export * from "./api/auth";
|
||||||
export * from "./api/platform";
|
export * from "./api/platform";
|
||||||
|
export * from "./api/privacyRetention";
|
||||||
export * from "./platform/modules";
|
export * from "./platform/modules";
|
||||||
export * from "./platform/ModuleContext";
|
export * from "./platform/ModuleContext";
|
||||||
export * from "./platform/moduleEvents";
|
export * from "./platform/moduleEvents";
|
||||||
|
|||||||
@@ -1,6 +1,42 @@
|
|||||||
import type { AuthInfo } from "../types";
|
import type { AuthInfo } from "../types";
|
||||||
|
|
||||||
const legacyToModuleScopes: Record<string, string> = {
|
const legacyToModuleScopes: Record<string, string> = {
|
||||||
|
"admin:users:read": "access:membership:read",
|
||||||
|
"admin:users:create": "access:membership:create",
|
||||||
|
"admin:users:update": "access:membership:update",
|
||||||
|
"admin:users:suspend": "access:membership:update",
|
||||||
|
"admin:groups:read": "access:group:read",
|
||||||
|
"admin:groups:write": "access:group:write",
|
||||||
|
"admin:groups:manage_members": "access:group:manage_members",
|
||||||
|
"admin:roles:read": "access:role:read",
|
||||||
|
"admin:roles:write": "access:role:write",
|
||||||
|
"admin:roles:assign": "access:role:assign",
|
||||||
|
"admin:api_keys:read": "access:api_key:read",
|
||||||
|
"admin:api_keys:create": "access:api_key:create",
|
||||||
|
"admin:api_keys:revoke": "access:api_key:revoke",
|
||||||
|
"admin:settings:read": "access:setting:read",
|
||||||
|
"admin:settings:write": "access:setting:write",
|
||||||
|
"admin:policies:read": "access:policy:read",
|
||||||
|
"admin:policies:write": "access:policy:write",
|
||||||
|
"system:tenants:read": "access:tenant:read",
|
||||||
|
"system:tenants:create": "access:tenant:create",
|
||||||
|
"system:tenants:update": "access:tenant:update",
|
||||||
|
"system:tenants:suspend": "access:tenant:suspend",
|
||||||
|
"system:accounts:read": "access:account:read",
|
||||||
|
"system:accounts:create": "access:account:create",
|
||||||
|
"system:accounts:update": "access:account:update",
|
||||||
|
"system:accounts:suspend": "access:account:suspend",
|
||||||
|
"system:roles:read": "access:system_role:read",
|
||||||
|
"system:roles:write": "access:system_role:write",
|
||||||
|
"system:roles:assign": "access:system_role:assign",
|
||||||
|
"system:access:read": "access:system_role:read",
|
||||||
|
"system:access:assign": "access:system_role:assign",
|
||||||
|
"system:audit:read": "access:audit:read",
|
||||||
|
"system:settings:read": "access:system_setting:read",
|
||||||
|
"system:settings:write": "access:system_setting:write",
|
||||||
|
"system:maintenance:access": "access:maintenance:access",
|
||||||
|
"system:governance:read": "access:governance:read",
|
||||||
|
"system:governance:write": "access:governance:write",
|
||||||
"campaign:read": "campaigns:campaign:read",
|
"campaign:read": "campaigns:campaign:read",
|
||||||
"campaign:create": "campaigns:campaign:create",
|
"campaign:create": "campaigns:campaign:create",
|
||||||
"campaign:update": "campaigns:campaign:update",
|
"campaign:update": "campaigns:campaign:update",
|
||||||
@@ -40,6 +76,16 @@ const legacyToModuleScopes: Record<string, string> = {
|
|||||||
};
|
};
|
||||||
|
|
||||||
const moduleToLegacyScopes = Object.fromEntries(Object.entries(legacyToModuleScopes).map(([legacy, module]) => [module, legacy]));
|
const moduleToLegacyScopes = Object.fromEntries(Object.entries(legacyToModuleScopes).map(([legacy, module]) => [module, legacy]));
|
||||||
|
const moduleSystemScopes = new Set(
|
||||||
|
Object.entries(legacyToModuleScopes)
|
||||||
|
.filter(([legacy]) => legacy.startsWith("system:"))
|
||||||
|
.map(([, module]) => module)
|
||||||
|
);
|
||||||
|
const moduleTenantScopes = new Set(
|
||||||
|
Object.entries(legacyToModuleScopes)
|
||||||
|
.filter(([legacy]) => !legacy.startsWith("system:"))
|
||||||
|
.map(([, module]) => module)
|
||||||
|
);
|
||||||
|
|
||||||
const legacyAliases: Record<string, string[]> = {
|
const legacyAliases: Record<string, string[]> = {
|
||||||
"campaign:write": ["campaign:create", "campaign:update", "campaign:copy", "campaign:archive", "campaign:delete", "campaign:share", "recipients:read", "recipients:write", "recipients:import"],
|
"campaign:write": ["campaign:create", "campaign:update", "campaign:copy", "campaign:archive", "campaign:delete", "campaign:share", "recipients:read", "recipients:write", "recipients:import"],
|
||||||
@@ -63,9 +109,10 @@ function primitiveScopeGrants(granted: string, required: string): boolean {
|
|||||||
if (legacyAliases[granted]?.some((alias) => primitiveScopeGrants(alias, required))) return true;
|
if (legacyAliases[granted]?.some((alias) => primitiveScopeGrants(alias, required))) return true;
|
||||||
if (granted === "*") return true;
|
if (granted === "*") return true;
|
||||||
if (granted === "tenant:*") {
|
if (granted === "tenant:*") {
|
||||||
return required === "tenant:*" || !required.startsWith("system:");
|
if (moduleSystemScopes.has(required)) return false;
|
||||||
|
return required === "tenant:*" || moduleTenantScopes.has(required) || !required.startsWith("system:");
|
||||||
}
|
}
|
||||||
if (granted === "system:*") return required.startsWith("system:") || required.startsWith("access:");
|
if (granted === "system:*") return required.startsWith("system:") || moduleSystemScopes.has(required);
|
||||||
if (granted.endsWith(":*") && required.startsWith(granted.slice(0, -1))) return true;
|
if (granted.endsWith(":*") && required.startsWith(granted.slice(0, -1))) return true;
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
@@ -111,5 +158,8 @@ export const adminReadScopes = [
|
|||||||
"system:governance:read",
|
"system:governance:read",
|
||||||
"access:tenant:read",
|
"access:tenant:read",
|
||||||
"access:account:read",
|
"access:account:read",
|
||||||
|
"access:system_role:read",
|
||||||
|
"access:audit:read",
|
||||||
|
"access:system_setting:read",
|
||||||
"access:governance:read"
|
"access:governance:read"
|
||||||
];
|
];
|
||||||
|
|||||||
Reference in New Issue
Block a user