feat: expose typed deployment capability receipts

This commit is contained in:
2026-08-07 11:15:49 +02:00
parent 40cc012124
commit eb0c01c5d2
4 changed files with 508 additions and 1 deletions
+30 -1
View File
@@ -159,7 +159,36 @@ The initial implementation includes provider-neutral orchestration helpers:
The first concrete provider is `govoplan_access.backend.configuration_provider`. The first concrete provider is `govoplan_access.backend.configuration_provider`.
It supports access-owned `roles`, `groups`, and `group_role_assignments` 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: The admin wizard backend starts with these routes:
@@ -28,6 +28,9 @@ from govoplan_core.core.external_references import (
SourceAuthorityMode, SourceAuthorityMode,
integration_maturity_rank, integration_maturity_rank,
) )
from govoplan_core.core.infrastructure_capabilities import (
InfrastructureCapabilityReceipt,
)
from govoplan_core.security.http_fetch import fetch_http_text from govoplan_core.security.http_fetch import fetch_http_text
@@ -441,6 +444,9 @@ class ConfigurationPreflightContext:
default_factory=dict default_factory=dict
) )
dry_run: bool = True 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) @dataclass(frozen=True, slots=True)
@@ -586,11 +592,14 @@ def apply_configuration_package(
apply_context = ConfigurationPreflightContext( apply_context = ConfigurationPreflightContext(
tenant_id=context.tenant_id, tenant_id=context.tenant_id,
operator_user_id=context.operator_user_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, supplied_data=supplied_data if supplied_data is not None else context.supplied_data,
installed_modules=context.installed_modules, installed_modules=context.installed_modules,
capabilities=context.capabilities, capabilities=context.capabilities,
external_provider_declarations=context.external_provider_declarations, external_provider_declarations=context.external_provider_declarations,
external_provider_states=context.external_provider_states, external_provider_states=context.external_provider_states,
infrastructure_receipt=context.infrastructure_receipt,
infrastructure_receipt_error=context.infrastructure_receipt_error,
dry_run=False, dry_run=False,
) )
preflight = dry_run_configuration_package(manifest, providers, apply_context) preflight = dry_run_configuration_package(manifest, providers, apply_context)
@@ -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",
]
+106
View File
@@ -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()