diff --git a/README.md b/README.md index 0b17642..782e75c 100644 --- a/README.md +++ b/README.md @@ -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 diff --git a/src/govoplan_workflow/backend/db/models.py b/src/govoplan_workflow/backend/db/models.py index 44ca2d3..7a0413e 100644 --- a/src/govoplan_workflow/backend/db/models.py +++ b/src/govoplan_workflow/backend/db/models.py @@ -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, diff --git a/src/govoplan_workflow/backend/governance.py b/src/govoplan_workflow/backend/governance.py new file mode 100644 index 0000000..26ccd94 --- /dev/null +++ b/src/govoplan_workflow/backend/governance.py @@ -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", +] diff --git a/src/govoplan_workflow/backend/manifest.py b/src/govoplan_workflow/backend/manifest.py index 62489cb..3c347fa 100644 --- a/src/govoplan_workflow/backend/manifest.py +++ b/src/govoplan_workflow/backend/manifest.py @@ -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, diff --git a/src/govoplan_workflow/backend/migrations/versions/c6d8f1a3e5b7_v0114_governed_definitions.py b/src/govoplan_workflow/backend/migrations/versions/c6d8f1a3e5b7_v0114_governed_definitions.py new file mode 100644 index 0000000..245275d --- /dev/null +++ b/src/govoplan_workflow/backend/migrations/versions/c6d8f1a3e5b7_v0114_governed_definitions.py @@ -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, + ) diff --git a/src/govoplan_workflow/backend/node_library.py b/src/govoplan_workflow/backend/node_library.py index e78d77d..666ca8e 100644 --- a/src/govoplan_workflow/backend/node_library.py +++ b/src/govoplan_workflow/backend/node_library.py @@ -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", diff --git a/src/govoplan_workflow/backend/router.py b/src/govoplan_workflow/backend/router.py index ff5b231..476d1a2 100644 --- a/src/govoplan_workflow/backend/router.py +++ b/src/govoplan_workflow/backend/router.py @@ -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( diff --git a/src/govoplan_workflow/backend/runtime.py b/src/govoplan_workflow/backend/runtime.py new file mode 100644 index 0000000..e947e73 --- /dev/null +++ b/src/govoplan_workflow/backend/runtime.py @@ -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 diff --git a/src/govoplan_workflow/backend/schemas.py b/src/govoplan_workflow/backend/schemas.py index f49f2f0..11102ab 100644 --- a/src/govoplan_workflow/backend/schemas.py +++ b/src/govoplan_workflow/backend/schemas.py @@ -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): diff --git a/src/govoplan_workflow/backend/service.py b/src/govoplan_workflow/backend/service.py index 4702be0..0f4660d 100644 --- a/src/govoplan_workflow/backend/service.py +++ b/src/govoplan_workflow/backend/service.py @@ -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", diff --git a/tests/test_governance.py b/tests/test_governance.py new file mode 100644 index 0000000..5d80484 --- /dev/null +++ b/tests/test_governance.py @@ -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() diff --git a/tests/test_migrations.py b/tests/test_migrations.py index 024f408..83215a5 100644 --- a/tests/test_migrations.py +++ b/tests/test_migrations.py @@ -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( diff --git a/webui/src/api/workflow.ts b/webui/src/api/workflow.ts index ff574b3..1c6bcda 100644 --- a/webui/src/api/workflow.ts +++ b/webui/src/api/workflow.ts @@ -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>; + requirements: string[]; + details: Record; +}; + +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; + actions: Record; + 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; + 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; + 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 { + return apiFetch( + settings, + `/api/v1/workflow/definitions/${encodeURIComponent(definitionId)}/derive`, + { method: "POST", body: JSON.stringify(payload) } + ); +} + export async function listWorkflowRevisions( settings: ApiSettings, definitionId: string diff --git a/webui/src/features/workflow/WorkflowPage.tsx b/webui/src/features/workflow/WorkflowPage.tsx index 88a49ee..33c1cae 100644 --- a/webui/src/features/workflow/WorkflowPage.tsx +++ b/webui/src/features/workflow/WorkflowPage.tsx @@ -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([]); 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 => { - 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({ {definition.key} · revision {definition.current_revision} + + {definition.governance.scope_type} · {definition.governance.definition_kind} + Restore @@ -510,10 +529,25 @@ export default function WorkflowPage({ + } + variant="ghost" + onClick={() => setDefinitionSettingsOpen(true)} + /> + {draft.id ? ( + } + variant="ghost" + onClick={() => setDeriveOpen(true)} + disabled={!canReuse} + /> + ) : null} {draft.id && draft.status !== "archived" ? ( @@ -522,7 +556,18 @@ export default function WorkflowPage({ @@ -530,7 +575,7 @@ export default function WorkflowPage({ @@ -540,7 +585,7 @@ export default function WorkflowPage({ icon={} variant="danger" onClick={() => setDeleteOpen(true)} - disabled={!canWrite || working} + disabled={!canEdit || working} /> ) : null} @@ -674,10 +719,287 @@ export default function WorkflowPage({ onCancel={() => setDeleteOpen(false)} onConfirm={() => void removeDefinition()} /> + setDraft((current) => current ? { ...current, ...patch } : current)} + onClose={() => setDefinitionSettingsOpen(false)} + /> + setDeriveOpen(false)} + onDerived={(definition) => { + applyDefinition(definition); + setDefinitions((current) => [ + definition, + ...current.filter((item) => item.id !== definition.id) + ]); + setDeriveOpen(false); + setSuccess("Created a pinned scoped copy."); + }} + /> ); } +function WorkflowDefinitionSettingsDialog({ + open, + draft, + editable, + onChange, + onClose +}: { + open: boolean; + draft: WorkflowDraft | null; + editable: boolean; + onChange: (patch: Partial) => void; + onClose: () => void; +}) { + const provenance = draft?.governance?.actions.edit?.source_path ?? []; + return ( + Close} + > + {draft ? ( +
+ + + + + onChange({ scopeId: event.target.value })} + /> + + + + +
+ onChange({ inheritToLowerScopes: value })} + /> + onChange({ allowStart: value })} + /> + onChange({ allowReuse: value })} + /> + onChange({ allowAutomation: value })} + /> +
+ {draft.governance?.automation_runtime_reason ? ( + + {draft.governance.automation_runtime_reason} + + ) : null} + {draft.governance?.derived_from_definition_id ? ( +
+ Derived from + + {draft.governance.derived_from_definition_id} + {" · revision "} + {draft.governance.derived_from_revision} + + {draft.governance.derived_from_hash} +
+ ) : null} + {provenance.length ? ( +
+ Effective Policy path + {provenance.map((item, index) => ( + + {String(item.label ?? item.path ?? item.scope_type)} + + ))} + {!editable && draft.governance?.actions.edit?.reason ? ( + {draft.governance.actions.edit.reason} + ) : null} +
+ ) : null} +
+ ) : null} +
+ ); +} + +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 ( + + + + + )} + > +
+ {error ? {error} : null} + + setName(event.target.value)} /> + + + + + {scopeType !== "tenant" ? ( + + setScopeId(event.target.value)} + /> + + ) : null} + + + + + The source graph revision, node-library version, content hash, and + effective ancestor limits are retained as provenance. + +
+
+ ); +} + function startPaletteDrag( event: DragEvent, type: string diff --git a/webui/src/features/workflow/model.ts b/webui/src/features/workflow/model.ts index 8c5f1c9..433cec7 100644 --- a/webui/src/features/workflow/model.ts +++ b/webui/src/features/workflow/model.ts @@ -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; 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 }); } diff --git a/webui/src/styles/workflow.css b/webui/src/styles/workflow.css index e833e48..667e940 100644 --- a/webui/src/styles/workflow.css +++ b/webui/src/styles/workflow.css @@ -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 {