From eb0c01c5d2c8ed2e0d3f515f564f616b586e5c03 Mon Sep 17 00:00:00 2001 From: Albrecht Degering Date: Fri, 7 Aug 2026 11:15:49 +0200 Subject: [PATCH] feat: expose typed deployment capability receipts --- docs/CONFIGURATION_PACKAGES.md | 31 +- .../core/configuration_packages.py | 9 + .../core/infrastructure_capabilities.py | 363 ++++++++++++++++++ tests/test_infrastructure_capabilities.py | 106 +++++ 4 files changed, 508 insertions(+), 1 deletion(-) create mode 100644 src/govoplan_core/core/infrastructure_capabilities.py create mode 100644 tests/test_infrastructure_capabilities.py diff --git a/docs/CONFIGURATION_PACKAGES.md b/docs/CONFIGURATION_PACKAGES.md index e78eba2..c2a76dd 100644 --- a/docs/CONFIGURATION_PACKAGES.md +++ b/docs/CONFIGURATION_PACKAGES.md @@ -159,7 +159,36 @@ The initial implementation includes provider-neutral orchestration helpers: The first concrete provider is `govoplan_access.backend.configuration_provider`. It supports access-owned `roles`, `groups`, and `group_role_assignments` -fragments and applies them idempotently. +fragments and applies them idempotently. Mail and Files also register providers +for deployment configuration: Mail owns receipt-bound SMTP profiles and Files +validates the deployment-owned managed-storage binding. + +### Deployment capability receipt + +The installer mounts a bounded, non-secret infrastructure receipt at the path +named by `GOVOPLAN_DEPLOYMENT_CAPABILITIES_PATH`. Core validates that document +once for configuration-package context and exposes typed capability and +post-install-task records to providers. Invalid receipts fail closed. Endpoint +metadata is sanitized, and secret fields may cross this boundary only as +`env:VARIABLE_NAME` references. + +Feature providers remain responsible for their own semantics: + +- Mail can derive host and port from `mail.smtp`, collect missing non-secret + transport fields, and bind an existing credential-envelope id. It never + accepts or exports a username, password, token, or decrypted credential. +- Files compares `files.storage` with the effective runtime backend, endpoint, + trust marker, bucket, and presence of referenced environment secrets. Storage + remains deployment-owned, so the provider reports `skip` when they agree and + blocks drift instead of rewriting process environment or storage credentials. +- A system-scoped Mail profile requires system configuration authority. Tenant + scope is the conservative default. +- Existing Mail configuration is preserved unless a reviewed fragment + explicitly selects `on_conflict: update`. Reapplying an unchanged fragment is + a no-op. + +Ops projects the same Core-validated receipt. It must not maintain a second +parser with different validation or secret-handling rules. The admin wizard backend starts with these routes: diff --git a/src/govoplan_core/core/configuration_packages.py b/src/govoplan_core/core/configuration_packages.py index d6f1e71..1ba8aec 100644 --- a/src/govoplan_core/core/configuration_packages.py +++ b/src/govoplan_core/core/configuration_packages.py @@ -28,6 +28,9 @@ from govoplan_core.core.external_references import ( SourceAuthorityMode, integration_maturity_rank, ) +from govoplan_core.core.infrastructure_capabilities import ( + InfrastructureCapabilityReceipt, +) from govoplan_core.security.http_fetch import fetch_http_text @@ -441,6 +444,9 @@ class ConfigurationPreflightContext: default_factory=dict ) dry_run: bool = True + operator_scopes: frozenset[str] = frozenset() + infrastructure_receipt: InfrastructureCapabilityReceipt | None = None + infrastructure_receipt_error: str | None = None @dataclass(frozen=True, slots=True) @@ -586,11 +592,14 @@ def apply_configuration_package( apply_context = ConfigurationPreflightContext( tenant_id=context.tenant_id, operator_user_id=context.operator_user_id, + operator_scopes=context.operator_scopes, supplied_data=supplied_data if supplied_data is not None else context.supplied_data, installed_modules=context.installed_modules, capabilities=context.capabilities, external_provider_declarations=context.external_provider_declarations, external_provider_states=context.external_provider_states, + infrastructure_receipt=context.infrastructure_receipt, + infrastructure_receipt_error=context.infrastructure_receipt_error, dry_run=False, ) preflight = dry_run_configuration_package(manifest, providers, apply_context) diff --git a/src/govoplan_core/core/infrastructure_capabilities.py b/src/govoplan_core/core/infrastructure_capabilities.py new file mode 100644 index 0000000..79ea508 --- /dev/null +++ b/src/govoplan_core/core/infrastructure_capabilities.py @@ -0,0 +1,363 @@ +from __future__ import annotations + +from collections.abc import Mapping +from dataclasses import dataclass +import json +import os +from pathlib import Path +import re +from typing import Any + + +DEPLOYMENT_CAPABILITIES_ENV = "GOVOPLAN_DEPLOYMENT_CAPABILITIES_PATH" +MAX_CAPABILITY_DOCUMENT_BYTES = 256 * 1024 +CAPABILITY_STATES = frozenset( + { + "configured", + "available_unconfigured", + "externally_supplied", + "unavailable", + } +) +_ENV_REFERENCE_RE = re.compile(r"^env:[A-Za-z_][A-Za-z0-9_]*$") + + +class InfrastructureCapabilityReceiptError(ValueError): + pass + + +@dataclass(frozen=True, slots=True) +class InfrastructureCapability: + id: str + label: str + state: str + source: str + detail: str + endpoint: Mapping[str, object] + secret_refs: tuple[str, ...] + dependent_modules: tuple[str, ...] + + def to_dict(self) -> dict[str, object]: + return { + "id": self.id, + "label": self.label, + "state": self.state, + "source": self.source, + "detail": self.detail, + "endpoint": dict(self.endpoint), + "secret_refs": list(self.secret_refs), + "dependent_modules": list(self.dependent_modules), + } + + +@dataclass(frozen=True, slots=True) +class InfrastructurePostInstallTask: + id: str + resume_key: str + capability_id: str + state: str + owner_module: str + summary: str + required_inputs: tuple[str, ...] + secret_boundary: str + + def to_dict(self) -> dict[str, object]: + return { + "id": self.id, + "resume_key": self.resume_key, + "capability_id": self.capability_id, + "state": self.state, + "owner_module": self.owner_module, + "summary": self.summary, + "required_inputs": list(self.required_inputs), + "secret_boundary": self.secret_boundary, + } + + +@dataclass(frozen=True, slots=True) +class InfrastructureCapabilityReceipt: + installation_id: str + profile: str + capabilities: tuple[InfrastructureCapability, ...] + post_install_tasks: tuple[InfrastructurePostInstallTask, ...] + schema_version: int = 1 + + def capability(self, capability_id: str) -> InfrastructureCapability | None: + return next( + (item for item in self.capabilities if item.id == capability_id), + None, + ) + + def tasks_for( + self, + *, + capability_id: str | None = None, + owner_module: str | None = None, + ) -> tuple[InfrastructurePostInstallTask, ...]: + return tuple( + item + for item in self.post_install_tasks + if (capability_id is None or item.capability_id == capability_id) + and (owner_module is None or item.owner_module == owner_module) + ) + + def to_dict(self) -> dict[str, object]: + return { + "schema_version": self.schema_version, + "installation_id": self.installation_id, + "profile": self.profile, + "capabilities": [item.to_dict() for item in self.capabilities], + "post_install_tasks": [ + item.to_dict() for item in self.post_install_tasks + ], + } + + +def load_infrastructure_capability_receipt( + path: Path | str | None = None, +) -> InfrastructureCapabilityReceipt | None: + configured_path = path + if configured_path is None: + raw_path = os.getenv(DEPLOYMENT_CAPABILITIES_ENV, "").strip() + if not raw_path: + return None + configured_path = raw_path + return read_infrastructure_capability_receipt(Path(configured_path)) + + +def read_infrastructure_capability_receipt( + path: Path, +) -> InfrastructureCapabilityReceipt: + if path.is_symlink() or not path.is_file(): + raise InfrastructureCapabilityReceiptError( + "Deployment capability receipt is not a regular file." + ) + try: + expected_size = path.stat().st_size + if expected_size > MAX_CAPABILITY_DOCUMENT_BYTES: + raise InfrastructureCapabilityReceiptError( + "Deployment capability receipt exceeds 256 KiB." + ) + raw = path.read_bytes() + except OSError as exc: + raise InfrastructureCapabilityReceiptError( + "Deployment capability receipt could not be read." + ) from exc + if len(raw) != expected_size: + raise InfrastructureCapabilityReceiptError( + "Deployment capability receipt changed while being read." + ) + try: + payload = json.loads(raw.decode("utf-8")) + except (UnicodeDecodeError, json.JSONDecodeError) as exc: + raise InfrastructureCapabilityReceiptError( + "Deployment capability receipt is not valid UTF-8 JSON." + ) from exc + return infrastructure_capability_receipt_from_mapping(payload) + + +def infrastructure_capability_receipt_from_mapping( + payload: object, +) -> InfrastructureCapabilityReceipt: + if ( + not isinstance(payload, Mapping) + or type(payload.get("schema_version")) is not int + or payload.get("schema_version") != 1 + ): + raise InfrastructureCapabilityReceiptError( + "Deployment capability receipt has an unsupported schema." + ) + raw_capabilities = payload.get("capabilities") + if not isinstance(raw_capabilities, list) or len(raw_capabilities) > 100: + raise InfrastructureCapabilityReceiptError( + "Deployment capability receipt has invalid capabilities." + ) + capabilities = tuple(_capability(item) for item in raw_capabilities) + capability_ids = [item.id for item in capabilities] + if len(capability_ids) != len(set(capability_ids)): + raise InfrastructureCapabilityReceiptError( + "Deployment capability receipt repeats a capability id." + ) + raw_tasks = payload.get("post_install_tasks", []) + if not isinstance(raw_tasks, list) or len(raw_tasks) > 100: + raise InfrastructureCapabilityReceiptError( + "Deployment capability receipt has invalid post-install tasks." + ) + tasks = tuple(_task(item) for item in raw_tasks) + known_capability_ids = set(capability_ids) + if any(item.capability_id not in known_capability_ids for item in tasks): + raise InfrastructureCapabilityReceiptError( + "Deployment post-install task references an unknown capability." + ) + return InfrastructureCapabilityReceipt( + installation_id=_required_text(payload, "installation_id", maximum=100), + profile=_required_text(payload, "profile", maximum=100), + capabilities=capabilities, + post_install_tasks=tasks, + ) + + +def deployment_capability_status( + path: Path | str | None = None, +) -> dict[str, object]: + try: + receipt = load_infrastructure_capability_receipt(path) + except InfrastructureCapabilityReceiptError as exc: + return _unavailable_status(configured=True, error=str(exc)) + if receipt is None: + return _unavailable_status(configured=False, error=None) + return { + "configured": True, + "available": True, + **receipt.to_dict(), + "error": None, + } + + +def _capability(value: object) -> InfrastructureCapability: + if not isinstance(value, Mapping): + raise InfrastructureCapabilityReceiptError( + "Deployment capability entries must be objects." + ) + state = _required_text(value, "state", maximum=40) + if state not in CAPABILITY_STATES: + raise InfrastructureCapabilityReceiptError( + f"Deployment capability state is unsupported: {state!r}." + ) + normalized_endpoint = _normalized_endpoint(value.get("endpoint", {})) + secret_refs = _string_list(value.get("secret_refs"), maximum_items=30) + if any(not _ENV_REFERENCE_RE.fullmatch(item) for item in secret_refs): + raise InfrastructureCapabilityReceiptError( + "Deployment capability secrets must use environment references." + ) + return InfrastructureCapability( + id=_required_text(value, "id", maximum=120), + label=_required_text(value, "label", maximum=200), + state=state, + source=_required_text(value, "source", maximum=120), + detail=_required_text(value, "detail", maximum=1000), + endpoint=normalized_endpoint, + secret_refs=secret_refs, + dependent_modules=_string_list( + value.get("dependent_modules"), + maximum_items=100, + ), + ) + + +def _normalized_endpoint(value: object) -> dict[str, object]: + if not isinstance(value, Mapping) or len(value) > 10: + raise InfrastructureCapabilityReceiptError( + "Deployment capability endpoint metadata is invalid." + ) + endpoint: dict[str, object] = {} + for key, raw in value.items(): + if not isinstance(key, str) or not key or len(key) > 50: + raise InfrastructureCapabilityReceiptError( + "Deployment capability endpoint key is invalid." + ) + if any( + marker in key.casefold() + for marker in ("password", "secret", "token", "credential") + ): + raise InfrastructureCapabilityReceiptError( + "Deployment capability endpoint metadata contains a secret field." + ) + if key.casefold() == "port" and ( + type(raw) is not int or not 1 <= raw <= 65535 + ): + raise InfrastructureCapabilityReceiptError( + "Deployment capability endpoint port is invalid." + ) + if isinstance(raw, bool) or raw is None: + endpoint[key] = raw + elif isinstance(raw, int): + endpoint[key] = raw + elif isinstance(raw, str) and len(raw) <= 500: + endpoint[key] = raw + else: + raise InfrastructureCapabilityReceiptError( + "Deployment capability endpoint value is invalid." + ) + return endpoint + + +def _task(value: object) -> InfrastructurePostInstallTask: + if not isinstance(value, Mapping): + raise InfrastructureCapabilityReceiptError( + "Deployment post-install task entries must be objects." + ) + return InfrastructurePostInstallTask( + id=_required_text(value, "id", maximum=120), + resume_key=_required_text(value, "resume_key", maximum=240), + capability_id=_required_text(value, "capability_id", maximum=120), + state=_required_text(value, "state", maximum=40), + owner_module=_required_text(value, "owner_module", maximum=120), + summary=_required_text(value, "summary", maximum=1000), + required_inputs=_string_list( + value.get("required_inputs"), + maximum_items=30, + ), + secret_boundary=_required_text( + value, + "secret_boundary", + maximum=120, + ), + ) + + +def _required_text( + value: Mapping[str, Any], + key: str, + *, + maximum: int, +) -> str: + raw = value.get(key) + text = str(raw).strip() if raw is not None else "" + if not text or len(text) > maximum: + raise InfrastructureCapabilityReceiptError( + f"Deployment capability field {key!r} is invalid." + ) + return text + + +def _string_list(value: object, *, maximum_items: int) -> tuple[str, ...]: + if not isinstance(value, list) or len(value) > maximum_items: + raise InfrastructureCapabilityReceiptError( + "Deployment capability list field is invalid." + ) + result: list[str] = [] + for item in value: + if not isinstance(item, str) or not item.strip() or len(item) > 500: + raise InfrastructureCapabilityReceiptError( + "Deployment capability list item is invalid." + ) + result.append(item.strip()) + return tuple(result) + + +def _unavailable_status(*, configured: bool, error: str | None) -> dict[str, object]: + return { + "configured": configured, + "available": False, + "schema_version": None, + "installation_id": None, + "profile": None, + "capabilities": [], + "post_install_tasks": [], + "error": error, + } + + +__all__ = [ + "CAPABILITY_STATES", + "DEPLOYMENT_CAPABILITIES_ENV", + "InfrastructureCapability", + "InfrastructureCapabilityReceipt", + "InfrastructureCapabilityReceiptError", + "InfrastructurePostInstallTask", + "deployment_capability_status", + "infrastructure_capability_receipt_from_mapping", + "load_infrastructure_capability_receipt", + "read_infrastructure_capability_receipt", +] diff --git a/tests/test_infrastructure_capabilities.py b/tests/test_infrastructure_capabilities.py new file mode 100644 index 0000000..e67ee73 --- /dev/null +++ b/tests/test_infrastructure_capabilities.py @@ -0,0 +1,106 @@ +from __future__ import annotations + +import json +import os +from pathlib import Path +import tempfile +import unittest +from unittest.mock import patch + +from govoplan_core.core.infrastructure_capabilities import ( + InfrastructureCapabilityReceiptError, + deployment_capability_status, + infrastructure_capability_receipt_from_mapping, + load_infrastructure_capability_receipt, +) + + +def _receipt_payload() -> dict[str, object]: + return { + "schema_version": 1, + "installation_id": "govoplan-test", + "profile": "evaluation", + "capabilities": [ + { + "id": "mail.smtp", + "label": "SMTP delivery", + "state": "available_unconfigured", + "source": "installer-managed-test", + "detail": "Mail owns the profile binding.", + "endpoint": {"scheme": "smtp", "host": "test-mail", "port": 3025}, + "secret_refs": [], + "dependent_modules": ["mail"], + } + ], + "post_install_tasks": [ + { + "id": "mail.smtp-profile", + "resume_key": "govoplan-test:mail.smtp-profile:v1", + "capability_id": "mail.smtp", + "state": "pending", + "owner_module": "mail", + "summary": "Create a Mail SMTP profile.", + "required_inputs": ["credential envelope reference when required"], + "secret_boundary": "credential-envelope-reference-only", + } + ], + } + + +class InfrastructureCapabilityReceiptTests(unittest.TestCase): + def test_parses_typed_capability_and_task_lookup(self) -> None: + receipt = infrastructure_capability_receipt_from_mapping(_receipt_payload()) + + capability = receipt.capability("mail.smtp") + self.assertIsNotNone(capability) + assert capability is not None + self.assertEqual("test-mail", capability.endpoint["host"]) + self.assertEqual( + "govoplan-test:mail.smtp-profile:v1", + receipt.tasks_for(capability_id="mail.smtp", owner_module="mail")[0].resume_key, + ) + self.assertEqual(_receipt_payload(), receipt.to_dict()) + + def test_loader_uses_configured_path_and_status_remains_non_secret(self) -> None: + with tempfile.TemporaryDirectory(prefix="govoplan-capability-receipt-") as root: + path = Path(root) / "capabilities.json" + path.write_text(json.dumps(_receipt_payload()), encoding="utf-8") + with patch.dict( + os.environ, + {"GOVOPLAN_DEPLOYMENT_CAPABILITIES_PATH": str(path)}, + clear=False, + ): + receipt = load_infrastructure_capability_receipt() + status = deployment_capability_status() + + self.assertIsNotNone(receipt) + self.assertTrue(status["available"]) + self.assertNotIn("password", json.dumps(status).casefold()) + + def test_rejects_inline_secret_endpoint_and_non_environment_secret_reference(self) -> None: + for endpoint, secret_refs in ( + ({"password": "inline"}, []), + ({}, ["plaintext-secret"]), + ): + with self.subTest(endpoint=endpoint, secret_refs=secret_refs): + payload = _receipt_payload() + capability = dict(payload["capabilities"][0]) # type: ignore[index] + capability["endpoint"] = endpoint + capability["secret_refs"] = secret_refs + payload["capabilities"] = [capability] + + with self.assertRaises(InfrastructureCapabilityReceiptError): + infrastructure_capability_receipt_from_mapping(payload) + + def test_rejects_post_install_task_for_unknown_capability(self) -> None: + payload = _receipt_payload() + task = dict(payload["post_install_tasks"][0]) # type: ignore[index] + task["capability_id"] = "unknown.capability" + payload["post_install_tasks"] = [task] + + with self.assertRaises(InfrastructureCapabilityReceiptError): + infrastructure_capability_receipt_from_mapping(payload) + + +if __name__ == "__main__": + unittest.main()