# Copyright (c) 2025 Patrick Motsch # All rights reserved. """ Mandatsweite WorkflowAutomation API. System-level API for workflows, runs, tasks — scoped by mandate membership, not by graphicalEditor FeatureInstance. Parallel to the legacy per-instance API in routeFeatureGraphicalEditor.py during the migration period. RBAC model: - Read: mandate membership (user sees workflows in own mandates) - Write/Execute: mandate admin or isPlatformAdmin - isPlatformAdmin bypasses all checks """ import json import logging import time from typing import Optional, List, Dict, Any from fastapi import APIRouter, Depends, HTTPException, Query from slowapi import Limiter from slowapi.util import get_remote_address from modules.auth.authentication import getRequestContext, RequestContext from modules.connectors.connectorDbPostgre import DatabaseConnector from modules.datamodels.datamodelWorkflowAutomation import ( AutoWorkflow, AutoVersion, AutoRun, AutoStepLog, AutoTask, GRAPHICAL_EDITOR_DATABASE, ) from modules.datamodels.datamodelPagination import PaginationParams, normalize_pagination_dict from modules.interfaces.interfaceDbApp import getRootInterface from modules.shared.configuration import APP_CONFIG from modules.shared.i18nRegistry import apiRouteContext routeApiMsg = apiRouteContext("routeWorkflowAutomation") logger = logging.getLogger(__name__) limiter = Limiter(key_func=get_remote_address) router = APIRouter(prefix="/api/workflow-automation", tags=["WorkflowAutomation"]) # --------------------------------------------------------------------------- # DB + RBAC helpers # --------------------------------------------------------------------------- def _getDb() -> DatabaseConnector: return DatabaseConnector( dbHost=APP_CONFIG.get("DB_HOST", "localhost"), dbDatabase=GRAPHICAL_EDITOR_DATABASE, dbUser=APP_CONFIG.get("DB_USER"), dbPassword=APP_CONFIG.get("DB_PASSWORD_SECRET") or APP_CONFIG.get("DB_PASSWORD"), dbPort=int(APP_CONFIG.get("DB_PORT", 5432)), userId=None, ) def _getUserMandateIds(userId: str) -> List[str]: rootIface = getRootInterface() memberships = rootIface.getUserMandates(userId) return [um.mandateId for um in memberships if um.mandateId and um.enabled] def _getAdminMandateIds(userId: str, mandateIds: List[str]) -> List[str]: if not mandateIds: return [] rootIface = getRootInterface() from modules.datamodels.datamodelMembership import UserMandate, UserMandateRole memberships = rootIface.db.getRecordset( UserMandate, recordFilter={"userId": userId, "mandateId": mandateIds, "enabled": True}, ) if not memberships: return [] umIdToMandateId: Dict[str, str] = {} for m in memberships: row = m if isinstance(m, dict) else m.__dict__ um_id = row.get("id") mid = row.get("mandateId") if um_id and mid: umIdToMandateId[str(um_id)] = str(mid) userMandateIds = list(umIdToMandateId.keys()) allRoles = rootIface.db.getRecordset( UserMandateRole, recordFilter={"userMandateId": userMandateIds}, ) if not allRoles: return [] roleIds: set = set() roleToMandate: Dict[str, set] = {} for r in allRoles: row = r if isinstance(r, dict) else r.__dict__ rid = row.get("roleId") um_id = row.get("userMandateId") mid = umIdToMandateId.get(str(um_id)) if um_id else None if rid and mid: roleIds.add(rid) roleToMandate.setdefault(rid, set()).add(mid) if not roleIds: return [] from modules.datamodels.datamodelRbac import Role roleRecords = rootIface.db.getRecordset(Role, recordFilter={"id": list(roleIds)}) adminMandates: set = set() for role in (roleRecords or []): row = role if isinstance(role, dict) else role.__dict__ rid = row.get("id") if not rid or rid not in roleToMandate: continue if row.get("roleLabel") == "admin" and not row.get("featureInstanceId"): adminMandates.update(roleToMandate[rid]) return [mid for mid in mandateIds if mid in adminMandates] def _validateWorkflowAccess( context: RequestContext, workflow: Optional[Dict[str, Any]], action: str = "read", ) -> None: """Validate access to a workflow based on mandate membership + admin status. Actions: 'read' (mandate member), 'write'/'execute'/'delete' (mandate admin or platform admin). Raises HTTPException(403) on denial. """ if context.isPlatformAdmin: return userId = str(context.user.id) if context.user else None if not userId: raise HTTPException(status_code=403, detail="Authentication required") if workflow is None: raise HTTPException(status_code=404, detail="Workflow not found") wfMandateId = workflow.get("mandateId") or "" if not wfMandateId: if action == "read": return raise HTTPException(status_code=403, detail="Workflow has no mandate — admin only") userMandateIds = _getUserMandateIds(userId) if wfMandateId not in userMandateIds: raise HTTPException(status_code=403, detail="Not a member of the workflow's mandate") if action == "read": return adminMandateIds = _getAdminMandateIds(userId, [wfMandateId]) if wfMandateId not in adminMandateIds: raise HTTPException( status_code=403, detail=f"Mandate admin required for '{action}' on workflows", ) def _scopedWorkflowFilter(context: RequestContext) -> Optional[Dict[str, Any]]: """Build DB filter for listing workflows: mandate-scoped for members, None for sysadmin.""" if context.isPlatformAdmin: return None userId = str(context.user.id) if context.user else None if not userId: return {"mandateId": "__impossible__"} mandateIds = _getUserMandateIds(userId) if mandateIds: return {"mandateId": mandateIds} return {"mandateId": "__impossible__"} def _scopedRunFilter(context: RequestContext) -> Optional[Dict[str, Any]]: """Build DB filter for listing runs: admin sees mandate runs, user sees own.""" if context.isPlatformAdmin: return None userId = str(context.user.id) if context.user else None if not userId: return {"ownerId": "__impossible__"} mandateIds = _getUserMandateIds(userId) adminMandateIds = _getAdminMandateIds(userId, mandateIds) if adminMandateIds: return {"mandateId": adminMandateIds} return {"ownerId": userId} def _parsePagination(pagination: Optional[str]) -> Optional[PaginationParams]: if not pagination: return None try: d = json.loads(pagination) except json.JSONDecodeError: raise HTTPException(status_code=400, detail="Invalid pagination JSON") if not d: return None return normalize_pagination_dict(d) # --------------------------------------------------------------------------- # Workflow CRUD # --------------------------------------------------------------------------- @router.get("/workflows") async def _listWorkflows( request: RequestContext = Depends(getRequestContext), pagination: Optional[str] = Query(default=None), mandateId: Optional[str] = Query(default=None), ): db = _getDb() try: db._ensureTableExists(AutoWorkflow) scopeFilter = _scopedWorkflowFilter(request) if mandateId and scopeFilter is not None: if mandateId not in (scopeFilter.get("mandateId") or []): return {"items": [], "total": 0} scopeFilter = {"mandateId": mandateId} elif mandateId and scopeFilter is None: scopeFilter = {"mandateId": mandateId} params = _parsePagination(pagination) records = db.getRecordset(AutoWorkflow, recordFilter=scopeFilter, pagination=params) total = db.getRecordCount(AutoWorkflow, recordFilter=scopeFilter) if params else len(records or []) return {"items": records or [], "total": total} finally: db.close() @router.get("/workflows/{workflowId}") async def _getWorkflow( workflowId: str, request: RequestContext = Depends(getRequestContext), ): db = _getDb() try: db._ensureTableExists(AutoWorkflow) wf = db.getRecord(AutoWorkflow, workflowId) if not wf: raise HTTPException(status_code=404, detail="Workflow not found") _validateWorkflowAccess(request, wf, "read") return wf finally: db.close() @router.post("/workflows") async def _createWorkflow( request: RequestContext = Depends(getRequestContext), body: Dict[str, Any] = {}, ): mandateId = body.get("mandateId") if not mandateId: raise HTTPException(status_code=400, detail="mandateId required") _validateWorkflowAccess(request, {"mandateId": mandateId}, "write") db = _getDb() try: db._ensureTableExists(AutoWorkflow) import uuid data = {**body, "id": str(uuid.uuid4())} if request.user: data.setdefault("runAsPrincipal", str(request.user.id)) rec = db.recordCreate(AutoWorkflow, data) return rec finally: db.close() @router.put("/workflows/{workflowId}") async def _updateWorkflow( workflowId: str, request: RequestContext = Depends(getRequestContext), body: Dict[str, Any] = {}, ): db = _getDb() try: db._ensureTableExists(AutoWorkflow) wf = db.getRecord(AutoWorkflow, workflowId) _validateWorkflowAccess(request, wf, "write") updated = db.recordModify(AutoWorkflow, workflowId, body) return updated finally: db.close() @router.delete("/workflows/{workflowId}") async def _deleteWorkflow( workflowId: str, request: RequestContext = Depends(getRequestContext), ): db = _getDb() try: db._ensureTableExists(AutoWorkflow) wf = db.getRecord(AutoWorkflow, workflowId) _validateWorkflowAccess(request, wf, "delete") for v in db.getRecordset(AutoVersion, recordFilter={"workflowId": workflowId}) or []: db.recordDelete(AutoVersion, v.get("id")) for run in db.getRecordset(AutoRun, recordFilter={"workflowId": workflowId}) or []: runId = run.get("id") for sl in db.getRecordset(AutoStepLog, recordFilter={"runId": runId}) or []: db.recordDelete(AutoStepLog, sl.get("id")) db.recordDelete(AutoRun, runId) for task in db.getRecordset(AutoTask, recordFilter={"workflowId": workflowId}) or []: db.recordDelete(AutoTask, task.get("id")) db.recordDelete(AutoWorkflow, workflowId) return {"deleted": True, "workflowId": workflowId} finally: db.close() # --------------------------------------------------------------------------- # Runs # --------------------------------------------------------------------------- @router.get("/runs") async def _listRuns( request: RequestContext = Depends(getRequestContext), pagination: Optional[str] = Query(default=None), mandateId: Optional[str] = Query(default=None), workflowId: Optional[str] = Query(default=None), ): db = _getDb() try: db._ensureTableExists(AutoRun) scopeFilter = _scopedRunFilter(request) if mandateId: if scopeFilter is None: scopeFilter = {"mandateId": mandateId} elif "mandateId" in scopeFilter: if mandateId not in scopeFilter["mandateId"]: return {"items": [], "total": 0} scopeFilter = {"mandateId": mandateId} if workflowId: scopeFilter = {**(scopeFilter or {}), "workflowId": workflowId} params = _parsePagination(pagination) records = db.getRecordset(AutoRun, recordFilter=scopeFilter, pagination=params) total = db.getRecordCount(AutoRun, recordFilter=scopeFilter) if params else len(records or []) return {"items": records or [], "total": total} finally: db.close() @router.get("/runs/{runId}") async def _getRun( runId: str, request: RequestContext = Depends(getRequestContext), ): db = _getDb() try: db._ensureTableExists(AutoRun) run = db.getRecord(AutoRun, runId) if not run: raise HTTPException(status_code=404, detail="Run not found") wfId = run.get("workflowId") if wfId: wf = db.getRecord(AutoWorkflow, wfId) _validateWorkflowAccess(request, wf, "read") return run finally: db.close() # --------------------------------------------------------------------------- # Tasks # --------------------------------------------------------------------------- @router.get("/tasks") async def _listTasks( request: RequestContext = Depends(getRequestContext), pagination: Optional[str] = Query(default=None), status: Optional[str] = Query(default=None), ): db = _getDb() try: db._ensureTableExists(AutoTask) scopeFilter: Optional[Dict[str, Any]] = None if not request.isPlatformAdmin: userId = str(request.user.id) if request.user else None if not userId: return {"items": [], "total": 0} scopeFilter = {"assigneeId": userId} if status: scopeFilter = {**(scopeFilter or {}), "status": status} params = _parsePagination(pagination) records = db.getRecordset(AutoTask, recordFilter=scopeFilter, pagination=params) total = db.getRecordCount(AutoTask, recordFilter=scopeFilter) if params else len(records or []) return {"items": records or [], "total": total} finally: db.close() # --------------------------------------------------------------------------- # Versions # --------------------------------------------------------------------------- @router.get("/workflows/{workflowId}/versions") async def _listVersions( workflowId: str, request: RequestContext = Depends(getRequestContext), ): db = _getDb() try: db._ensureTableExists(AutoWorkflow) wf = db.getRecord(AutoWorkflow, workflowId) _validateWorkflowAccess(request, wf, "read") db._ensureTableExists(AutoVersion) versions = db.getRecordset(AutoVersion, recordFilter={"workflowId": workflowId}) return {"items": versions or []} finally: db.close() # --------------------------------------------------------------------------- # Step logs # --------------------------------------------------------------------------- @router.get("/runs/{runId}/steps") async def _listStepLogs( runId: str, request: RequestContext = Depends(getRequestContext), ): db = _getDb() try: db._ensureTableExists(AutoRun) run = db.getRecord(AutoRun, runId) if not run: raise HTTPException(status_code=404, detail="Run not found") wfId = run.get("workflowId") if wfId: wf = db.getRecord(AutoWorkflow, wfId) _validateWorkflowAccess(request, wf, "read") db._ensureTableExists(AutoStepLog) steps = db.getRecordset(AutoStepLog, recordFilter={"runId": runId}) return {"items": steps or []} finally: db.close()