Files
govoplan-workflow-engine/src/govoplan_workflow_engine/backend/governance.py
T

324 lines
10 KiB
Python

from __future__ import annotations
from collections.abc import Mapping
from typing import Literal
from govoplan_core.auth import ApiPrincipal, has_scope
from govoplan_core.core.policy import (
DefinitionGovernanceAction,
DefinitionGovernanceRequest,
DefinitionScopeRef,
PolicyDecision,
PolicySourceStep,
definition_governance_policy,
)
from govoplan_core.core.workflows import workflow_runtime_worker
from govoplan_workflow_engine.backend.db.models import WorkflowDefinition
WorkflowAction = Literal[
"view",
"edit",
"start",
"reuse",
"derive",
"automate",
]
WORKFLOW_ACTIONS: tuple[WorkflowAction, ...] = (
"view",
"edit",
"start",
"reuse",
"derive",
"automate",
)
def normalize_definition_scope(
principal: ApiPrincipal,
*,
scope_type: str,
scope_id: str | None,
administrative: bool,
) -> tuple[str | None, str, str | None, str]:
clean_type = scope_type.strip().casefold()
clean_id = str(scope_id or "").strip() or None
if clean_type == "system":
if not has_scope(principal, "system:governance:write"):
raise PermissionError(
"System definitions require system governance permission."
)
if clean_id is not None:
raise ValueError("System definitions do not carry a scope ID.")
return None, "system", None, "system"
if clean_type == "tenant":
if clean_id not in {None, principal.tenant_id}:
raise PermissionError(
"Definitions can only be created for the active tenant."
)
return (
principal.tenant_id,
"tenant",
principal.tenant_id,
f"tenant:{principal.tenant_id}",
)
if clean_type == "group":
if not clean_id:
raise ValueError("Group definitions require a group ID.")
if clean_id not in principal.group_ids and not administrative:
raise PermissionError(
"Definitions can only be created for one of the actor's groups."
)
return principal.tenant_id, "group", clean_id, f"group:{clean_id}"
if clean_type == "user":
clean_id = clean_id or principal.account_id
if (
clean_id not in {principal.membership_id, principal.account_id}
and not administrative
):
raise PermissionError(
"Definitions can only be created for the current user."
)
if clean_id == principal.membership_id:
clean_id = principal.account_id
return principal.tenant_id, "user", clean_id, f"user:{clean_id}"
raise ValueError(
"Definition scope must be system, tenant, group, or user."
)
def definition_decision(
definition: WorkflowDefinition,
*,
principal: ApiPrincipal,
registry: object | None,
action: WorkflowAction,
) -> PolicyDecision:
policy_action: DefinitionGovernanceAction = (
"run" if action == "start" else action
)
request = DefinitionGovernanceRequest(
module_id="workflow",
definition_ref=f"workflow-definition:{definition.id}",
tenant_id=principal.tenant_id,
definition_scope=DefinitionScopeRef(
scope_type=definition.scope_type, # type: ignore[arg-type]
scope_id=definition.scope_id,
),
target_scope=_target_scope(definition, principal),
definition_kind=definition.definition_kind, # type: ignore[arg-type]
action=policy_action,
actor=principal.to_platform_principal(),
status=definition.status,
inherit_to_lower_scopes=definition.inherit_to_lower_scopes,
allow_run=definition.allow_start,
allow_reuse=definition.allow_reuse,
allow_automation=definition.allow_automation,
context=_ancestor_context(definition),
)
provider = definition_governance_policy(registry)
if provider is not None:
return provider.resolve_definition_action(request=request)
return _tenant_local_fallback(request, displayed_action=action)
def definition_governance_payload(
definition: WorkflowDefinition,
*,
principal: ApiPrincipal,
registry: object | None,
) -> dict[str, object]:
runtime_available = workflow_runtime_worker(registry) is not None
actions = {
action: definition_decision(
definition,
principal=principal,
registry=registry,
action=action,
).to_dict()
for action in WORKFLOW_ACTIONS
}
return {
"scope_type": definition.scope_type,
"scope_id": definition.scope_id,
"definition_kind": definition.definition_kind,
"inherit_to_lower_scopes": definition.inherit_to_lower_scopes,
"allow_start": definition.allow_start,
"allow_reuse": definition.allow_reuse,
"allow_automation": definition.allow_automation,
"derived_from_definition_id": (
definition.derived_from_definition_id
),
"derived_from_revision": definition.derived_from_revision,
"derived_from_hash": definition.derived_from_hash,
"derivation_provenance": dict(definition.derivation_provenance),
"actions": actions,
"automation_runtime_available": runtime_available,
"automation_runtime_reason": (
None
if runtime_available
else "Automatic reconciliation requires the Workflow runtime worker."
),
}
def require_definition_action(
definition: WorkflowDefinition,
*,
principal: ApiPrincipal,
registry: object | None,
action: WorkflowAction,
) -> PolicyDecision:
decision = definition_decision(
definition,
principal=principal,
registry=registry,
action=action,
)
if not decision.allowed:
raise PermissionError(
decision.reason or f"Definition action is not allowed: {action}"
)
return decision
def _target_scope(
definition: WorkflowDefinition,
principal: ApiPrincipal,
) -> DefinitionScopeRef:
if (
definition.scope_type == "group"
and definition.scope_id in principal.group_ids
):
return DefinitionScopeRef("group", definition.scope_id)
if definition.scope_type == "user" and definition.scope_id in {
principal.membership_id,
principal.account_id,
}:
return DefinitionScopeRef("user", definition.scope_id)
return DefinitionScopeRef("tenant", principal.tenant_id)
def _ancestor_context(
definition: WorkflowDefinition,
) -> dict[str, object]:
provenance = definition.derivation_provenance
limits = provenance.get("source_effective_limits")
source = provenance.get("source_scope")
context: dict[str, object] = {}
if isinstance(limits, Mapping):
context["ancestor_limits"] = {
"inherit_to_lower_scopes": bool(
limits.get("inherit_to_lower_scopes", True)
),
"allow_run": bool(limits.get("allow_start")),
"allow_reuse": bool(limits.get("allow_reuse")),
"allow_automation": bool(limits.get("allow_automation")),
}
if isinstance(source, Mapping):
context["ancestor_source"] = dict(source)
return context
def _tenant_local_fallback(
request: DefinitionGovernanceRequest,
*,
displayed_action: WorkflowAction,
) -> PolicyDecision:
ancestor = request.context.get("ancestor_limits")
ancestor_limits = ancestor if isinstance(ancestor, Mapping) else {}
effective_limits = {
"inherit_to_lower_scopes": (
request.inherit_to_lower_scopes
and _fallback_ancestor_flag(
ancestor_limits,
"inherit_to_lower_scopes",
)
),
"allow_run": request.allow_run
and _fallback_ancestor_flag(ancestor_limits, "allow_run"),
"allow_reuse": request.allow_reuse
and _fallback_ancestor_flag(ancestor_limits, "allow_reuse"),
"allow_automation": request.allow_automation
and _fallback_ancestor_flag(
ancestor_limits,
"allow_automation",
),
}
local = (
request.definition_scope.scope_type == "tenant"
and request.definition_scope.scope_id == request.tenant_id
and request.actor.tenant_id == request.tenant_id
)
allowed = False
reason: str | None = None
if not local:
reason = (
"Inherited definitions require the Policy module; only local "
"tenant definitions are available."
)
elif request.action in {"view", "edit"}:
allowed = True
elif request.action == "run":
allowed = (
request.definition_kind == "flow"
and request.status == "active"
and effective_limits["allow_run"]
)
reason = (
None
if allowed
else "Only active local flows with starting enabled can start."
)
else:
reason = "Definition reuse and automation require the Policy module."
return PolicyDecision(
allowed=allowed,
reason=reason,
source_path=(
PolicySourceStep(
scope_type=request.definition_scope.scope_type,
scope_id=request.definition_scope.scope_id,
label="Tenant-local conservative fallback",
applied_fields=(
"definition_kind",
"status",
"allow_run",
),
policy={
"policy_module_available": False,
"definition_kind": request.definition_kind,
"status": request.status,
"allow_start": request.allow_run,
},
),
),
requirements=(
()
if allowed
else (f"workflow.definition.{displayed_action}",)
),
details={
"fallback": "tenant_local",
"action": displayed_action,
"effective_limits": effective_limits,
},
)
def _fallback_ancestor_flag(
limits: Mapping[str, object],
key: str,
) -> bool:
value = limits.get(key)
return True if value is None else value is True
__all__ = [
"WORKFLOW_ACTIONS",
"definition_decision",
"definition_governance_payload",
"normalize_definition_scope",
"require_definition_action",
]