Add governed reusable workflow definitions
This commit is contained in:
@@ -5,6 +5,7 @@ from datetime import datetime
|
||||
from typing import Any
|
||||
|
||||
from sqlalchemy import (
|
||||
Boolean,
|
||||
DateTime,
|
||||
ForeignKey,
|
||||
Index,
|
||||
@@ -27,7 +28,7 @@ class WorkflowDefinition(Base, TimestampMixin):
|
||||
__tablename__ = "workflow_definitions"
|
||||
__table_args__ = (
|
||||
UniqueConstraint(
|
||||
"tenant_id",
|
||||
"scope_key",
|
||||
"definition_key",
|
||||
name="uq_workflow_definition_key",
|
||||
),
|
||||
@@ -36,7 +37,71 @@ class WorkflowDefinition(Base, TimestampMixin):
|
||||
)
|
||||
|
||||
id: Mapped[str] = mapped_column(String(36), primary_key=True, default=new_uuid)
|
||||
tenant_id: Mapped[str] = mapped_column(String(36), nullable=False, index=True)
|
||||
tenant_id: Mapped[str | None] = mapped_column(
|
||||
String(36),
|
||||
nullable=True,
|
||||
index=True,
|
||||
)
|
||||
scope_type: Mapped[str] = mapped_column(
|
||||
String(20),
|
||||
default="tenant",
|
||||
nullable=False,
|
||||
index=True,
|
||||
)
|
||||
scope_id: Mapped[str | None] = mapped_column(
|
||||
String(36),
|
||||
nullable=True,
|
||||
index=True,
|
||||
)
|
||||
scope_key: Mapped[str] = mapped_column(
|
||||
String(80),
|
||||
nullable=False,
|
||||
index=True,
|
||||
)
|
||||
definition_kind: Mapped[str] = mapped_column(
|
||||
String(20),
|
||||
default="flow",
|
||||
nullable=False,
|
||||
index=True,
|
||||
)
|
||||
inherit_to_lower_scopes: Mapped[bool] = mapped_column(
|
||||
Boolean,
|
||||
default=False,
|
||||
nullable=False,
|
||||
)
|
||||
allow_start: Mapped[bool] = mapped_column(
|
||||
Boolean,
|
||||
default=True,
|
||||
nullable=False,
|
||||
)
|
||||
allow_reuse: Mapped[bool] = mapped_column(
|
||||
Boolean,
|
||||
default=False,
|
||||
nullable=False,
|
||||
)
|
||||
allow_automation: Mapped[bool] = mapped_column(
|
||||
Boolean,
|
||||
default=False,
|
||||
nullable=False,
|
||||
)
|
||||
derived_from_definition_id: Mapped[str | None] = mapped_column(
|
||||
String(36),
|
||||
nullable=True,
|
||||
index=True,
|
||||
)
|
||||
derived_from_revision: Mapped[int | None] = mapped_column(
|
||||
Integer,
|
||||
nullable=True,
|
||||
)
|
||||
derived_from_hash: Mapped[str | None] = mapped_column(
|
||||
String(64),
|
||||
nullable=True,
|
||||
)
|
||||
derivation_provenance: Mapped[dict[str, Any]] = mapped_column(
|
||||
JSON,
|
||||
default=dict,
|
||||
nullable=False,
|
||||
)
|
||||
definition_key: Mapped[str] = mapped_column(String(120), nullable=False)
|
||||
name: Mapped[str] = mapped_column(String(300), nullable=False)
|
||||
description: Mapped[str | None] = mapped_column(Text)
|
||||
@@ -98,7 +163,11 @@ class WorkflowDefinitionRevision(Base, TimestampMixin):
|
||||
)
|
||||
|
||||
id: Mapped[str] = mapped_column(String(36), primary_key=True, default=new_uuid)
|
||||
tenant_id: Mapped[str] = mapped_column(String(36), nullable=False, index=True)
|
||||
tenant_id: Mapped[str | None] = mapped_column(
|
||||
String(36),
|
||||
nullable=True,
|
||||
index=True,
|
||||
)
|
||||
definition_id: Mapped[str] = mapped_column(
|
||||
ForeignKey("workflow_definitions.id", ondelete="CASCADE"),
|
||||
nullable=False,
|
||||
|
||||
@@ -0,0 +1,318 @@
|
||||
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_workflow.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.membership_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."
|
||||
)
|
||||
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]:
|
||||
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": False,
|
||||
"automation_runtime_reason": (
|
||||
"Workflow start definitions are persisted and governed, but "
|
||||
"automatic instance dispatch requires the Workflow runtime."
|
||||
),
|
||||
}
|
||||
|
||||
|
||||
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",
|
||||
]
|
||||
@@ -23,6 +23,9 @@ from govoplan_core.core.modules import (
|
||||
PermissionDefinition,
|
||||
RoleTemplate,
|
||||
)
|
||||
from govoplan_core.core.policy import (
|
||||
CAPABILITY_POLICY_DEFINITION_GOVERNANCE,
|
||||
)
|
||||
from govoplan_core.db.base import Base
|
||||
from govoplan_workflow.backend.db import models as workflow_models
|
||||
|
||||
@@ -107,7 +110,10 @@ ROLE_TEMPLATES = (
|
||||
)
|
||||
|
||||
|
||||
def _router(_context: ModuleContext):
|
||||
def _router(context: ModuleContext):
|
||||
from govoplan_workflow.backend.runtime import configure_runtime
|
||||
|
||||
configure_runtime(registry=context.registry, settings=context.settings)
|
||||
from govoplan_workflow.backend.router import router
|
||||
|
||||
return router
|
||||
@@ -131,6 +137,7 @@ manifest = ModuleManifest(
|
||||
CAPABILITY_AUTH_PRINCIPAL_RESOLVER,
|
||||
CAPABILITY_AUTH_PERMISSION_EVALUATOR,
|
||||
CAPABILITY_DATAFLOW_RUN_LIFECYCLE,
|
||||
CAPABILITY_POLICY_DEFINITION_GOVERNANCE,
|
||||
),
|
||||
provides_interfaces=(
|
||||
ModuleInterfaceProvider(name="workflow.definition_graph", version="0.1.0"),
|
||||
@@ -144,6 +151,12 @@ manifest = ModuleManifest(
|
||||
version_max_exclusive="1.0.0",
|
||||
optional=True,
|
||||
),
|
||||
ModuleInterfaceRequirement(
|
||||
name="policy.definition_governance",
|
||||
version_min="0.1.0",
|
||||
version_max_exclusive="1.0.0",
|
||||
optional=True,
|
||||
),
|
||||
),
|
||||
permissions=PERMISSIONS,
|
||||
role_templates=ROLE_TEMPLATES,
|
||||
|
||||
+209
@@ -0,0 +1,209 @@
|
||||
"""v0.1.14 governed Workflow definitions
|
||||
|
||||
Revision ID: c6d8f1a3e5b7
|
||||
Revises: a7c4e2f9b1d3
|
||||
Create Date: 2026-07-28 00:00:00.000000
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
from alembic import op
|
||||
import sqlalchemy as sa
|
||||
|
||||
|
||||
revision = "c6d8f1a3e5b7"
|
||||
down_revision = "a7c4e2f9b1d3"
|
||||
branch_labels = None
|
||||
depends_on = None
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
with op.batch_alter_table("workflow_definitions") as batch_op:
|
||||
batch_op.drop_constraint(
|
||||
"uq_workflow_definition_key",
|
||||
type_="unique",
|
||||
)
|
||||
batch_op.alter_column(
|
||||
"tenant_id",
|
||||
existing_type=sa.String(length=36),
|
||||
nullable=True,
|
||||
)
|
||||
batch_op.add_column(
|
||||
sa.Column(
|
||||
"scope_type",
|
||||
sa.String(length=20),
|
||||
nullable=False,
|
||||
server_default="tenant",
|
||||
)
|
||||
)
|
||||
batch_op.add_column(
|
||||
sa.Column("scope_id", sa.String(length=36), nullable=True)
|
||||
)
|
||||
batch_op.add_column(
|
||||
sa.Column(
|
||||
"scope_key",
|
||||
sa.String(length=80),
|
||||
nullable=False,
|
||||
server_default="tenant:legacy",
|
||||
)
|
||||
)
|
||||
batch_op.add_column(
|
||||
sa.Column(
|
||||
"definition_kind",
|
||||
sa.String(length=20),
|
||||
nullable=False,
|
||||
server_default="flow",
|
||||
)
|
||||
)
|
||||
batch_op.add_column(
|
||||
sa.Column(
|
||||
"inherit_to_lower_scopes",
|
||||
sa.Boolean(),
|
||||
nullable=False,
|
||||
server_default=sa.false(),
|
||||
)
|
||||
)
|
||||
batch_op.add_column(
|
||||
sa.Column(
|
||||
"allow_start",
|
||||
sa.Boolean(),
|
||||
nullable=False,
|
||||
server_default=sa.true(),
|
||||
)
|
||||
)
|
||||
batch_op.add_column(
|
||||
sa.Column(
|
||||
"allow_reuse",
|
||||
sa.Boolean(),
|
||||
nullable=False,
|
||||
server_default=sa.false(),
|
||||
)
|
||||
)
|
||||
batch_op.add_column(
|
||||
sa.Column(
|
||||
"allow_automation",
|
||||
sa.Boolean(),
|
||||
nullable=False,
|
||||
server_default=sa.false(),
|
||||
)
|
||||
)
|
||||
batch_op.add_column(
|
||||
sa.Column(
|
||||
"derived_from_definition_id",
|
||||
sa.String(length=36),
|
||||
nullable=True,
|
||||
)
|
||||
)
|
||||
batch_op.add_column(
|
||||
sa.Column("derived_from_revision", sa.Integer(), nullable=True)
|
||||
)
|
||||
batch_op.add_column(
|
||||
sa.Column(
|
||||
"derived_from_hash",
|
||||
sa.String(length=64),
|
||||
nullable=True,
|
||||
)
|
||||
)
|
||||
batch_op.add_column(
|
||||
sa.Column(
|
||||
"derivation_provenance",
|
||||
sa.JSON(),
|
||||
nullable=False,
|
||||
server_default=sa.text("'{}'"),
|
||||
)
|
||||
)
|
||||
op.execute(
|
||||
sa.text(
|
||||
"UPDATE workflow_definitions "
|
||||
"SET scope_id = tenant_id, "
|
||||
"scope_key = 'tenant:' || tenant_id "
|
||||
"WHERE tenant_id IS NOT NULL"
|
||||
)
|
||||
)
|
||||
with op.batch_alter_table("workflow_definitions") as batch_op:
|
||||
batch_op.create_unique_constraint(
|
||||
"uq_workflow_definition_key",
|
||||
["scope_key", "definition_key"],
|
||||
)
|
||||
for column in (
|
||||
"scope_type",
|
||||
"scope_id",
|
||||
"scope_key",
|
||||
"definition_kind",
|
||||
"derived_from_definition_id",
|
||||
):
|
||||
op.create_index(
|
||||
op.f(f"ix_workflow_definitions_{column}"),
|
||||
"workflow_definitions",
|
||||
[column],
|
||||
unique=False,
|
||||
)
|
||||
|
||||
with op.batch_alter_table(
|
||||
"workflow_definition_revisions"
|
||||
) as batch_op:
|
||||
batch_op.alter_column(
|
||||
"tenant_id",
|
||||
existing_type=sa.String(length=36),
|
||||
nullable=True,
|
||||
)
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
op.execute(
|
||||
sa.text(
|
||||
"UPDATE workflow_definition_revisions "
|
||||
"SET tenant_id = COALESCE(tenant_id, 'system')"
|
||||
)
|
||||
)
|
||||
with op.batch_alter_table(
|
||||
"workflow_definition_revisions"
|
||||
) as batch_op:
|
||||
batch_op.alter_column(
|
||||
"tenant_id",
|
||||
existing_type=sa.String(length=36),
|
||||
nullable=False,
|
||||
)
|
||||
|
||||
for column in (
|
||||
"derived_from_definition_id",
|
||||
"definition_kind",
|
||||
"scope_key",
|
||||
"scope_id",
|
||||
"scope_type",
|
||||
):
|
||||
op.drop_index(
|
||||
op.f(f"ix_workflow_definitions_{column}"),
|
||||
table_name="workflow_definitions",
|
||||
)
|
||||
op.execute(
|
||||
sa.text(
|
||||
"UPDATE workflow_definitions "
|
||||
"SET tenant_id = COALESCE(tenant_id, 'system')"
|
||||
)
|
||||
)
|
||||
with op.batch_alter_table("workflow_definitions") as batch_op:
|
||||
batch_op.drop_constraint(
|
||||
"uq_workflow_definition_key",
|
||||
type_="unique",
|
||||
)
|
||||
batch_op.create_unique_constraint(
|
||||
"uq_workflow_definition_key",
|
||||
["tenant_id", "definition_key"],
|
||||
)
|
||||
batch_op.drop_column("derivation_provenance")
|
||||
batch_op.drop_column("derived_from_hash")
|
||||
batch_op.drop_column("derived_from_revision")
|
||||
batch_op.drop_column("derived_from_definition_id")
|
||||
batch_op.drop_column("allow_automation")
|
||||
batch_op.drop_column("allow_reuse")
|
||||
batch_op.drop_column("allow_start")
|
||||
batch_op.drop_column("inherit_to_lower_scopes")
|
||||
batch_op.drop_column("definition_kind")
|
||||
batch_op.drop_column("scope_key")
|
||||
batch_op.drop_column("scope_id")
|
||||
batch_op.drop_column("scope_type")
|
||||
batch_op.alter_column(
|
||||
"tenant_id",
|
||||
existing_type=sa.String(length=36),
|
||||
nullable=False,
|
||||
)
|
||||
@@ -24,7 +24,7 @@ WORKFLOW_NODE_TYPES = (
|
||||
type="workflow.start.manual",
|
||||
category="trigger",
|
||||
label="Manual start",
|
||||
description="Start an instance through an explicit user or API action.",
|
||||
description="Start an instance through an explicit user action.",
|
||||
icon="circle-play",
|
||||
default_config={"input_schema_ref": ""},
|
||||
config_fields=(
|
||||
@@ -36,6 +36,68 @@ WORKFLOW_NODE_TYPES = (
|
||||
),
|
||||
),
|
||||
),
|
||||
DefinitionNodeType(
|
||||
type="workflow.start.api",
|
||||
category="trigger",
|
||||
label="API start",
|
||||
description=(
|
||||
"Start through an authenticated API request with an explicit "
|
||||
"input contract."
|
||||
),
|
||||
icon="braces",
|
||||
default_config={
|
||||
"input_schema_ref": "",
|
||||
"authorization_policy_ref": "",
|
||||
},
|
||||
config_fields=(
|
||||
DefinitionConfigField(
|
||||
id="input_schema_ref",
|
||||
label="Input schema",
|
||||
kind="text",
|
||||
required=True,
|
||||
),
|
||||
DefinitionConfigField(
|
||||
id="authorization_policy_ref",
|
||||
label="Authorization policy",
|
||||
kind="text",
|
||||
required=True,
|
||||
),
|
||||
),
|
||||
),
|
||||
DefinitionNodeType(
|
||||
type="workflow.start.workflow",
|
||||
category="trigger",
|
||||
label="Parent workflow",
|
||||
description=(
|
||||
"Start as a pinned child or dependency of another Workflow "
|
||||
"instance."
|
||||
),
|
||||
icon="git-branch",
|
||||
default_config={
|
||||
"parent_definition_ref": "",
|
||||
"parent_outcome": "completed",
|
||||
"input_mapping": {},
|
||||
},
|
||||
config_fields=(
|
||||
DefinitionConfigField(
|
||||
id="parent_definition_ref",
|
||||
label="Parent definition",
|
||||
kind="text",
|
||||
required=True,
|
||||
),
|
||||
DefinitionConfigField(
|
||||
id="parent_outcome",
|
||||
label="Parent outcome",
|
||||
kind="text",
|
||||
required=True,
|
||||
),
|
||||
DefinitionConfigField(
|
||||
id="input_mapping",
|
||||
label="Input mapping",
|
||||
kind="mapping",
|
||||
),
|
||||
),
|
||||
),
|
||||
DefinitionNodeType(
|
||||
type="workflow.start.event",
|
||||
category="trigger",
|
||||
|
||||
@@ -6,6 +6,11 @@ from sqlalchemy.orm import Session
|
||||
from govoplan_core.audit.logging import audit_event
|
||||
from govoplan_core.auth import ApiPrincipal, get_api_principal, has_scope
|
||||
from govoplan_core.db.session import get_session
|
||||
from govoplan_workflow.backend.governance import (
|
||||
definition_decision,
|
||||
normalize_definition_scope,
|
||||
require_definition_action,
|
||||
)
|
||||
from govoplan_workflow.backend.manifest import (
|
||||
ADMIN_SCOPE,
|
||||
DEFINITION_READ_SCOPE,
|
||||
@@ -17,6 +22,7 @@ from govoplan_workflow.backend.schemas import (
|
||||
WorkflowDefinitionActivateRequest,
|
||||
WorkflowDefinitionCreateRequest,
|
||||
WorkflowDefinitionDeleteResponse,
|
||||
WorkflowDefinitionDeriveRequest,
|
||||
WorkflowDefinitionListResponse,
|
||||
WorkflowDefinitionResponse,
|
||||
WorkflowDefinitionRevisionListResponse,
|
||||
@@ -29,6 +35,7 @@ from govoplan_workflow.backend.schemas import (
|
||||
WorkflowNodeTypeResponse,
|
||||
WorkflowPortResponse,
|
||||
)
|
||||
from govoplan_workflow.backend.runtime import get_registry
|
||||
from govoplan_workflow.backend.service import (
|
||||
WorkflowConflictError,
|
||||
WorkflowError,
|
||||
@@ -39,6 +46,7 @@ from govoplan_workflow.backend.service import (
|
||||
create_definition,
|
||||
definition_response,
|
||||
delete_definition,
|
||||
derive_definition,
|
||||
get_definition,
|
||||
get_definition_revision,
|
||||
list_definition_revisions,
|
||||
@@ -89,6 +97,35 @@ def _http_error(exc: WorkflowError) -> HTTPException:
|
||||
)
|
||||
|
||||
|
||||
def _governance_http_error(
|
||||
exc: PermissionError | ValueError,
|
||||
) -> HTTPException:
|
||||
return HTTPException(
|
||||
status_code=(
|
||||
status.HTTP_403_FORBIDDEN
|
||||
if isinstance(exc, PermissionError)
|
||||
else status.HTTP_422_UNPROCESSABLE_CONTENT
|
||||
),
|
||||
detail=str(exc),
|
||||
)
|
||||
|
||||
|
||||
def _definition_response(
|
||||
session: Session,
|
||||
definition,
|
||||
principal: ApiPrincipal,
|
||||
*,
|
||||
revision: int | None = None,
|
||||
) -> WorkflowDefinitionResponse:
|
||||
return definition_response(
|
||||
session,
|
||||
definition,
|
||||
principal=principal,
|
||||
registry=get_registry(),
|
||||
revision=revision,
|
||||
)
|
||||
|
||||
|
||||
def _actor_id(principal: ApiPrincipal) -> str | None:
|
||||
return principal.account_id or principal.membership_id or principal.identity_id
|
||||
|
||||
@@ -209,13 +246,29 @@ def api_list_definitions(
|
||||
principal: ApiPrincipal = Depends(get_api_principal),
|
||||
) -> WorkflowDefinitionListResponse:
|
||||
_require_any_scope(principal, DEFINITION_READ_SCOPE, ADMIN_SCOPE)
|
||||
registry = get_registry()
|
||||
definitions = [
|
||||
definition
|
||||
for definition in list_definitions(
|
||||
session,
|
||||
tenant_id=principal.tenant_id,
|
||||
)
|
||||
if definition_decision(
|
||||
definition,
|
||||
principal=principal,
|
||||
registry=registry,
|
||||
action="view",
|
||||
).allowed
|
||||
]
|
||||
return WorkflowDefinitionListResponse(
|
||||
definitions=[
|
||||
definition_response(session, definition)
|
||||
for definition in list_definitions(
|
||||
definition_response(
|
||||
session,
|
||||
tenant_id=principal.tenant_id,
|
||||
definition,
|
||||
principal=principal,
|
||||
registry=registry,
|
||||
)
|
||||
for definition in definitions
|
||||
]
|
||||
)
|
||||
|
||||
@@ -232,12 +285,25 @@ def api_create_definition(
|
||||
) -> WorkflowDefinitionResponse:
|
||||
_require_any_scope(principal, DEFINITION_WRITE_SCOPE, ADMIN_SCOPE)
|
||||
try:
|
||||
tenant_id, scope_type, scope_id, _scope_key = (
|
||||
normalize_definition_scope(
|
||||
principal,
|
||||
scope_type=payload.scope_type,
|
||||
scope_id=payload.scope_id,
|
||||
administrative=has_scope(principal, ADMIN_SCOPE),
|
||||
)
|
||||
)
|
||||
payload = payload.model_copy(
|
||||
update={"scope_type": scope_type, "scope_id": scope_id}
|
||||
)
|
||||
definition = create_definition(
|
||||
session,
|
||||
tenant_id=principal.tenant_id,
|
||||
tenant_id=tenant_id or principal.tenant_id,
|
||||
actor_id=_actor_id(principal),
|
||||
payload=payload,
|
||||
)
|
||||
except (PermissionError, ValueError) as exc:
|
||||
raise _governance_http_error(exc) from exc
|
||||
except WorkflowError as exc:
|
||||
raise _http_error(exc) from exc
|
||||
_audit(
|
||||
@@ -248,9 +314,12 @@ def api_create_definition(
|
||||
details={
|
||||
"key": definition.definition_key,
|
||||
"revision": definition.current_revision,
|
||||
"scope_type": definition.scope_type,
|
||||
"scope_id": definition.scope_id,
|
||||
"definition_kind": definition.definition_kind,
|
||||
},
|
||||
)
|
||||
response = definition_response(session, definition)
|
||||
response = _definition_response(session, definition, principal)
|
||||
session.commit()
|
||||
return response
|
||||
|
||||
@@ -272,7 +341,20 @@ def api_get_definition(
|
||||
tenant_id=principal.tenant_id,
|
||||
definition_id=definition_id,
|
||||
)
|
||||
return definition_response(session, definition, revision=revision)
|
||||
require_definition_action(
|
||||
definition,
|
||||
principal=principal,
|
||||
registry=get_registry(),
|
||||
action="view",
|
||||
)
|
||||
return _definition_response(
|
||||
session,
|
||||
definition,
|
||||
principal,
|
||||
revision=revision,
|
||||
)
|
||||
except PermissionError as exc:
|
||||
raise _governance_http_error(exc) from exc
|
||||
except WorkflowError as exc:
|
||||
raise _http_error(exc) from exc
|
||||
|
||||
@@ -289,6 +371,17 @@ def api_update_definition(
|
||||
) -> WorkflowDefinitionResponse:
|
||||
_require_any_scope(principal, DEFINITION_WRITE_SCOPE, ADMIN_SCOPE)
|
||||
try:
|
||||
existing = get_definition(
|
||||
session,
|
||||
tenant_id=principal.tenant_id,
|
||||
definition_id=definition_id,
|
||||
)
|
||||
require_definition_action(
|
||||
existing,
|
||||
principal=principal,
|
||||
registry=get_registry(),
|
||||
action="edit",
|
||||
)
|
||||
definition = update_definition(
|
||||
session,
|
||||
tenant_id=principal.tenant_id,
|
||||
@@ -296,6 +389,8 @@ def api_update_definition(
|
||||
actor_id=_actor_id(principal),
|
||||
payload=payload,
|
||||
)
|
||||
except PermissionError as exc:
|
||||
raise _governance_http_error(exc) from exc
|
||||
except WorkflowError as exc:
|
||||
raise _http_error(exc) from exc
|
||||
_audit(
|
||||
@@ -308,7 +403,64 @@ def api_update_definition(
|
||||
"status": definition.status,
|
||||
},
|
||||
)
|
||||
response = definition_response(session, definition)
|
||||
response = _definition_response(session, definition, principal)
|
||||
session.commit()
|
||||
return response
|
||||
|
||||
|
||||
@router.post(
|
||||
"/definitions/{definition_id}/derive",
|
||||
response_model=WorkflowDefinitionResponse,
|
||||
status_code=status.HTTP_201_CREATED,
|
||||
)
|
||||
def api_derive_definition(
|
||||
definition_id: str,
|
||||
payload: WorkflowDefinitionDeriveRequest,
|
||||
session: Session = Depends(get_session),
|
||||
principal: ApiPrincipal = Depends(get_api_principal),
|
||||
) -> WorkflowDefinitionResponse:
|
||||
_require_any_scope(principal, DEFINITION_WRITE_SCOPE, ADMIN_SCOPE)
|
||||
try:
|
||||
tenant_id, scope_type, scope_id, _scope_key = (
|
||||
normalize_definition_scope(
|
||||
principal,
|
||||
scope_type=payload.scope_type,
|
||||
scope_id=payload.scope_id,
|
||||
administrative=has_scope(principal, ADMIN_SCOPE),
|
||||
)
|
||||
)
|
||||
payload = payload.model_copy(
|
||||
update={"scope_type": scope_type, "scope_id": scope_id}
|
||||
)
|
||||
definition = derive_definition(
|
||||
session,
|
||||
tenant_id=tenant_id or principal.tenant_id,
|
||||
actor_id=_actor_id(principal),
|
||||
principal=principal,
|
||||
registry=get_registry(),
|
||||
source_definition_id=definition_id,
|
||||
payload=payload,
|
||||
)
|
||||
except (PermissionError, ValueError) as exc:
|
||||
raise _governance_http_error(exc) from exc
|
||||
except WorkflowError as exc:
|
||||
raise _http_error(exc) from exc
|
||||
_audit(
|
||||
session,
|
||||
principal,
|
||||
action="workflow.definition.derived",
|
||||
definition_id=definition.id,
|
||||
details={
|
||||
"source_definition_id": (
|
||||
definition.derived_from_definition_id
|
||||
),
|
||||
"source_revision": definition.derived_from_revision,
|
||||
"source_hash": definition.derived_from_hash,
|
||||
"scope_type": definition.scope_type,
|
||||
"scope_id": definition.scope_id,
|
||||
},
|
||||
)
|
||||
response = _definition_response(session, definition, principal)
|
||||
session.commit()
|
||||
return response
|
||||
|
||||
@@ -329,7 +481,15 @@ def api_list_definition_revisions(
|
||||
tenant_id=principal.tenant_id,
|
||||
definition_id=definition_id,
|
||||
)
|
||||
require_definition_action(
|
||||
definition,
|
||||
principal=principal,
|
||||
registry=get_registry(),
|
||||
action="view",
|
||||
)
|
||||
revisions = list_definition_revisions(session, definition=definition)
|
||||
except PermissionError as exc:
|
||||
raise _governance_http_error(exc) from exc
|
||||
except WorkflowError as exc:
|
||||
raise _http_error(exc) from exc
|
||||
return WorkflowDefinitionRevisionListResponse(
|
||||
@@ -354,11 +514,19 @@ def api_get_definition_revision(
|
||||
tenant_id=principal.tenant_id,
|
||||
definition_id=definition_id,
|
||||
)
|
||||
require_definition_action(
|
||||
definition,
|
||||
principal=principal,
|
||||
registry=get_registry(),
|
||||
action="view",
|
||||
)
|
||||
item = get_definition_revision(
|
||||
session,
|
||||
definition=definition,
|
||||
revision=revision,
|
||||
)
|
||||
except PermissionError as exc:
|
||||
raise _governance_http_error(exc) from exc
|
||||
except WorkflowError as exc:
|
||||
raise _http_error(exc) from exc
|
||||
return revision_response(item)
|
||||
@@ -376,6 +544,17 @@ def api_activate_definition(
|
||||
) -> WorkflowDefinitionResponse:
|
||||
_require_any_scope(principal, DEFINITION_WRITE_SCOPE, ADMIN_SCOPE)
|
||||
try:
|
||||
existing = get_definition(
|
||||
session,
|
||||
tenant_id=principal.tenant_id,
|
||||
definition_id=definition_id,
|
||||
)
|
||||
require_definition_action(
|
||||
existing,
|
||||
principal=principal,
|
||||
registry=get_registry(),
|
||||
action="edit",
|
||||
)
|
||||
definition = activate_definition(
|
||||
session,
|
||||
tenant_id=principal.tenant_id,
|
||||
@@ -383,6 +562,8 @@ def api_activate_definition(
|
||||
actor_id=_actor_id(principal),
|
||||
revision=payload.revision,
|
||||
)
|
||||
except PermissionError as exc:
|
||||
raise _governance_http_error(exc) from exc
|
||||
except WorkflowError as exc:
|
||||
raise _http_error(exc) from exc
|
||||
_audit(
|
||||
@@ -392,7 +573,7 @@ def api_activate_definition(
|
||||
definition_id=definition.id,
|
||||
details={"active_revision": definition.active_revision},
|
||||
)
|
||||
response = definition_response(session, definition)
|
||||
response = _definition_response(session, definition, principal)
|
||||
session.commit()
|
||||
return response
|
||||
|
||||
@@ -408,12 +589,25 @@ def api_archive_definition(
|
||||
) -> WorkflowDefinitionResponse:
|
||||
_require_any_scope(principal, DEFINITION_WRITE_SCOPE, ADMIN_SCOPE)
|
||||
try:
|
||||
existing = get_definition(
|
||||
session,
|
||||
tenant_id=principal.tenant_id,
|
||||
definition_id=definition_id,
|
||||
)
|
||||
require_definition_action(
|
||||
existing,
|
||||
principal=principal,
|
||||
registry=get_registry(),
|
||||
action="edit",
|
||||
)
|
||||
definition = archive_definition(
|
||||
session,
|
||||
tenant_id=principal.tenant_id,
|
||||
definition_id=definition_id,
|
||||
actor_id=_actor_id(principal),
|
||||
)
|
||||
except PermissionError as exc:
|
||||
raise _governance_http_error(exc) from exc
|
||||
except WorkflowError as exc:
|
||||
raise _http_error(exc) from exc
|
||||
_audit(
|
||||
@@ -423,7 +617,7 @@ def api_archive_definition(
|
||||
definition_id=definition.id,
|
||||
details={"active_revision": definition.active_revision},
|
||||
)
|
||||
response = definition_response(session, definition)
|
||||
response = _definition_response(session, definition, principal)
|
||||
session.commit()
|
||||
return response
|
||||
|
||||
@@ -439,12 +633,25 @@ def api_delete_definition(
|
||||
) -> WorkflowDefinitionDeleteResponse:
|
||||
_require_any_scope(principal, DEFINITION_WRITE_SCOPE, ADMIN_SCOPE)
|
||||
try:
|
||||
existing = get_definition(
|
||||
session,
|
||||
tenant_id=principal.tenant_id,
|
||||
definition_id=definition_id,
|
||||
)
|
||||
require_definition_action(
|
||||
existing,
|
||||
principal=principal,
|
||||
registry=get_registry(),
|
||||
action="edit",
|
||||
)
|
||||
definition = delete_definition(
|
||||
session,
|
||||
tenant_id=principal.tenant_id,
|
||||
definition_id=definition_id,
|
||||
actor_id=_actor_id(principal),
|
||||
)
|
||||
except PermissionError as exc:
|
||||
raise _governance_http_error(exc) from exc
|
||||
except WorkflowError as exc:
|
||||
raise _http_error(exc) from exc
|
||||
_audit(
|
||||
|
||||
@@ -0,0 +1,11 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from govoplan_core.core.runtime import ModuleRuntimeState
|
||||
|
||||
|
||||
_runtime = ModuleRuntimeState("Workflow")
|
||||
|
||||
configure_runtime = _runtime.configure_runtime
|
||||
get_registry = _runtime.get_registry
|
||||
get_settings = _runtime.get_settings
|
||||
settings = _runtime.settings
|
||||
@@ -8,6 +8,8 @@ from pydantic import BaseModel, Field, field_validator
|
||||
|
||||
|
||||
WorkflowDefinitionStatus = Literal["draft", "active", "archived"]
|
||||
DefinitionScopeType = Literal["system", "tenant", "group", "user"]
|
||||
DefinitionKind = Literal["flow", "template"]
|
||||
|
||||
|
||||
class WorkflowPosition(BaseModel):
|
||||
@@ -110,9 +112,34 @@ class WorkflowDefinitionRevisionResponse(BaseModel):
|
||||
created_at: datetime
|
||||
|
||||
|
||||
class WorkflowActionDecisionResponse(BaseModel):
|
||||
allowed: bool
|
||||
reason: str | None = None
|
||||
source_path: list[dict[str, Any]] = Field(default_factory=list)
|
||||
requirements: list[str] = Field(default_factory=list)
|
||||
details: dict[str, Any] = Field(default_factory=dict)
|
||||
|
||||
|
||||
class WorkflowGovernanceResponse(BaseModel):
|
||||
scope_type: DefinitionScopeType
|
||||
scope_id: str | None
|
||||
definition_kind: DefinitionKind
|
||||
inherit_to_lower_scopes: bool
|
||||
allow_start: bool
|
||||
allow_reuse: bool
|
||||
allow_automation: bool
|
||||
derived_from_definition_id: str | None
|
||||
derived_from_revision: int | None
|
||||
derived_from_hash: str | None
|
||||
derivation_provenance: dict[str, Any] = Field(default_factory=dict)
|
||||
actions: dict[str, WorkflowActionDecisionResponse]
|
||||
automation_runtime_available: bool = False
|
||||
automation_runtime_reason: str | None = None
|
||||
|
||||
|
||||
class WorkflowDefinitionResponse(BaseModel):
|
||||
id: str
|
||||
tenant_id: str
|
||||
tenant_id: str | None
|
||||
key: str
|
||||
name: str
|
||||
description: str | None
|
||||
@@ -125,6 +152,7 @@ class WorkflowDefinitionResponse(BaseModel):
|
||||
created_at: datetime
|
||||
updated_at: datetime
|
||||
revision: WorkflowDefinitionRevisionResponse
|
||||
governance: WorkflowGovernanceResponse
|
||||
|
||||
|
||||
class WorkflowDefinitionListResponse(BaseModel):
|
||||
@@ -146,6 +174,13 @@ class WorkflowDefinitionCreateRequest(BaseModel):
|
||||
description: str | None = Field(default=None, max_length=4_000)
|
||||
graph: WorkflowGraph
|
||||
metadata: dict[str, Any] = Field(default_factory=dict)
|
||||
scope_type: DefinitionScopeType = "tenant"
|
||||
scope_id: str | None = Field(default=None, max_length=36)
|
||||
definition_kind: DefinitionKind = "flow"
|
||||
inherit_to_lower_scopes: bool = False
|
||||
allow_start: bool = True
|
||||
allow_reuse: bool = False
|
||||
allow_automation: bool = False
|
||||
|
||||
|
||||
class WorkflowDefinitionUpdateRequest(BaseModel):
|
||||
@@ -154,6 +189,33 @@ class WorkflowDefinitionUpdateRequest(BaseModel):
|
||||
graph: WorkflowGraph
|
||||
metadata: dict[str, Any] = Field(default_factory=dict)
|
||||
expected_revision: int = Field(ge=1)
|
||||
scope_type: DefinitionScopeType = "tenant"
|
||||
scope_id: str | None = Field(default=None, max_length=36)
|
||||
definition_kind: DefinitionKind = "flow"
|
||||
inherit_to_lower_scopes: bool = False
|
||||
allow_start: bool = True
|
||||
allow_reuse: bool = False
|
||||
allow_automation: bool = False
|
||||
|
||||
|
||||
class WorkflowDefinitionDeriveRequest(BaseModel):
|
||||
key: str | None = Field(
|
||||
default=None,
|
||||
min_length=1,
|
||||
max_length=120,
|
||||
pattern=r"^[A-Za-z0-9][A-Za-z0-9_-]*$",
|
||||
)
|
||||
name: str = Field(min_length=1, max_length=300)
|
||||
description: str | None = Field(default=None, max_length=4_000)
|
||||
source_revision: int | None = Field(default=None, ge=1)
|
||||
metadata: dict[str, Any] = Field(default_factory=dict)
|
||||
scope_type: DefinitionScopeType = "tenant"
|
||||
scope_id: str | None = Field(default=None, max_length=36)
|
||||
definition_kind: DefinitionKind = "flow"
|
||||
inherit_to_lower_scopes: bool = False
|
||||
allow_start: bool = True
|
||||
allow_reuse: bool = False
|
||||
allow_automation: bool = False
|
||||
|
||||
|
||||
class WorkflowDefinitionActivateRequest(BaseModel):
|
||||
|
||||
@@ -1,21 +1,28 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from collections.abc import Mapping
|
||||
import hashlib
|
||||
import json
|
||||
import re
|
||||
import unicodedata
|
||||
|
||||
from sqlalchemy import select
|
||||
from sqlalchemy import or_, select
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from govoplan_core.auth import ApiPrincipal
|
||||
from govoplan_core.db.base import utcnow
|
||||
from govoplan_workflow.backend.db.models import (
|
||||
WorkflowDefinition,
|
||||
WorkflowDefinitionRevision,
|
||||
)
|
||||
from govoplan_workflow.backend.node_library import WORKFLOW_GRAPH_LIBRARY
|
||||
from govoplan_workflow.backend.governance import (
|
||||
definition_governance_payload,
|
||||
require_definition_action,
|
||||
)
|
||||
from govoplan_workflow.backend.schemas import (
|
||||
WorkflowDefinitionCreateRequest,
|
||||
WorkflowDefinitionDeriveRequest,
|
||||
WorkflowDefinitionResponse,
|
||||
WorkflowDefinitionRevisionResponse,
|
||||
WorkflowDefinitionUpdateRequest,
|
||||
@@ -54,7 +61,10 @@ def list_definitions(
|
||||
session.scalars(
|
||||
select(WorkflowDefinition)
|
||||
.where(
|
||||
WorkflowDefinition.tenant_id == tenant_id,
|
||||
or_(
|
||||
WorkflowDefinition.tenant_id == tenant_id,
|
||||
WorkflowDefinition.tenant_id.is_(None),
|
||||
),
|
||||
WorkflowDefinition.deleted_at.is_(None),
|
||||
)
|
||||
.order_by(
|
||||
@@ -74,7 +84,10 @@ def get_definition(
|
||||
definition = session.scalar(
|
||||
select(WorkflowDefinition).where(
|
||||
WorkflowDefinition.id == definition_id,
|
||||
WorkflowDefinition.tenant_id == tenant_id,
|
||||
or_(
|
||||
WorkflowDefinition.tenant_id == tenant_id,
|
||||
WorkflowDefinition.tenant_id.is_(None),
|
||||
),
|
||||
WorkflowDefinition.deleted_at.is_(None),
|
||||
)
|
||||
)
|
||||
@@ -127,11 +140,34 @@ def create_definition(
|
||||
payload: WorkflowDefinitionCreateRequest,
|
||||
) -> WorkflowDefinition:
|
||||
graph = _validated_graph(payload.graph)
|
||||
stored_tenant_id = (
|
||||
None if payload.scope_type == "system" else tenant_id
|
||||
)
|
||||
scope_id = (
|
||||
None
|
||||
if payload.scope_type == "system"
|
||||
else tenant_id
|
||||
if payload.scope_type == "tenant"
|
||||
else payload.scope_id
|
||||
)
|
||||
scope_key = (
|
||||
"system"
|
||||
if payload.scope_type == "system"
|
||||
else f"{payload.scope_type}:{scope_id}"
|
||||
)
|
||||
definition = WorkflowDefinition(
|
||||
tenant_id=tenant_id,
|
||||
tenant_id=stored_tenant_id,
|
||||
scope_type=payload.scope_type,
|
||||
scope_id=scope_id,
|
||||
scope_key=scope_key,
|
||||
definition_kind=payload.definition_kind,
|
||||
inherit_to_lower_scopes=payload.inherit_to_lower_scopes,
|
||||
allow_start=payload.allow_start,
|
||||
allow_reuse=payload.allow_reuse,
|
||||
allow_automation=payload.allow_automation,
|
||||
definition_key=_available_key(
|
||||
session,
|
||||
tenant_id=tenant_id,
|
||||
scope_key=scope_key,
|
||||
requested=payload.key,
|
||||
name=payload.name,
|
||||
),
|
||||
@@ -146,7 +182,7 @@ def create_definition(
|
||||
)
|
||||
definition.revisions.append(
|
||||
_new_revision(
|
||||
tenant_id=tenant_id,
|
||||
tenant_id=stored_tenant_id,
|
||||
revision=1,
|
||||
graph=graph,
|
||||
actor_id=actor_id,
|
||||
@@ -176,18 +212,49 @@ def update_definition(
|
||||
f"expected revision {payload.expected_revision}, "
|
||||
f"current revision is {definition.current_revision}."
|
||||
)
|
||||
if (
|
||||
payload.scope_type != definition.scope_type
|
||||
or payload.scope_id != definition.scope_id
|
||||
and not (
|
||||
definition.scope_type == "tenant"
|
||||
and payload.scope_id in {None, definition.scope_id}
|
||||
)
|
||||
):
|
||||
raise WorkflowConflictError(
|
||||
"Definition scope is immutable; derive a scoped copy instead."
|
||||
)
|
||||
if payload.definition_kind != definition.definition_kind:
|
||||
raise WorkflowConflictError(
|
||||
"Definition kind is immutable; derive a flow or template instead."
|
||||
)
|
||||
graph = _validated_graph(payload.graph)
|
||||
current = get_definition_revision(session, definition=definition)
|
||||
graph_hash = _content_hash(graph)
|
||||
definition.name = payload.name.strip()
|
||||
definition.description = _clean_optional(payload.description)
|
||||
definition.metadata_ = dict(payload.metadata)
|
||||
ancestor_limits = _ancestor_governance_limits(
|
||||
definition.derivation_provenance
|
||||
)
|
||||
definition.inherit_to_lower_scopes = (
|
||||
payload.inherit_to_lower_scopes
|
||||
and ancestor_limits["inherit_to_lower_scopes"]
|
||||
)
|
||||
definition.allow_start = (
|
||||
payload.allow_start and ancestor_limits["allow_start"]
|
||||
)
|
||||
definition.allow_reuse = (
|
||||
payload.allow_reuse and ancestor_limits["allow_reuse"]
|
||||
)
|
||||
definition.allow_automation = (
|
||||
payload.allow_automation and ancestor_limits["allow_automation"]
|
||||
)
|
||||
definition.updated_by = actor_id
|
||||
if current.content_hash != graph_hash:
|
||||
definition.current_revision += 1
|
||||
definition.revisions.append(
|
||||
_new_revision(
|
||||
tenant_id=tenant_id,
|
||||
tenant_id=definition.tenant_id,
|
||||
revision=definition.current_revision,
|
||||
graph=graph,
|
||||
actor_id=actor_id,
|
||||
@@ -199,6 +266,127 @@ def update_definition(
|
||||
return definition
|
||||
|
||||
|
||||
def derive_definition(
|
||||
session: Session,
|
||||
*,
|
||||
tenant_id: str,
|
||||
actor_id: str | None,
|
||||
principal: ApiPrincipal,
|
||||
registry: object | None,
|
||||
source_definition_id: str,
|
||||
payload: WorkflowDefinitionDeriveRequest,
|
||||
) -> WorkflowDefinition:
|
||||
source = get_definition(
|
||||
session,
|
||||
tenant_id=tenant_id,
|
||||
definition_id=source_definition_id,
|
||||
)
|
||||
decision = require_definition_action(
|
||||
source,
|
||||
principal=principal,
|
||||
registry=registry,
|
||||
action="derive",
|
||||
)
|
||||
source_revision = get_definition_revision(
|
||||
session,
|
||||
definition=source,
|
||||
revision=payload.source_revision,
|
||||
)
|
||||
stored_tenant_id = (
|
||||
None if payload.scope_type == "system" else tenant_id
|
||||
)
|
||||
scope_id = (
|
||||
None
|
||||
if payload.scope_type == "system"
|
||||
else tenant_id
|
||||
if payload.scope_type == "tenant"
|
||||
else payload.scope_id
|
||||
)
|
||||
scope_key = (
|
||||
"system"
|
||||
if payload.scope_type == "system"
|
||||
else f"{payload.scope_type}:{scope_id}"
|
||||
)
|
||||
source_limits = _effective_governance_limits(
|
||||
source,
|
||||
decision_details=decision.details,
|
||||
)
|
||||
limits = {
|
||||
"inherit_to_lower_scopes": (
|
||||
source_limits["inherit_to_lower_scopes"]
|
||||
and payload.inherit_to_lower_scopes
|
||||
),
|
||||
"allow_start": (
|
||||
source_limits["allow_start"] and payload.allow_start
|
||||
),
|
||||
"allow_reuse": (
|
||||
source_limits["allow_reuse"] and payload.allow_reuse
|
||||
),
|
||||
"allow_automation": (
|
||||
source_limits["allow_automation"]
|
||||
and payload.allow_automation
|
||||
),
|
||||
}
|
||||
provenance = {
|
||||
"source_ref": f"workflow-definition:{source.id}",
|
||||
"source_scope": {
|
||||
"scope_type": source.scope_type,
|
||||
"scope_id": source.scope_id,
|
||||
},
|
||||
"source_definition_kind": source.definition_kind,
|
||||
"source_revision": source_revision.revision,
|
||||
"source_hash": source_revision.content_hash,
|
||||
"source_effective_limits": limits,
|
||||
"policy_decision": decision.to_dict(),
|
||||
"derived_by": actor_id,
|
||||
"derived_at": utcnow().isoformat(),
|
||||
}
|
||||
definition = WorkflowDefinition(
|
||||
tenant_id=stored_tenant_id,
|
||||
scope_type=payload.scope_type,
|
||||
scope_id=scope_id,
|
||||
scope_key=scope_key,
|
||||
definition_kind=payload.definition_kind,
|
||||
inherit_to_lower_scopes=limits["inherit_to_lower_scopes"],
|
||||
allow_start=limits["allow_start"],
|
||||
allow_reuse=limits["allow_reuse"],
|
||||
allow_automation=limits["allow_automation"],
|
||||
derived_from_definition_id=source.id,
|
||||
derived_from_revision=source_revision.revision,
|
||||
derived_from_hash=source_revision.content_hash,
|
||||
derivation_provenance=provenance,
|
||||
definition_key=_available_key(
|
||||
session,
|
||||
scope_key=scope_key,
|
||||
requested=payload.key,
|
||||
name=payload.name,
|
||||
),
|
||||
name=payload.name.strip(),
|
||||
description=_clean_optional(payload.description),
|
||||
status="draft",
|
||||
current_revision=1,
|
||||
active_revision=None,
|
||||
metadata_=dict(payload.metadata),
|
||||
created_by=actor_id,
|
||||
updated_by=actor_id,
|
||||
)
|
||||
definition.revisions.append(
|
||||
WorkflowDefinitionRevision(
|
||||
tenant_id=stored_tenant_id,
|
||||
revision=1,
|
||||
schema_version=source_revision.schema_version,
|
||||
graph=dict(source_revision.graph),
|
||||
content_hash=source_revision.content_hash,
|
||||
library_id=source_revision.library_id,
|
||||
library_version=source_revision.library_version,
|
||||
created_by=actor_id,
|
||||
)
|
||||
)
|
||||
session.add(definition)
|
||||
session.flush()
|
||||
return definition
|
||||
|
||||
|
||||
def activate_definition(
|
||||
session: Session,
|
||||
*,
|
||||
@@ -217,6 +405,10 @@ def activate_definition(
|
||||
definition=definition,
|
||||
revision=revision,
|
||||
)
|
||||
if definition.definition_kind == "template":
|
||||
raise WorkflowConflictError(
|
||||
"Workflow templates cannot be activated or started."
|
||||
)
|
||||
_validated_graph(WorkflowGraph.model_validate(selected.graph))
|
||||
definition.active_revision = selected.revision
|
||||
definition.status = "active"
|
||||
@@ -265,6 +457,8 @@ def definition_response(
|
||||
session: Session,
|
||||
definition: WorkflowDefinition,
|
||||
*,
|
||||
principal: ApiPrincipal,
|
||||
registry: object | None,
|
||||
revision: int | None = None,
|
||||
) -> WorkflowDefinitionResponse:
|
||||
selected = get_definition_revision(
|
||||
@@ -287,6 +481,11 @@ def definition_response(
|
||||
created_at=definition.created_at,
|
||||
updated_at=definition.updated_at,
|
||||
revision=revision_response(selected),
|
||||
governance=definition_governance_payload(
|
||||
definition,
|
||||
principal=principal,
|
||||
registry=registry,
|
||||
),
|
||||
)
|
||||
|
||||
|
||||
@@ -308,7 +507,7 @@ def revision_response(
|
||||
|
||||
def _new_revision(
|
||||
*,
|
||||
tenant_id: str,
|
||||
tenant_id: str | None,
|
||||
revision: int,
|
||||
graph: WorkflowGraph,
|
||||
actor_id: str | None,
|
||||
@@ -348,7 +547,7 @@ def _content_hash(graph: WorkflowGraph) -> str:
|
||||
def _available_key(
|
||||
session: Session,
|
||||
*,
|
||||
tenant_id: str,
|
||||
scope_key: str,
|
||||
requested: str | None,
|
||||
name: str,
|
||||
) -> str:
|
||||
@@ -357,7 +556,7 @@ def _available_key(
|
||||
suffix = 2
|
||||
while session.scalar(
|
||||
select(WorkflowDefinition.id).where(
|
||||
WorkflowDefinition.tenant_id == tenant_id,
|
||||
WorkflowDefinition.scope_key == scope_key,
|
||||
WorkflowDefinition.definition_key == candidate,
|
||||
)
|
||||
):
|
||||
@@ -380,6 +579,65 @@ def _clean_optional(value: str | None) -> str | None:
|
||||
return cleaned or None
|
||||
|
||||
|
||||
def _ancestor_governance_limits(
|
||||
provenance: Mapping[str, object],
|
||||
) -> dict[str, bool]:
|
||||
raw = provenance.get("source_effective_limits")
|
||||
limits = raw if isinstance(raw, Mapping) else {}
|
||||
return {
|
||||
key: value if isinstance((value := limits.get(key)), bool) else True
|
||||
for key in (
|
||||
"inherit_to_lower_scopes",
|
||||
"allow_start",
|
||||
"allow_reuse",
|
||||
"allow_automation",
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
def _effective_governance_limits(
|
||||
definition: WorkflowDefinition,
|
||||
*,
|
||||
decision_details: Mapping[str, object] | None = None,
|
||||
) -> dict[str, bool]:
|
||||
ancestor = _ancestor_governance_limits(
|
||||
definition.derivation_provenance
|
||||
)
|
||||
effective = {
|
||||
"inherit_to_lower_scopes": (
|
||||
definition.inherit_to_lower_scopes
|
||||
and ancestor["inherit_to_lower_scopes"]
|
||||
),
|
||||
"allow_start": (
|
||||
definition.allow_start and ancestor["allow_start"]
|
||||
),
|
||||
"allow_reuse": (
|
||||
definition.allow_reuse and ancestor["allow_reuse"]
|
||||
),
|
||||
"allow_automation": (
|
||||
definition.allow_automation
|
||||
and ancestor["allow_automation"]
|
||||
),
|
||||
}
|
||||
policy_limits = (
|
||||
decision_details.get("effective_limits")
|
||||
if decision_details is not None
|
||||
else None
|
||||
)
|
||||
if isinstance(policy_limits, Mapping):
|
||||
policy_key_by_local_key = {
|
||||
"inherit_to_lower_scopes": "inherit_to_lower_scopes",
|
||||
"allow_start": "allow_run",
|
||||
"allow_reuse": "allow_reuse",
|
||||
"allow_automation": "allow_automation",
|
||||
}
|
||||
for local_key, policy_key in policy_key_by_local_key.items():
|
||||
value = policy_limits.get(policy_key)
|
||||
if isinstance(value, bool):
|
||||
effective[local_key] = effective[local_key] and value
|
||||
return effective
|
||||
|
||||
|
||||
__all__ = [
|
||||
"WorkflowConflictError",
|
||||
"WorkflowError",
|
||||
@@ -388,6 +646,7 @@ __all__ = [
|
||||
"activate_definition",
|
||||
"archive_definition",
|
||||
"create_definition",
|
||||
"derive_definition",
|
||||
"definition_response",
|
||||
"delete_definition",
|
||||
"get_definition",
|
||||
|
||||
Reference in New Issue
Block a user