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