platform-core/modules/routes/routeWorkflowAutomation.py
ValueOn AG 39aba4cca8
All checks were successful
Deploy Plattform-Core (Int) / test (push) Successful in 1m2s
Deploy Plattform-Core (Int) / deploy (push) Successful in 9s
before refactory workflowAutomation
2026-06-07 22:26:18 +02:00

453 lines
15 KiB
Python

# 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()