Add shared automation and WebUI editing primitives
This commit is contained in:
@@ -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",
|
||||
|
||||
@@ -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
|
||||
"""
|
||||
|
||||
@@ -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",
|
||||
]
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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")
|
||||
|
||||
Reference in New Issue
Block a user