Add governed reusable workflow definitions

This commit is contained in:
2026-07-28 15:04:32 +02:00
parent 6737b60c11
commit 1ec8336c02
16 changed files with 1986 additions and 38 deletions
+72 -3
View File
@@ -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,
+318
View File
@@ -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",
]
+14 -1
View File
@@ -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,
@@ -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,
)
+63 -1
View File
@@ -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",
+216 -9
View File
@@ -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(
+11
View File
@@ -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
+63 -1
View File
@@ -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):
+269 -10
View File
@@ -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",