324 lines
10 KiB
Python
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",
|
|
]
|