Add governed automation contracts

This commit is contained in:
2026-07-28 15:02:42 +02:00
parent baa2143a26
commit 26ae034153
10 changed files with 539 additions and 0 deletions

View File

@@ -28,6 +28,9 @@ CAPABILITY_AUDIT_RECORDER = "audit.recorder"
CAPABILITY_AUDIT_RETENTION = "audit.retention"
CAPABILITY_AUTH_API_PRINCIPAL_PROVIDER = "auth.apiPrincipalProvider"
CAPABILITY_AUTH_AUTOMATION_PRINCIPAL_PROVIDER = (
"auth.automationPrincipalProvider"
)
CAPABILITY_AUTH_PRINCIPAL_RESOLVER = "auth.principalResolver"
CAPABILITY_AUTH_PERMISSION_EVALUATOR = "auth.permissionEvaluator"
CAPABILITY_AUTH_TENANT_CONTEXT_SWITCHER = "auth.tenantContextSwitcher"
@@ -48,6 +51,7 @@ ACCESS_CAPABILITY_NAMES = frozenset(
CAPABILITY_AUDIT_RECORDER,
CAPABILITY_AUDIT_RETENTION,
CAPABILITY_AUTH_API_PRINCIPAL_PROVIDER,
CAPABILITY_AUTH_AUTOMATION_PRINCIPAL_PROVIDER,
CAPABILITY_AUTH_PRINCIPAL_RESOLVER,
CAPABILITY_AUTH_PERMISSION_EVALUATOR,
CAPABILITY_AUTH_TENANT_CONTEXT_SWITCHER,
@@ -57,6 +61,7 @@ ACCESS_CAPABILITY_NAMES = frozenset(
AUTH_CAPABILITY_NAMES = frozenset(
{
CAPABILITY_AUTH_API_PRINCIPAL_PROVIDER,
CAPABILITY_AUTH_AUTOMATION_PRINCIPAL_PROVIDER,
CAPABILITY_AUTH_PRINCIPAL_RESOLVER,
CAPABILITY_AUTH_PERMISSION_EVALUATOR,
CAPABILITY_AUTH_TENANT_CONTEXT_SWITCHER,

View File

@@ -0,0 +1,98 @@
from __future__ import annotations
from collections.abc import Mapping
from dataclasses import dataclass, field
from datetime import datetime
from typing import Literal, Protocol, runtime_checkable
from govoplan_core.core.access import (
CAPABILITY_AUTH_AUTOMATION_PRINCIPAL_PROVIDER,
)
AutomationInvocationKind = Literal[
"manual",
"api",
"schedule",
"event",
"workflow",
"dependency",
"retry",
"backfill",
]
@dataclass(frozen=True, slots=True)
class AutomationInvocation:
kind: AutomationInvocationKind = "manual"
trigger_ref: str | None = None
delivery_ref: str | None = None
event_id: str | None = None
event_type: str | None = None
correlation_id: str | None = None
causation_id: str | None = None
scheduled_for: datetime | None = None
requested_by: str | None = None
metadata: Mapping[str, object] = field(default_factory=dict)
@dataclass(frozen=True, slots=True)
class AutomationPrincipalRequest:
tenant_id: str
account_id: str
membership_id: str
authorization_ref: str
grant_scopes: tuple[str, ...]
context: Mapping[str, object] = field(default_factory=dict)
@dataclass(frozen=True, slots=True)
class AutomationPrincipalResolution:
allowed: bool
principal: object | None = None
reason: str | None = None
granted_scopes: tuple[str, ...] = ()
missing_scopes: tuple[str, ...] = ()
provenance: Mapping[str, object] = field(default_factory=dict)
@runtime_checkable
class AutomationPrincipalProvider(Protocol):
def resolve_automation_principal(
self,
session: object,
*,
request: AutomationPrincipalRequest,
) -> AutomationPrincipalResolution:
...
def automation_principal_provider(
registry: object | None,
) -> AutomationPrincipalProvider | None:
if (
registry is None
or not hasattr(registry, "has_capability")
or not registry.has_capability(
CAPABILITY_AUTH_AUTOMATION_PRINCIPAL_PROVIDER
)
):
return None
capability = registry.capability(
CAPABILITY_AUTH_AUTOMATION_PRINCIPAL_PROVIDER
)
return (
capability
if isinstance(capability, AutomationPrincipalProvider)
else None
)
__all__ = [
"CAPABILITY_AUTH_AUTOMATION_PRINCIPAL_PROVIDER",
"AutomationInvocation",
"AutomationInvocationKind",
"AutomationPrincipalProvider",
"AutomationPrincipalRequest",
"AutomationPrincipalResolution",
"automation_principal_provider",
]

View File

@@ -5,8 +5,12 @@ from dataclasses import dataclass, field
from datetime import datetime
from typing import Protocol, runtime_checkable
from govoplan_core.core.automation import AutomationInvocation
from govoplan_core.core.events import PlatformEvent
CAPABILITY_DATAFLOW_RUN_LIFECYCLE = "dataflow.runLifecycle"
CAPABILITY_DATAFLOW_TRIGGER_DISPATCHER = "dataflow.triggerDispatcher"
class DataflowRunError(ValueError):
@@ -44,6 +48,9 @@ class DataflowRunRequest:
idempotency_key: str
row_limit: int = 500
publication: DataflowPublicationTarget | None = None
invocation: AutomationInvocation = field(
default_factory=AutomationInvocation
)
@dataclass(frozen=True, slots=True)
@@ -59,6 +66,9 @@ class DataflowRunDescriptor:
output_publication_ref: str | None = None
output_datasource_ref: str | None = None
output_materialization_ref: str | None = None
invocation_kind: str = "manual"
trigger_ref: str | None = None
delivery_ref: str | None = None
error: str | None = None
started_at: datetime | None = None
finished_at: datetime | None = None
@@ -96,6 +106,26 @@ class DataflowRunLifecycleProvider(Protocol):
...
@runtime_checkable
class DataflowTriggerDispatcher(Protocol):
def dispatch_due(
self,
session: object,
*,
now: datetime | None = None,
limit: int = 50,
) -> Mapping[str, object]:
...
def ingest_event(
self,
session: object,
*,
event: PlatformEvent,
) -> Mapping[str, object]:
...
def dataflow_run_lifecycle(
registry: object | None,
) -> DataflowRunLifecycleProvider | None:
@@ -103,6 +133,17 @@ def dataflow_run_lifecycle(
return capability if isinstance(capability, DataflowRunLifecycleProvider) else None
def dataflow_trigger_dispatcher(
registry: object | None,
) -> DataflowTriggerDispatcher | None:
capability = _capability(registry, CAPABILITY_DATAFLOW_TRIGGER_DISPATCHER)
return (
capability
if isinstance(capability, DataflowTriggerDispatcher)
else None
)
def _capability(registry: object | None, name: str) -> object | None:
if (
registry is None
@@ -116,6 +157,7 @@ def _capability(registry: object | None, name: str) -> object | None:
__all__ = [
"CAPABILITY_DATAFLOW_RUN_LIFECYCLE",
"CAPABILITY_DATAFLOW_TRIGGER_DISPATCHER",
"DataflowPublicationTarget",
"DataflowRunConflictError",
"DataflowRunDescriptor",
@@ -124,5 +166,7 @@ __all__ = [
"DataflowRunNotFoundError",
"DataflowRunRequest",
"DataflowRunUnavailableError",
"DataflowTriggerDispatcher",
"dataflow_run_lifecycle",
"dataflow_trigger_dispatcher",
]

View File

@@ -4,11 +4,24 @@ from dataclasses import dataclass, field
from typing import Any, Iterable, Literal, Mapping, Protocol, cast, runtime_checkable
from urllib.parse import quote, unquote
from govoplan_core.core.access import PrincipalRef
PolicyScopeType = Literal["system", "tenant", "user", "group", "campaign"]
SchedulingParticipantVisibility = Literal["aggregates_only", "names_and_statuses"]
DefinitionScopeType = Literal["system", "tenant", "group", "user"]
DefinitionKind = Literal["flow", "template"]
DefinitionGovernanceAction = Literal[
"view",
"edit",
"run",
"reuse",
"derive",
"automate",
]
CAPABILITY_POLICY_PRIVACY_RETENTION = "policy.privacyRetention"
CAPABILITY_POLICY_SCHEDULING_PARTICIPANT_PRIVACY = "policy.schedulingParticipantPrivacy"
CAPABILITY_POLICY_DEFINITION_GOVERNANCE = "policy.definitionGovernance"
POLICY_SCOPE_TYPES: tuple[PolicyScopeType, ...] = ("system", "tenant", "user", "group", "campaign")
@@ -128,6 +141,63 @@ class PolicyDecision:
}
@dataclass(frozen=True, slots=True)
class DefinitionScopeRef:
scope_type: DefinitionScopeType
scope_id: str | None = None
@property
def path(self) -> str:
return policy_source_path(self.scope_type, self.scope_id)
@dataclass(frozen=True, slots=True)
class DefinitionGovernanceRequest:
module_id: str
definition_ref: str
tenant_id: str
definition_scope: DefinitionScopeRef
target_scope: DefinitionScopeRef
definition_kind: DefinitionKind
action: DefinitionGovernanceAction
actor: PrincipalRef
status: str = "draft"
inherit_to_lower_scopes: bool = False
allow_run: bool = True
allow_reuse: bool = False
allow_automation: bool = False
context: Mapping[str, Any] = field(default_factory=dict)
@runtime_checkable
class DefinitionGovernancePolicy(Protocol):
def resolve_definition_action(
self,
*,
request: DefinitionGovernanceRequest,
) -> PolicyDecision:
...
def definition_governance_policy(
registry: object | None,
) -> DefinitionGovernancePolicy | None:
if (
registry is None
or not hasattr(registry, "has_capability")
or not registry.has_capability(
CAPABILITY_POLICY_DEFINITION_GOVERNANCE
)
):
return None
capability = registry.capability(CAPABILITY_POLICY_DEFINITION_GOVERNANCE)
return (
capability
if isinstance(capability, DefinitionGovernancePolicy)
else None
)
@dataclass(frozen=True, slots=True)
class SchedulingParticipantPrivacyRequest:
"""Context for resolving what one Scheduling participant may see.