453 lines
15 KiB
Python
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()
|