Add shared automation and WebUI editing primitives

This commit is contained in:
2026-07-31 02:48:56 +02:00
parent f0898fcdee
commit 5b55f59a92
42 changed files with 3788 additions and 554 deletions
+210
View File
@@ -21,6 +21,172 @@ AutomationInvocationKind = Literal[
]
AutomationSubjectKind = Literal["delegated_user", "service_account"]
AUTOMATION_PRINCIPAL_CONTRACT_VERSION = "1"
ACTION_EFFECT_CONTRACT_VERSION = "1"
ActionRiskLevel = Literal["low", "moderate", "high", "critical"]
ActionReversibility = Literal[
"reversible",
"compensatable",
"corrective_only",
"irreversible",
]
ActionExecutionState = Literal[
"pending",
"running",
"completed",
"blocked",
"retryable",
"quarantined",
"manual_required",
"compensation_required",
]
EffectOperation = Literal[
"created",
"changed",
"deleted",
"sent",
"notified",
"locked",
"retained",
"external",
]
@dataclass(frozen=True, slots=True)
class EffectDefinition:
effect_key: str
owner_module: str
operation: EffectOperation
description: str
resource_types: tuple[str, ...] = ()
visibility_classification: str = "internal"
audit_event_types: tuple[str, ...] = ()
compensation_hint: str | None = None
contract_version: str = ACTION_EFFECT_CONTRACT_VERSION
def __post_init__(self) -> None:
_validate_contract_version(self.contract_version)
_require_text(self.effect_key, "Effect key")
_require_text(self.owner_module, "Effect owner module")
_require_text(self.description, "Effect description")
@dataclass(frozen=True, slots=True)
class ActionDefinition:
action_key: str
owner_module: str
description: str
input_schema_ref: str
required_scopes: tuple[str, ...] = ()
required_capabilities: tuple[str, ...] = ()
policy_checks: tuple[str, ...] = ()
risk_level: ActionRiskLevel = "moderate"
reversibility: ActionReversibility = "corrective_only"
expected_effect_keys: tuple[str, ...] = ()
idempotency_strategy: str = "caller_supplied"
audit_event_types: tuple[str, ...] = ()
preview_required: bool = True
contract_version: str = ACTION_EFFECT_CONTRACT_VERSION
def __post_init__(self) -> None:
_validate_contract_version(self.contract_version)
_require_text(self.action_key, "Action key")
_require_text(self.owner_module, "Action owner module")
_require_text(self.description, "Action description")
_require_text(self.input_schema_ref, "Action input schema reference")
_require_text(self.idempotency_strategy, "Action idempotency strategy")
if any(not value.strip() for value in self.required_scopes):
raise ValueError("Action scopes must not be empty")
if any(not value.strip() for value in self.required_capabilities):
raise ValueError("Action capabilities must not be empty")
if any(not value.strip() for value in self.expected_effect_keys):
raise ValueError("Expected effect keys must not be empty")
@dataclass(frozen=True, slots=True)
class ActionExecutionRequest:
tenant_id: str
action_key: str
input: Mapping[str, object]
idempotency_key: str
invocation: AutomationInvocation
actor_ref: str | None = None
preview_ref: str | None = None
metadata: Mapping[str, object] = field(default_factory=dict)
contract_version: str = ACTION_EFFECT_CONTRACT_VERSION
def __post_init__(self) -> None:
_validate_contract_version(self.contract_version)
_require_text(self.tenant_id, "Action tenant id")
_require_text(self.action_key, "Action key")
_require_text(self.idempotency_key, "Action idempotency key")
@dataclass(frozen=True, slots=True)
class EffectPreview:
effect_key: str
summary: str
resource_refs: tuple[str, ...] = ()
external_system_refs: tuple[str, ...] = ()
@dataclass(frozen=True, slots=True)
class ActionPreview:
action_key: str
allowed: bool
summary: str
risk_level: ActionRiskLevel
reversibility: ActionReversibility
effects: tuple[EffectPreview, ...] = ()
blockers: tuple[str, ...] = ()
policy_provenance: tuple[Mapping[str, object], ...] = ()
preview_ref: str | None = None
@dataclass(frozen=True, slots=True)
class ObservedEffect:
effect_key: str
operation: EffectOperation
resource_ref: str | None = None
external_system_ref: str | None = None
audit_event_ref: str | None = None
summary: str | None = None
metadata: Mapping[str, object] = field(default_factory=dict)
@dataclass(frozen=True, slots=True)
class ActionExecutionResult:
state: ActionExecutionState
output: Mapping[str, object] = field(default_factory=dict)
observed_effects: tuple[ObservedEffect, ...] = ()
error: str | None = None
retry_after: datetime | None = None
manual_instructions: str | None = None
compensation_action_key: str | None = None
audit_event_refs: tuple[str, ...] = ()
@runtime_checkable
class ActionEffectProvider(Protocol):
def action_definitions(self) -> tuple[ActionDefinition, ...]: ...
def effect_definitions(self) -> tuple[EffectDefinition, ...]: ...
def preview_action(
self,
session: object,
principal: object,
*,
request: ActionExecutionRequest,
) -> ActionPreview: ...
def execute_action(
self,
session: object,
principal: object,
*,
request: ActionExecutionRequest,
) -> ActionExecutionResult: ...
@dataclass(frozen=True, slots=True)
@@ -170,14 +336,58 @@ def automation_principal_provider(
)
def action_effect_provider(
registry: object | None,
capability_name: str,
) -> ActionEffectProvider | None:
name = capability_name.strip()
if (
not name
or registry is None
or not hasattr(registry, "has_capability")
or not registry.has_capability(name)
):
return None
capability = registry.capability(name)
return (
capability
if isinstance(capability, ActionEffectProvider)
else None
)
def _validate_contract_version(value: str) -> None:
if value != ACTION_EFFECT_CONTRACT_VERSION:
raise ValueError("Unsupported action/effect contract version")
def _require_text(value: str, label: str) -> None:
if not value.strip():
raise ValueError(f"{label} is required")
__all__ = [
"ACTION_EFFECT_CONTRACT_VERSION",
"CAPABILITY_AUTH_AUTOMATION_PRINCIPAL_PROVIDER",
"AUTOMATION_PRINCIPAL_CONTRACT_VERSION",
"ActionDefinition",
"ActionEffectProvider",
"ActionExecutionRequest",
"ActionExecutionResult",
"ActionExecutionState",
"AutomationInvocation",
"AutomationInvocationKind",
"AutomationPrincipalProvider",
"AutomationPrincipalRequest",
"AutomationPrincipalResolution",
"AutomationSubjectKind",
"ActionPreview",
"ActionReversibility",
"ActionRiskLevel",
"EffectDefinition",
"EffectOperation",
"EffectPreview",
"ObservedEffect",
"action_effect_provider",
"automation_principal_provider",
]
@@ -301,6 +301,18 @@ _CONFIGURATION_FIELD_SAFETY: tuple[ConfigurationFieldSafety, ...] = (
maintenance_required=True,
notes="This deployment-wide egress boundary remains out of band and applies to every connector worker.",
),
ConfigurationFieldSafety(
key="FILE_STORAGE_S3_DEPLOYMENT_MANAGED",
label="Installer-managed Garage trust",
owner_module="files",
scope="system",
storage="environment",
ui_managed=False,
risk="high",
secret_handling="env_only", # noqa: S106 # nosec B106 - policy vocabulary.
maintenance_required=True,
notes="Reserved for the exact installer-owned http://garage:3900 service; it must never authorize another S3 endpoint.",
),
ConfigurationFieldSafety(
key="GOVOPLAN_CONNECTOR_MAX_STRUCTURED_RESPONSE_BYTES",
label="Structured connector response limit",
+47 -2
View File
@@ -239,10 +239,30 @@ def _validate_cors_settings(env: Mapping[str, str], runtime: _RuntimeProfile, co
def _validate_file_storage_settings(env: Mapping[str, str], runtime: _RuntimeProfile, collector: _ConfigIssueCollector) -> None:
storage_backend = _clean(env.get("FILE_STORAGE_BACKEND")) or "local"
deployment_managed_raw = _clean(env.get("FILE_STORAGE_S3_DEPLOYMENT_MANAGED")).lower()
if deployment_managed_raw and deployment_managed_raw not in {"true", "false", "1", "0", "yes", "no", "on", "off"}:
collector.add(
"error",
"FILE_STORAGE_S3_DEPLOYMENT_MANAGED",
"Managed S3 trust must be an explicit boolean.",
"Set FILE_STORAGE_S3_DEPLOYMENT_MANAGED=false, or let the supported installer manage Garage.",
)
deployment_managed = _truthy(deployment_managed_raw)
if storage_backend == "local":
if deployment_managed:
collector.add(
"error",
"FILE_STORAGE_S3_DEPLOYMENT_MANAGED",
"Managed S3 trust cannot be enabled for local file storage.",
"Set FILE_STORAGE_S3_DEPLOYMENT_MANAGED=false.",
)
_validate_local_file_storage(env, runtime, collector)
elif storage_backend == "s3":
_validate_s3_file_storage(env, collector)
_validate_s3_file_storage(
env,
collector,
deployment_managed=deployment_managed,
)
else:
collector.add("error", "FILE_STORAGE_BACKEND", f"Unsupported FILE_STORAGE_BACKEND={storage_backend!r}.", "Use `local` or `s3`.")
@@ -254,10 +274,25 @@ def _validate_local_file_storage(env: Mapping[str, str], runtime: _RuntimeProfil
collector.add("warning", "FILE_STORAGE_BACKEND", "Production is configured for local file storage.", "Confirm the path is durable and backed up, or use object storage once the deployment needs independent file scaling.")
def _validate_s3_file_storage(env: Mapping[str, str], collector: _ConfigIssueCollector) -> None:
def _validate_s3_file_storage(
env: Mapping[str, str],
collector: _ConfigIssueCollector,
*,
deployment_managed: bool,
) -> None:
for key in ("FILE_STORAGE_S3_ENDPOINT_URL", "FILE_STORAGE_S3_REGION", "FILE_STORAGE_S3_ACCESS_KEY_ID", "FILE_STORAGE_S3_SECRET_ACCESS_KEY", "FILE_STORAGE_S3_BUCKET"):
if not _clean(env.get(key)):
collector.add("error", key, f"{key} is required when FILE_STORAGE_BACKEND=s3.", "Configure all FILE_STORAGE_S3_* settings through deployment secrets.")
if (
deployment_managed
and _clean(env.get("FILE_STORAGE_S3_ENDPOINT_URL")) != "http://garage:3900"
):
collector.add(
"error",
"FILE_STORAGE_S3_ENDPOINT_URL",
"Installer-managed S3 trust is restricted to http://garage:3900.",
"Use the exact managed Garage endpoint or disable FILE_STORAGE_S3_DEPLOYMENT_MANAGED.",
)
def _validate_outbound_connector_policy(
@@ -373,6 +408,11 @@ AUTH_COOKIE_DOMAIN=
FILE_STORAGE_BACKEND=local
FILE_STORAGE_LOCAL_ROOT=/var/lib/govoplan/files
FILE_STORAGE_LOCAL_FALLBACK_ROOTS=
FILE_STORAGE_S3_DEPLOYMENT_MANAGED=false
FILE_ARCHIVE_MAX_ENTRIES=10000
FILE_ARCHIVE_MAX_EXPANDED_BYTES=2147483648
FILE_ARCHIVE_MAX_EXPANSION_RATIO=100
FILE_ARCHIVE_PREVIEW_TTL_SECONDS=1800
DEV_AUTO_MIGRATE_ENABLED=false
DEV_BOOTSTRAP_ENABLED=false
@@ -433,6 +473,11 @@ FORWARDED_ALLOW_IPS=127.0.0.1
AUTH_COOKIE_SECURE=false
FILE_STORAGE_BACKEND=local
FILE_STORAGE_LOCAL_ROOT=runtime/production-like/files
FILE_STORAGE_S3_DEPLOYMENT_MANAGED=false
FILE_ARCHIVE_MAX_ENTRIES=10000
FILE_ARCHIVE_MAX_EXPANDED_BYTES=2147483648
FILE_ARCHIVE_MAX_EXPANSION_RATIO=100
FILE_ARCHIVE_PREVIEW_TTL_SECONDS=1800
DEV_AUTO_MIGRATE_ENABLED=false
DEV_BOOTSTRAP_ENABLED=true
"""
+14
View File
@@ -56,9 +56,22 @@ class ViewResolver(Protocol):
account_id: str,
group_ids: Iterable[str] = (),
workflow_view_id: str | None = None,
workflow_revision_id: str | None = None,
workflow_surface_ids: Iterable[str] = (),
) -> EffectiveView: ...
def views_resolver(registry: object | None) -> ViewResolver | None:
if (
registry is None
or not hasattr(registry, "has_capability")
or not registry.has_capability(CAPABILITY_VIEWS_RESOLVER)
):
return None
capability = registry.capability(CAPABILITY_VIEWS_RESOLVER)
return capability if isinstance(capability, ViewResolver) else None
def module_view_surface_id(module_id: str) -> str:
return f"{module_id}.module"
@@ -92,4 +105,5 @@ __all__ = [
"navigation_view_surface_id",
"route_view_surface_id",
"validate_view_surface_id",
"views_resolver",
]
+32 -1
View File
@@ -2,8 +2,9 @@ from __future__ import annotations
import base64
import hashlib
import json
from functools import lru_cache
from typing import Protocol, runtime_checkable
from typing import Any, Mapping, Protocol, runtime_checkable
from cryptography.fernet import Fernet, InvalidToken
@@ -18,6 +19,10 @@ class SecretDecryptionError(RuntimeError):
pass
class TransientPayloadError(RuntimeError):
pass
CAPABILITY_SECURITY_SECRET_PROVIDER = "security.secretProvider" # noqa: S105 # nosec B105 - capability identifier.
@@ -72,3 +77,29 @@ def decrypt_secret(value: str | None) -> str | None:
return _fernet().decrypt(value.encode("utf-8")).decode("utf-8")
except InvalidToken as exc:
raise SecretDecryptionError("Stored secret cannot be decrypted with the configured master key") from exc
def seal_transient_payload(payload: Mapping[str, Any]) -> str:
"""Seal a short-lived JSON object without persisting server-side state."""
encoded = json.dumps(
dict(payload),
separators=(",", ":"),
sort_keys=True,
).encode("utf-8")
return _fernet().encrypt(encoded).decode("utf-8")
def open_transient_payload(token: str, *, ttl_seconds: int) -> dict[str, Any]:
"""Open a sealed JSON object and enforce its maximum age."""
if ttl_seconds <= 0:
raise ValueError("Transient payload TTL must be positive")
try:
encoded = _fernet().decrypt(token.encode("utf-8"), ttl=ttl_seconds)
payload = json.loads(encoded.decode("utf-8"))
except (InvalidToken, UnicodeDecodeError, json.JSONDecodeError, TypeError, ValueError) as exc:
raise TransientPayloadError("Transient payload is invalid or expired") from exc
if not isinstance(payload, dict):
raise TransientPayloadError("Transient payload must contain a JSON object")
return payload
+20
View File
@@ -74,8 +74,28 @@ class Settings(BaseSettings):
file_storage_s3_access_key_id: str | None = Field(default=None, alias="FILE_STORAGE_S3_ACCESS_KEY_ID")
file_storage_s3_secret_access_key: str | None = Field(default=None, alias="FILE_STORAGE_S3_SECRET_ACCESS_KEY")
file_storage_s3_bucket: str | None = Field(default="files", alias="FILE_STORAGE_S3_BUCKET")
file_storage_s3_deployment_managed: bool = Field(
default=False,
alias="FILE_STORAGE_S3_DEPLOYMENT_MANAGED",
)
file_upload_max_bytes: int = Field(default=50 * 1024 * 1024, alias="FILE_UPLOAD_MAX_BYTES")
file_upload_zip_max_bytes: int = Field(default=250 * 1024 * 1024, alias="FILE_UPLOAD_ZIP_MAX_BYTES")
file_archive_max_entries: int = Field(default=10_000, ge=1, alias="FILE_ARCHIVE_MAX_ENTRIES")
file_archive_max_expanded_bytes: int = Field(
default=2 * 1024 * 1024 * 1024,
ge=1,
alias="FILE_ARCHIVE_MAX_EXPANDED_BYTES",
)
file_archive_max_expansion_ratio: int = Field(
default=100,
ge=1,
alias="FILE_ARCHIVE_MAX_EXPANSION_RATIO",
)
file_archive_preview_ttl_seconds: int = Field(
default=30 * 60,
ge=60,
alias="FILE_ARCHIVE_PREVIEW_TTL_SECONDS",
)
auth_session_cookie_name: str = Field(default="govoplan_session", alias="AUTH_SESSION_COOKIE_NAME")
auth_csrf_cookie_name: str = Field(default="govoplan_csrf", alias="AUTH_CSRF_COOKIE_NAME")