306 lines
10 KiB
Python
306 lines
10 KiB
Python
"""Authorized, bounded projections for workflow progress and retained history."""
|
|
|
|
from __future__ import annotations
|
|
|
|
import base64
|
|
import binascii
|
|
from datetime import datetime
|
|
import json
|
|
|
|
from sqlalchemy import and_, func, or_, select
|
|
from sqlalchemy.orm import Session, joinedload, load_only, raiseload
|
|
|
|
from govoplan_core.auth import ApiPrincipal, has_scope
|
|
from govoplan_workflow_engine.backend.db.models import (
|
|
WorkflowDefinitionRevision,
|
|
WorkflowInstance,
|
|
WorkflowInstanceEvent,
|
|
WorkflowInstanceStep,
|
|
)
|
|
from govoplan_workflow_engine.backend.governance import (
|
|
definition_decision,
|
|
require_definition_action,
|
|
)
|
|
from govoplan_workflow_engine.backend.instance_service import (
|
|
event_response,
|
|
step_response,
|
|
)
|
|
from govoplan_workflow_engine.backend.schemas import (
|
|
WorkflowInstanceEventPageResponse,
|
|
WorkflowInstanceStepPageResponse,
|
|
WorkflowInstanceSummaryListResponse,
|
|
WorkflowInstanceSummaryResponse,
|
|
)
|
|
from govoplan_workflow_engine.backend.service import WorkflowNotFoundError
|
|
|
|
|
|
MAX_PAGE_SIZE = 200
|
|
MAX_HISTORY_SEQUENCE = 2_147_483_647
|
|
_CANDIDATE_BATCH_SIZE = 250
|
|
|
|
|
|
def _require_read(principal: ApiPrincipal) -> None:
|
|
if not any(
|
|
has_scope(principal, scope)
|
|
for scope in ("workflow:instance:read", "workflow:instance:admin")
|
|
):
|
|
raise PermissionError("Workflow instance read permission is required.")
|
|
|
|
|
|
def _summary_statement():
|
|
# Raiseload makes accidental history/payload expansion fail visibly. Only the
|
|
# pinned revision metadata is needed, never its graph or BPMN document.
|
|
return (
|
|
select(WorkflowInstance)
|
|
.options(
|
|
load_only(
|
|
WorkflowInstance.tenant_id,
|
|
WorkflowInstance.definition_id,
|
|
WorkflowInstance.definition_revision_id,
|
|
WorkflowInstance.status,
|
|
WorkflowInstance.start_origin,
|
|
WorkflowInstance.current_step_id,
|
|
WorkflowInstance.started_at,
|
|
WorkflowInstance.finished_at,
|
|
WorkflowInstance.created_at,
|
|
WorkflowInstance.updated_at,
|
|
raiseload=True,
|
|
),
|
|
joinedload(WorkflowInstance.definition),
|
|
joinedload(WorkflowInstance.definition_revision).load_only(
|
|
WorkflowDefinitionRevision.tenant_id,
|
|
WorkflowDefinitionRevision.definition_id,
|
|
WorkflowDefinitionRevision.revision,
|
|
WorkflowDefinitionRevision.content_hash,
|
|
WorkflowDefinitionRevision.execution_mode,
|
|
raiseload=True,
|
|
),
|
|
raiseload("*"),
|
|
)
|
|
.execution_options(populate_existing=True)
|
|
)
|
|
|
|
|
|
def _require_evidence(instance: WorkflowInstance) -> None:
|
|
definition = instance.definition
|
|
revision = instance.definition_revision
|
|
if (
|
|
definition is None
|
|
or revision is None
|
|
or revision.id != instance.definition_revision_id
|
|
or definition.id != instance.definition_id
|
|
or definition.tenant_id not in {None, instance.tenant_id}
|
|
or revision.definition_id != definition.id
|
|
or revision.tenant_id != definition.tenant_id
|
|
):
|
|
raise WorkflowNotFoundError(
|
|
"Workflow instance definition evidence is incomplete."
|
|
)
|
|
|
|
|
|
def _summary(instance: WorkflowInstance) -> WorkflowInstanceSummaryResponse:
|
|
_require_evidence(instance)
|
|
return WorkflowInstanceSummaryResponse(
|
|
id=instance.id,
|
|
definition_id=instance.definition_id,
|
|
definition_name=instance.definition.name,
|
|
definition_revision=instance.definition_revision.revision,
|
|
definition_hash=instance.definition_revision.content_hash,
|
|
execution_mode=instance.definition_revision.execution_mode,
|
|
start_origin=instance.start_origin,
|
|
status=instance.status,
|
|
current_step_id=instance.current_step_id,
|
|
started_at=instance.started_at,
|
|
finished_at=instance.finished_at,
|
|
created_at=instance.created_at,
|
|
updated_at=instance.updated_at,
|
|
)
|
|
|
|
|
|
def _cursor(instance: WorkflowInstance) -> str:
|
|
return base64.urlsafe_b64encode(
|
|
json.dumps([instance.created_at.isoformat(), instance.id]).encode("utf-8")
|
|
).decode("ascii")
|
|
|
|
|
|
def _cursor_boundary(cursor: str) -> tuple[datetime, str]:
|
|
try:
|
|
if len(cursor) > 256:
|
|
raise ValueError
|
|
payload = json.loads(base64.b64decode(cursor, altchars=b"-_", validate=True))
|
|
if (
|
|
not isinstance(payload, list)
|
|
or len(payload) != 2
|
|
or not all(isinstance(value, str) for value in payload)
|
|
or not 1 <= len(payload[1]) <= 36
|
|
):
|
|
raise ValueError
|
|
return datetime.fromisoformat(payload[0]), payload[1]
|
|
except (ValueError, TypeError, UnicodeError, binascii.Error) as exc:
|
|
raise ValueError("Invalid workflow instance cursor.") from exc
|
|
|
|
|
|
def list_instance_summaries(
|
|
session: Session,
|
|
*,
|
|
principal: ApiPrincipal,
|
|
registry: object | None,
|
|
definition_id: str | None = None,
|
|
limit: int = 100,
|
|
cursor: str | None = None,
|
|
) -> WorkflowInstanceSummaryListResponse:
|
|
_require_read(principal)
|
|
if not 1 <= limit <= MAX_PAGE_SIZE:
|
|
raise ValueError("Workflow page size must be between 1 and 200.")
|
|
boundary = _cursor_boundary(cursor) if cursor is not None else None
|
|
statement = _summary_statement().where(
|
|
WorkflowInstance.tenant_id == principal.tenant_id,
|
|
)
|
|
if definition_id is not None:
|
|
statement = statement.where(WorkflowInstance.definition_id == definition_id)
|
|
statement = statement.order_by(
|
|
WorkflowInstance.created_at.desc(), WorkflowInstance.id.desc()
|
|
)
|
|
items: list[WorkflowInstanceSummaryResponse] = []
|
|
last_cursor: str | None = None
|
|
# Authorization can depend on a Core policy provider, so a raw SQL count or
|
|
# SQL limit before authorization cannot define an authorized page. Scan in
|
|
# bounded batches until the page and one authorized lookahead are found.
|
|
while True:
|
|
decisions: dict[str, bool] = {}
|
|
batch_statement = statement
|
|
if boundary is not None:
|
|
created_at, instance_id = boundary
|
|
batch_statement = batch_statement.where(
|
|
or_(
|
|
WorkflowInstance.created_at < created_at,
|
|
and_(
|
|
WorkflowInstance.created_at == created_at,
|
|
WorkflowInstance.id < instance_id,
|
|
),
|
|
)
|
|
)
|
|
candidates = list(session.scalars(batch_statement.limit(_CANDIDATE_BATCH_SIZE)))
|
|
for instance in candidates:
|
|
_require_evidence(instance)
|
|
allowed = decisions.get(instance.definition_id)
|
|
if allowed is None:
|
|
allowed = definition_decision(
|
|
instance.definition,
|
|
principal=principal,
|
|
registry=registry,
|
|
action="view",
|
|
).allowed
|
|
decisions[instance.definition_id] = allowed
|
|
if not allowed:
|
|
continue
|
|
if len(items) == limit:
|
|
return WorkflowInstanceSummaryListResponse(
|
|
instances=items, next_cursor=last_cursor
|
|
)
|
|
items.append(_summary(instance))
|
|
last_cursor = _cursor(instance)
|
|
if len(candidates) < _CANDIDATE_BATCH_SIZE:
|
|
return WorkflowInstanceSummaryListResponse(instances=items)
|
|
last = candidates[-1]
|
|
boundary = (last.created_at, last.id)
|
|
|
|
|
|
def _authorized_instance(
|
|
session: Session,
|
|
*,
|
|
principal: ApiPrincipal,
|
|
registry: object | None,
|
|
instance_id: str,
|
|
) -> WorkflowInstance:
|
|
_require_read(principal)
|
|
instance = session.scalar(
|
|
_summary_statement().where(
|
|
WorkflowInstance.tenant_id == principal.tenant_id,
|
|
WorkflowInstance.id == instance_id,
|
|
)
|
|
)
|
|
if instance is None:
|
|
raise WorkflowNotFoundError("Workflow instance not found.")
|
|
_require_evidence(instance)
|
|
require_definition_action(
|
|
instance.definition, principal=principal, registry=registry, action="view"
|
|
)
|
|
return instance
|
|
|
|
|
|
def get_instance_summary(
|
|
session: Session,
|
|
*,
|
|
principal: ApiPrincipal,
|
|
registry: object | None,
|
|
instance_id: str,
|
|
) -> WorkflowInstanceSummaryResponse:
|
|
return _summary(
|
|
_authorized_instance(
|
|
session, principal=principal, registry=registry, instance_id=instance_id
|
|
)
|
|
)
|
|
|
|
|
|
def instance_history_page(
|
|
session: Session,
|
|
*,
|
|
principal: ApiPrincipal,
|
|
registry: object | None,
|
|
instance_id: str,
|
|
kind: str,
|
|
limit: int = 100,
|
|
after_sequence: int = 0,
|
|
through_sequence: int | None = None,
|
|
) -> WorkflowInstanceStepPageResponse | WorkflowInstanceEventPageResponse:
|
|
if kind not in {"steps", "events"}:
|
|
raise ValueError("Workflow history kind must be steps or events.")
|
|
if (
|
|
not 1 <= limit <= MAX_PAGE_SIZE
|
|
or not 0 <= after_sequence <= MAX_HISTORY_SEQUENCE
|
|
or (
|
|
through_sequence is not None
|
|
and not 0 <= through_sequence <= MAX_HISTORY_SEQUENCE
|
|
)
|
|
):
|
|
raise ValueError("Invalid workflow history page bounds.")
|
|
_authorized_instance(
|
|
session, principal=principal, registry=registry, instance_id=instance_id
|
|
)
|
|
model = WorkflowInstanceStep if kind == "steps" else WorkflowInstanceEvent
|
|
predicates = [
|
|
model.tenant_id == principal.tenant_id,
|
|
model.instance_id == instance_id,
|
|
]
|
|
if through_sequence is not None:
|
|
predicates.append(model.sequence <= through_sequence)
|
|
total, last_sequence = session.execute(
|
|
select(func.count(), func.max(model.sequence)).where(*predicates)
|
|
).one()
|
|
upper = int(last_sequence or 0)
|
|
rows = list(
|
|
session.scalars(
|
|
select(model)
|
|
.where(
|
|
*predicates, model.sequence > after_sequence, model.sequence <= upper
|
|
)
|
|
.order_by(model.sequence.asc())
|
|
.limit(limit + 1)
|
|
.execution_options(populate_existing=True)
|
|
)
|
|
)
|
|
next_after = rows[limit - 1].sequence if len(rows) > limit else None
|
|
common = {
|
|
"total": int(total),
|
|
"through_sequence": upper,
|
|
"next_after_sequence": next_after,
|
|
}
|
|
if kind == "steps":
|
|
return WorkflowInstanceStepPageResponse(
|
|
steps=[step_response(row) for row in rows[:limit]], **common
|
|
)
|
|
return WorkflowInstanceEventPageResponse(
|
|
events=[event_response(row) for row in rows[:limit]], **common
|
|
)
|