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

View File

@@ -25,6 +25,18 @@ Instance state, resumable transitions, human activities, retries, and event
subscriptions must build on the pinned definition and versioned module
capabilities rather than bypassing them.
Definitions can be complete flows or non-runnable templates at system,
tenant, group, or user scope. Policy resolves whether a definition can be
viewed, edited, started, reused, derived, or automated and returns the ordered
source path shown in the editor. Derivation copies an immutable graph revision
and records its hash, node-library version, source scope, actor, Policy
decision, and effective ancestor limits.
The start-node library distinguishes explicit user, API, scheduled, event, and
parent-workflow starts. These are definition contracts only until the
resumable Workflow instance runtime is implemented; the UI reports that
limitation rather than presenting automation as operational.
See [docs/CONCEPT.md](docs/CONCEPT.md) for the complete module concept.
There is a BPMN component playing a major role here. Maybe this needs to

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,

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",
]

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,

View File

@@ -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,
)

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",

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)
return WorkflowDefinitionListResponse(
registry = get_registry()
definitions = [
definition_response(session, definition)
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,
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(

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

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):

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(
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,
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",

256
tests/test_governance.py Normal file
View File

@@ -0,0 +1,256 @@
from __future__ import annotations
import unittest
from sqlalchemy import create_engine
from sqlalchemy.orm import Session, sessionmaker
from govoplan_core.auth import ApiPrincipal
from govoplan_core.core.access import PrincipalRef
from govoplan_core.core.policy import PolicyDecision
from govoplan_core.db.base import Base
from govoplan_workflow.backend.db.models import (
WorkflowDefinition,
WorkflowDefinitionRevision,
)
from govoplan_workflow.backend.schemas import (
WorkflowDefinitionCreateRequest,
WorkflowDefinitionDeriveRequest,
WorkflowDefinitionUpdateRequest,
)
from govoplan_workflow.backend.service import (
WorkflowConflictError,
activate_definition,
create_definition,
definition_response,
derive_definition,
update_definition,
)
from test_service import sample_graph
POLICY_CAPABILITY = "policy.definitionGovernance"
def principal() -> ApiPrincipal:
return ApiPrincipal(
principal=PrincipalRef(
account_id="account-1",
membership_id="membership-1",
tenant_id="tenant-1",
scopes=frozenset(
{
"workflow:definition:read",
"workflow:definition:write",
"workflow:instance:start",
}
),
),
account=object(),
user=object(),
)
class DefinitionPolicy:
def resolve_definition_action(self, *, request):
local = request.definition_scope.scope_type == "tenant"
inherited = (
request.definition_scope.scope_type == "system"
and request.inherit_to_lower_scopes
)
allowed = local or inherited
if request.action == "edit":
allowed = local
elif request.action in {"reuse", "derive"}:
allowed = allowed and request.allow_reuse
elif request.action == "run":
allowed = (
allowed
and request.definition_kind == "flow"
and request.status == "active"
and request.allow_run
)
elif request.action == "automate":
allowed = (
allowed
and request.definition_kind == "flow"
and request.allow_automation
)
return PolicyDecision(
allowed=allowed,
reason=None if allowed else "Definition action denied.",
)
class Registry:
def has_capability(self, name: str) -> bool:
return name == POLICY_CAPABILITY
def capability(self, name: str):
if not self.has_capability(name):
raise KeyError(name)
return DefinitionPolicy()
class WorkflowGovernanceTests(unittest.TestCase):
def setUp(self) -> None:
self.engine = create_engine("sqlite:///:memory:")
Base.metadata.create_all(
self.engine,
tables=[
WorkflowDefinition.__table__,
WorkflowDefinitionRevision.__table__,
],
)
self.Session = sessionmaker(bind=self.engine)
self.session: Session = self.Session()
self.principal = principal()
self.registry = Registry()
def tearDown(self) -> None:
self.session.close()
Base.metadata.drop_all(
self.engine,
tables=[
WorkflowDefinitionRevision.__table__,
WorkflowDefinition.__table__,
],
)
self.engine.dispose()
def test_derivation_pins_template_revision_and_provenance(self) -> None:
template = create_definition(
self.session,
tenant_id="tenant-1",
actor_id="system-admin",
payload=WorkflowDefinitionCreateRequest(
key="permit-review",
name="Permit review template",
graph=sample_graph(),
scope_type="system",
definition_kind="template",
inherit_to_lower_scopes=True,
allow_reuse=True,
),
)
derived = derive_definition(
self.session,
tenant_id="tenant-1",
actor_id="account-1",
principal=self.principal,
registry=self.registry,
source_definition_id=template.id,
payload=WorkflowDefinitionDeriveRequest(
name="Tenant permit review",
allow_start=True,
),
)
self.session.commit()
response = definition_response(
self.session,
derived,
principal=self.principal,
registry=self.registry,
)
self.assertEqual(template.id, derived.derived_from_definition_id)
self.assertEqual(1, derived.derived_from_revision)
self.assertEqual(
template.revisions[0].content_hash,
derived.derived_from_hash,
)
self.assertEqual(
"system",
response.governance.derivation_provenance["source_scope"][
"scope_type"
],
)
self.assertFalse(response.governance.automation_runtime_available)
def test_template_cannot_be_activated(self) -> None:
template = create_definition(
self.session,
tenant_id="tenant-1",
actor_id="account-1",
payload=WorkflowDefinitionCreateRequest(
name="Reusable review",
graph=sample_graph(),
definition_kind="template",
allow_reuse=True,
),
)
with self.assertRaises(WorkflowConflictError):
activate_definition(
self.session,
tenant_id="tenant-1",
definition_id=template.id,
actor_id="account-1",
)
def test_derived_limits_cannot_be_broadened_transitively(self) -> None:
template = create_definition(
self.session,
tenant_id="tenant-1",
actor_id="account-1",
payload=WorkflowDefinitionCreateRequest(
name="Restricted review",
graph=sample_graph(),
definition_kind="template",
allow_reuse=True,
allow_automation=False,
inherit_to_lower_scopes=False,
),
)
derived = derive_definition(
self.session,
tenant_id="tenant-1",
actor_id="account-1",
principal=self.principal,
registry=self.registry,
source_definition_id=template.id,
payload=WorkflowDefinitionDeriveRequest(
name="Tenant review",
allow_reuse=True,
allow_automation=True,
inherit_to_lower_scopes=True,
),
)
update_definition(
self.session,
tenant_id="tenant-1",
definition_id=derived.id,
actor_id="account-1",
payload=WorkflowDefinitionUpdateRequest(
name=derived.name,
graph=sample_graph(),
expected_revision=1,
allow_reuse=True,
allow_automation=True,
inherit_to_lower_scopes=True,
),
)
grandchild = derive_definition(
self.session,
tenant_id="tenant-1",
actor_id="account-1",
principal=self.principal,
registry=self.registry,
source_definition_id=derived.id,
payload=WorkflowDefinitionDeriveRequest(
name="User review",
scope_type="user",
scope_id="membership-1",
allow_automation=True,
inherit_to_lower_scopes=True,
),
)
self.assertFalse(derived.allow_automation)
self.assertFalse(derived.inherit_to_lower_scopes)
self.assertFalse(grandchild.allow_automation)
self.assertFalse(grandchild.inherit_to_lower_scopes)
if __name__ == "__main__":
unittest.main()

View File

@@ -26,7 +26,7 @@ class WorkflowMigrationTests(unittest.TestCase):
try:
with engine.connect() as connection:
self.assertIn(
"a7c4e2f9b1d3",
"c6d8f1a3e5b7",
set(MigrationContext.configure(connection).get_current_heads()),
)
self.assertEqual(

View File

@@ -6,6 +6,8 @@ import type {
} from "@govoplan/core-webui/definition-graph";
export type WorkflowStatus = "draft" | "active" | "archived";
export type DefinitionScopeType = "system" | "tenant" | "group" | "user";
export type DefinitionKind = "flow" | "template";
export type WorkflowGraphNode = DefinitionGraphNode;
export type WorkflowGraph = DefinitionGraph & {
schema_version: 1;
@@ -36,7 +38,7 @@ export type WorkflowRevision = {
export type WorkflowDefinition = {
id: string;
tenant_id: string;
tenant_id: string | null;
key: string;
name: string;
description?: string | null;
@@ -49,6 +51,32 @@ export type WorkflowDefinition = {
created_at: string;
updated_at: string;
revision: WorkflowRevision;
governance: WorkflowGovernance;
};
export type WorkflowActionDecision = {
allowed: boolean;
reason?: string | null;
source_path: Array<Record<string, unknown>>;
requirements: string[];
details: Record<string, unknown>;
};
export type WorkflowGovernance = {
scope_type: DefinitionScopeType;
scope_id?: string | null;
definition_kind: DefinitionKind;
inherit_to_lower_scopes: boolean;
allow_start: boolean;
allow_reuse: boolean;
allow_automation: boolean;
derived_from_definition_id?: string | null;
derived_from_revision?: number | null;
derived_from_hash?: string | null;
derivation_provenance: Record<string, unknown>;
actions: Record<string, WorkflowActionDecision>;
automation_runtime_available: boolean;
automation_runtime_reason?: string | null;
};
export type WorkflowDefinitionPayload = {
@@ -56,6 +84,13 @@ export type WorkflowDefinitionPayload = {
description?: string | null;
graph: WorkflowGraph;
metadata: Record<string, unknown>;
scope_type: DefinitionScopeType;
scope_id?: string | null;
definition_kind: DefinitionKind;
inherit_to_lower_scopes: boolean;
allow_start: boolean;
allow_reuse: boolean;
allow_automation: boolean;
};
export async function listWorkflowNodeTypes(
@@ -99,6 +134,31 @@ export function updateWorkflowDefinition(
);
}
export function deriveWorkflowDefinition(
settings: ApiSettings,
definitionId: string,
payload: {
key?: string | null;
name: string;
description?: string | null;
source_revision?: number | null;
metadata: Record<string, unknown>;
scope_type: DefinitionScopeType;
scope_id?: string | null;
definition_kind: DefinitionKind;
inherit_to_lower_scopes: boolean;
allow_start: boolean;
allow_reuse: boolean;
allow_automation: boolean;
}
): Promise<WorkflowDefinition> {
return apiFetch(
settings,
`/api/v1/workflow/definitions/${encodeURIComponent(definitionId)}/derive`,
{ method: "POST", body: JSON.stringify(payload) }
);
}
export async function listWorkflowRevisions(
settings: ApiSettings,
definitionId: string

View File

@@ -8,21 +8,26 @@ import {
import {
Archive,
CheckCircle2,
CopyPlus,
GitFork,
Plus,
RefreshCw,
RotateCcw,
Save,
Settings2,
Trash2
} from "lucide-react";
import { ReactFlowProvider } from "@xyflow/react";
import {
Button,
ConfirmDialog,
Dialog,
DismissibleAlert,
FormField,
IconButton,
LoadingFrame,
StatusBadge,
ToggleSwitch,
hasScope,
isApiError,
useUnsavedChanges,
@@ -35,6 +40,7 @@ import {
archiveWorkflowDefinition,
createWorkflowDefinition,
deleteWorkflowDefinition,
deriveWorkflowDefinition,
listWorkflowDefinitions,
listWorkflowNodeTypes,
listWorkflowRevisions,
@@ -93,13 +99,23 @@ export default function WorkflowPage({
const [success, setSuccess] = useState("");
const [diagnostics, setDiagnostics] = useState<WorkflowDiagnostic[]>([]);
const [deleteOpen, setDeleteOpen] = useState(false);
const [definitionSettingsOpen, setDefinitionSettingsOpen] = useState(false);
const [deriveOpen, setDeriveOpen] = useState(false);
const canWrite = hasScope(auth, "workflow:definition:write")
|| hasScope(auth, "workflow:instance:admin");
const canEdit = canWrite && (
!draft?.id || draft.governance?.actions.edit?.allowed !== false
);
const canReuse = Boolean(
draft?.id
&& canWrite
&& draft.governance?.actions.derive?.allowed
);
const dirty = Boolean(draft)
&& workflowFingerprint(draft) !== workflowFingerprint(savedDraft);
const displayedGraph = historicalRevision?.graph ?? draft?.graph ?? null;
const readOnly = !canWrite || historicalRevision !== null;
const readOnly = !canEdit || historicalRevision !== null;
const selectedNode = useMemo(
() => displayedGraph?.nodes.find((node) => node.id === selectedNodeId) ?? null,
[displayedGraph, selectedNodeId]
@@ -205,7 +221,7 @@ export default function WorkflowPage({
}, [savedDraft]);
const saveDraft = useCallback(async (): Promise<boolean> => {
if (!draft || !canWrite || !draft.name.trim()) {
if (!draft || !canEdit || !draft.name.trim()) {
setError(
!draft?.name.trim()
? "Workflow name is required."
@@ -236,7 +252,7 @@ export default function WorkflowPage({
} finally {
setWorking(false);
}
}, [applyDefinition, canWrite, draft, settings]);
}, [applyDefinition, canEdit, draft, settings]);
useUnsavedDraftGuard({
dirty,
@@ -413,6 +429,9 @@ export default function WorkflowPage({
<small>
{definition.key} · revision {definition.current_revision}
</small>
<small>
{definition.governance.scope_type} · {definition.governance.definition_kind}
</small>
</span>
<StatusBadge
status={definition.status}
@@ -502,7 +521,7 @@ export default function WorkflowPage({
setHistoricalRevision(null);
setDiagnostics([]);
}}
disabled={!canWrite}
disabled={!canEdit}
>
<RotateCcw size={16} /> Restore
</Button>
@@ -510,10 +529,25 @@ export default function WorkflowPage({
<Button onClick={() => void validate()} disabled={working}>
<CheckCircle2 size={16} /> Validate
</Button>
<IconButton
label="Definition settings"
icon={<Settings2 size={16} />}
variant="ghost"
onClick={() => setDefinitionSettingsOpen(true)}
/>
{draft.id ? (
<IconButton
label="Reuse as scoped copy"
icon={<CopyPlus size={16} />}
variant="ghost"
onClick={() => setDeriveOpen(true)}
disabled={!canReuse}
/>
) : null}
{draft.id && draft.status !== "archived" ? (
<Button
onClick={() => void archiveDefinition()}
disabled={!canWrite || dirty || working || readOnly}
disabled={!canEdit || dirty || working || readOnly}
>
<Archive size={16} /> Archive
</Button>
@@ -522,7 +556,18 @@ export default function WorkflowPage({
<Button
variant="primary"
onClick={() => void activate()}
disabled={!canWrite || dirty || working || readOnly}
disabled={
!canEdit
|| dirty
|| working
|| readOnly
|| draft.definitionKind === "template"
}
disabledReason={
draft.definitionKind === "template"
? "Templates cannot be activated or started."
: undefined
}
>
<CheckCircle2 size={16} /> Activate
</Button>
@@ -530,7 +575,7 @@ export default function WorkflowPage({
<Button
variant="primary"
onClick={() => void saveDraft()}
disabled={!canWrite || !dirty || working || readOnly}
disabled={!canEdit || !dirty || working || readOnly}
>
<Save size={16} /> Save
</Button>
@@ -540,7 +585,7 @@ export default function WorkflowPage({
icon={<Trash2 size={16} />}
variant="danger"
onClick={() => setDeleteOpen(true)}
disabled={!canWrite || working}
disabled={!canEdit || working}
/>
) : null}
</span>
@@ -674,10 +719,287 @@ export default function WorkflowPage({
onCancel={() => setDeleteOpen(false)}
onConfirm={() => void removeDefinition()}
/>
<WorkflowDefinitionSettingsDialog
open={definitionSettingsOpen}
draft={draft}
editable={canEdit}
onChange={(patch) => setDraft((current) => current ? { ...current, ...patch } : current)}
onClose={() => setDefinitionSettingsOpen(false)}
/>
<DeriveWorkflowDialog
open={deriveOpen}
settings={settings}
definition={draft?.id ? {
id: draft.id,
name: draft.name,
revision: draft.currentRevision ?? 1
} : null}
onClose={() => setDeriveOpen(false)}
onDerived={(definition) => {
applyDefinition(definition);
setDefinitions((current) => [
definition,
...current.filter((item) => item.id !== definition.id)
]);
setDeriveOpen(false);
setSuccess("Created a pinned scoped copy.");
}}
/>
</main>
);
}
function WorkflowDefinitionSettingsDialog({
open,
draft,
editable,
onChange,
onClose
}: {
open: boolean;
draft: WorkflowDraft | null;
editable: boolean;
onChange: (patch: Partial<WorkflowDraft>) => void;
onClose: () => void;
}) {
const provenance = draft?.governance?.actions.edit?.source_path ?? [];
return (
<Dialog
open={open}
title="Definition settings"
className="workflow-definition-dialog"
onClose={onClose}
footer={<Button onClick={onClose}>Close</Button>}
>
{draft ? (
<div className="workflow-definition-fields">
<FormField
label="Scope"
help="The scope determines ownership, visibility, and the Policy inheritance path."
>
<select
value={draft.scopeType}
disabled={!editable || Boolean(draft.id)}
onChange={(event) => onChange({
scopeType: event.target.value as WorkflowDraft["scopeType"],
scopeId: ""
})}
>
<option value="system">System</option>
<option value="tenant">Tenant</option>
<option value="group">Group</option>
<option value="user">User</option>
</select>
</FormField>
<FormField label="Scope ID">
<input
value={draft.scopeId}
disabled={
!editable
|| Boolean(draft.id)
|| draft.scopeType === "system"
|| draft.scopeType === "tenant"
}
placeholder={
draft.scopeType === "user"
? "Current user when empty"
: "Required for group"
}
onChange={(event) => onChange({ scopeId: event.target.value })}
/>
</FormField>
<FormField
label="Definition kind"
help="Templates can be reused or derived, but cannot be activated or started."
>
<select
value={draft.definitionKind}
disabled={!editable || Boolean(draft.id)}
onChange={(event) => onChange({
definitionKind: event.target.value as WorkflowDraft["definitionKind"],
status: "draft"
})}
>
<option value="flow">Complete flow</option>
<option value="template">Template</option>
</select>
</FormField>
<div className="workflow-definition-toggles">
<ToggleSwitch
label="Visible to lower scopes"
checked={draft.inheritToLowerScopes}
disabled={!editable}
onChange={(value) => onChange({ inheritToLowerScopes: value })}
/>
<ToggleSwitch
label="Allow starts"
checked={draft.allowStart}
disabled={!editable}
onChange={(value) => onChange({ allowStart: value })}
/>
<ToggleSwitch
label="Allow reuse"
checked={draft.allowReuse}
disabled={!editable}
onChange={(value) => onChange({ allowReuse: value })}
/>
<ToggleSwitch
label="Allow automation"
checked={draft.allowAutomation}
disabled={!editable}
onChange={(value) => onChange({ allowAutomation: value })}
/>
</div>
{draft.governance?.automation_runtime_reason ? (
<DismissibleAlert
tone="warning"
resetKey={draft.governance.automation_runtime_reason}
>
{draft.governance.automation_runtime_reason}
</DismissibleAlert>
) : null}
{draft.governance?.derived_from_definition_id ? (
<section className="workflow-provenance">
<strong>Derived from</strong>
<span>
{draft.governance.derived_from_definition_id}
{" · revision "}
{draft.governance.derived_from_revision}
</span>
<code>{draft.governance.derived_from_hash}</code>
</section>
) : null}
{provenance.length ? (
<section className="workflow-provenance">
<strong>Effective Policy path</strong>
{provenance.map((item, index) => (
<span key={`${String(item.path ?? item.scope_type)}-${index}`}>
{String(item.label ?? item.path ?? item.scope_type)}
</span>
))}
{!editable && draft.governance?.actions.edit?.reason ? (
<small>{draft.governance.actions.edit.reason}</small>
) : null}
</section>
) : null}
</div>
) : null}
</Dialog>
);
}
function DeriveWorkflowDialog({
open,
settings,
definition,
onClose,
onDerived
}: {
open: boolean;
settings: ApiSettings;
definition: { id: string; name: string; revision: number } | null;
onClose: () => void;
onDerived: (definition: WorkflowDefinition) => void;
}) {
const [name, setName] = useState("");
const [kind, setKind] = useState<"flow" | "template">("flow");
const [scopeType, setScopeType] = useState<"tenant" | "group" | "user">("tenant");
const [scopeId, setScopeId] = useState("");
const [busy, setBusy] = useState(false);
const [error, setError] = useState("");
useEffect(() => {
if (!open) return;
setName(`${definition?.name ?? "Workflow"} copy`);
setKind("flow");
setScopeType("tenant");
setScopeId("");
setError("");
}, [open, definition?.id]);
const derive = async () => {
if (!definition || !name.trim()) return;
setBusy(true);
setError("");
try {
onDerived(await deriveWorkflowDefinition(settings, definition.id, {
name: name.trim(),
source_revision: definition.revision,
metadata: {},
scope_type: scopeType,
scope_id: scopeId.trim() || null,
definition_kind: kind,
inherit_to_lower_scopes: false,
allow_start: true,
allow_reuse: false,
allow_automation: false
}));
} catch (deriveError) {
setError(apiErrorMessage(deriveError));
} finally {
setBusy(false);
}
};
return (
<Dialog
open={open}
title="Reuse as scoped copy"
className="workflow-definition-dialog"
closeDisabled={busy}
onClose={onClose}
footer={(
<>
<Button onClick={onClose} disabled={busy}>Cancel</Button>
<Button
variant="primary"
onClick={() => void derive()}
disabled={busy || !definition || !name.trim()}
>
<CopyPlus size={16} /> Create copy
</Button>
</>
)}
>
<div className="workflow-definition-fields">
{error ? <DismissibleAlert tone="danger" resetKey={error}>{error}</DismissibleAlert> : null}
<FormField label="Name">
<input value={name} onChange={(event) => setName(event.target.value)} />
</FormField>
<FormField label="Target scope">
<select
value={scopeType}
onChange={(event) => setScopeType(event.target.value as typeof scopeType)}
>
<option value="tenant">Tenant</option>
<option value="group">Group</option>
<option value="user">User</option>
</select>
</FormField>
{scopeType !== "tenant" ? (
<FormField label="Scope ID">
<input
value={scopeId}
placeholder={scopeType === "user" ? "Current user when empty" : "Group ID"}
onChange={(event) => setScopeId(event.target.value)}
/>
</FormField>
) : null}
<FormField label="Copy kind">
<select value={kind} onChange={(event) => setKind(event.target.value as typeof kind)}>
<option value="flow">Complete flow</option>
<option value="template">Template</option>
</select>
</FormField>
<small>
The source graph revision, node-library version, content hash, and
effective ancestor limits are retained as provenance.
</small>
</div>
</Dialog>
);
}
function startPaletteDrag(
event: DragEvent<HTMLButtonElement>,
type: string

View File

@@ -1,7 +1,10 @@
import { createDefinitionGraphNode } from "@govoplan/core-webui/definition-graph";
import type {
DefinitionKind,
DefinitionScopeType,
WorkflowDefinition,
WorkflowDefinitionPayload,
WorkflowGovernance,
WorkflowGraph,
WorkflowGraphNode,
WorkflowNodeType,
@@ -17,6 +20,14 @@ export type WorkflowDraft = {
activeRevision?: number | null;
metadata: Record<string, unknown>;
graph: WorkflowGraph;
scopeType: DefinitionScopeType;
scopeId: string;
definitionKind: DefinitionKind;
inheritToLowerScopes: boolean;
allowStart: boolean;
allowReuse: boolean;
allowAutomation: boolean;
governance: WorkflowGovernance | null;
};
const inputPort = [{
@@ -92,6 +103,14 @@ export function sampleWorkflowDraft(): WorkflowDraft {
description: "",
status: "draft",
metadata: {},
scopeType: "tenant",
scopeId: "",
definitionKind: "flow",
inheritToLowerScopes: false,
allowStart: true,
allowReuse: false,
allowAutomation: false,
governance: null,
graph: {
schema_version: 1,
nodes: [
@@ -153,7 +172,15 @@ export function draftFromDefinition(
currentRevision: definition.current_revision,
activeRevision: definition.active_revision,
metadata: structuredClone(definition.metadata),
graph: structuredClone(definition.revision.graph)
graph: structuredClone(definition.revision.graph),
scopeType: definition.governance.scope_type,
scopeId: definition.governance.scope_id ?? "",
definitionKind: definition.governance.definition_kind,
inheritToLowerScopes: definition.governance.inherit_to_lower_scopes,
allowStart: definition.governance.allow_start,
allowReuse: definition.governance.allow_reuse,
allowAutomation: definition.governance.allow_automation,
governance: definition.governance
};
}
@@ -164,7 +191,14 @@ export function workflowPayload(
name: draft.name.trim(),
description: draft.description.trim() || null,
graph: draft.graph,
metadata: draft.metadata
metadata: draft.metadata,
scope_type: draft.scopeType,
scope_id: draft.scopeId.trim() || null,
definition_kind: draft.definitionKind,
inherit_to_lower_scopes: draft.inheritToLowerScopes,
allow_start: draft.allowStart,
allow_reuse: draft.allowReuse,
allow_automation: draft.allowAutomation
};
}
@@ -176,7 +210,14 @@ export function workflowFingerprint(
name: draft.name,
description: draft.description,
graph: draft.graph,
metadata: draft.metadata
metadata: draft.metadata,
scopeType: draft.scopeType,
scopeId: draft.scopeId,
definitionKind: draft.definitionKind,
inheritToLowerScopes: draft.inheritToLowerScopes,
allowStart: draft.allowStart,
allowReuse: draft.allowReuse,
allowAutomation: draft.allowAutomation
});
}

View File

@@ -9,6 +9,53 @@
background: var(--bg);
}
.workflow-definition-dialog {
width: min(620px, calc(100vw - 32px));
}
.workflow-definition-fields {
display: grid;
gap: 12px;
}
.workflow-definition-fields input,
.workflow-definition-fields select {
width: 100%;
}
.workflow-definition-toggles {
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
gap: 10px 18px;
}
.workflow-provenance {
display: grid;
gap: 6px;
padding: 10px;
border: var(--border-line);
border-radius: var(--radius-sm);
background: var(--panel-soft);
}
.workflow-provenance span,
.workflow-provenance small {
color: var(--muted);
font-size: 12px;
}
.workflow-provenance code {
overflow: hidden;
font-size: 11px;
text-overflow: ellipsis;
}
@media (max-width: 640px) {
.workflow-definition-toggles {
grid-template-columns: 1fr;
}
}
.workflow-page *,
.workflow-page *::before,
.workflow-page *::after {