216 lines
8 KiB
Python
216 lines
8 KiB
Python
# Copyright (c) 2026 Patrick Motsch
|
|
# All rights reserved.
|
|
"""Generic UDB (Unified Data Bar) router.
|
|
|
|
The UDB is feature-agnostic: it can render the user's accessible data
|
|
sources (personal + feature-owned) without being coupled to a specific
|
|
caller feature instance. This router owns two endpoints:
|
|
|
|
POST /api/udb/tree/children
|
|
Resolve the children for a list of parent tree keys (UI walks).
|
|
|
|
POST /api/udb/node/{nodeKey}/flag/{flag}
|
|
Persist a new value for a single flag on a single node.
|
|
|
|
Permission policy:
|
|
- DataSource-family nodes: owner-of-record (rec.userId == user).
|
|
- FdsRecord / FdsField nodes: feature-admin on the FDS's
|
|
featureInstanceId (a FeatureAccessRole whose Role.roleLabel ends
|
|
with '-admin').
|
|
- Synthetic containers (personalRoot, mgrp): never editable.
|
|
|
|
See wiki/b-reference/platform/unified-data-bar.md for the full domain
|
|
model and the rationale behind the hard cut from the previous
|
|
feature-instance-scoped endpoints.
|
|
"""
|
|
|
|
import logging
|
|
from typing import Any, Dict, List, Optional
|
|
|
|
from fastapi import APIRouter, Body, Depends, HTTPException, Path, Request
|
|
from pydantic import BaseModel, Field
|
|
|
|
from modules.auth import getRequestContext, limiter, RequestContext
|
|
from modules.shared.i18nRegistry import apiRouteContext
|
|
|
|
routeApiMsg = apiRouteContext("routeUdb")
|
|
logger = logging.getLogger(__name__)
|
|
|
|
|
|
router = APIRouter(
|
|
prefix="/api/udb",
|
|
tags=["Unified Data Bar"],
|
|
responses={
|
|
400: {"description": "Bad request"},
|
|
401: {"description": "Unauthorized"},
|
|
403: {"description": "Forbidden"},
|
|
404: {"description": "Not found"},
|
|
500: {"description": "Internal server error"},
|
|
},
|
|
)
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# POST /api/udb/tree/children
|
|
# ---------------------------------------------------------------------------
|
|
|
|
class _UdbTreeChildrenRequest(BaseModel):
|
|
"""Request body for the generic UDB tree children endpoint."""
|
|
parents: List[Optional[str]] = Field(
|
|
default_factory=list,
|
|
description="List of parent keys to fetch children for. Use null for top-level.",
|
|
)
|
|
|
|
|
|
@router.post("/tree/children")
|
|
@limiter.limit("300/minute")
|
|
async def _udbTreeChildren(
|
|
request: Request,
|
|
body: _UdbTreeChildrenRequest = Body(...),
|
|
context: RequestContext = Depends(getRequestContext),
|
|
) -> Dict[str, Any]:
|
|
"""Resolve children for the given parent keys.
|
|
|
|
The UDB is feature-agnostic; this endpoint requires only that the
|
|
user is authenticated. Visibility is driven by the user's accessible
|
|
mandates and feature instances inside `getChildrenForParents`.
|
|
"""
|
|
from modules.serviceCenter.services.serviceKnowledge._buildTree import getChildrenForParents
|
|
try:
|
|
nodesByParent = await getChildrenForParents(body.parents, context)
|
|
except Exception as exc:
|
|
logger.exception("UDB tree children build failed: %s", exc)
|
|
raise HTTPException(status_code=500, detail=str(exc))
|
|
return {"nodesByParent": nodesByParent}
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# POST /api/udb/node/{nodeKey}/flag/{flag}
|
|
# ---------------------------------------------------------------------------
|
|
|
|
class _UdbFlagBody(BaseModel):
|
|
"""Generic flag-mutation body.
|
|
|
|
Exactly one of `value` / `neutralizeFields` is expected depending on
|
|
the flag (see `_extractFlagValue` for the mapping). `value` is typed
|
|
as Any because the legal type depends on the flag:
|
|
- neutralize/ragIndexEnabled : bool | null (null = inherit)
|
|
"""
|
|
value: Any = Field(default=None, description="New flag value or null to reset to inherit.")
|
|
|
|
|
|
@router.post("/node/{nodeKey:path}/flag/{flag}")
|
|
@limiter.limit("60/minute")
|
|
async def _udbNodeFlag(
|
|
request: Request,
|
|
nodeKey: str = Path(..., description="Tree key of the node to modify"),
|
|
flag: str = Path(..., description="One of: neutralize | ragIndexEnabled"),
|
|
body: _UdbFlagBody = Body(default_factory=_UdbFlagBody),
|
|
context: RequestContext = Depends(getRequestContext),
|
|
) -> Dict[str, Any]:
|
|
"""Persist a new value for `flag` on the node identified by `nodeKey`.
|
|
|
|
`value=null` resets the node to inherit from its ancestor chain (no
|
|
cascade, no purge). `value=true/false` (or a scope string) writes
|
|
the explicit override and cascade-resets any explicit child
|
|
descendants so they re-inherit.
|
|
|
|
RBAC: `node.canEdit(context, rootIf)` decides; the route never
|
|
re-implements ownership rules.
|
|
"""
|
|
if flag not in ("neutralize", "ragIndexEnabled"):
|
|
raise HTTPException(status_code=400, detail=f"Unknown flag: {flag}")
|
|
|
|
value = _validateFlagValue(flag, body.value, context)
|
|
|
|
from modules.interfaces.interfaceDbApp import getRootInterface
|
|
from modules.serviceCenter.services.serviceKnowledge.udbNodes import buildNodeForKey
|
|
rootIf = getRootInterface()
|
|
|
|
node = buildNodeForKey(nodeKey, context, rootIf)
|
|
if node is None:
|
|
raise HTTPException(status_code=404, detail=f"Unknown UDB node key: {nodeKey}")
|
|
|
|
if not node.supportsFlag(flag):
|
|
raise HTTPException(
|
|
status_code=400,
|
|
detail=f"{type(node).__name__} does not support flag '{flag}'",
|
|
)
|
|
if not node.canEdit(context, rootIf):
|
|
raise HTTPException(status_code=403, detail=routeApiMsg("Not allowed to edit this UDB node"))
|
|
|
|
try:
|
|
resetIds = node.setFlag(flag, value, rootIf)
|
|
except NotImplementedError as exc:
|
|
raise HTTPException(status_code=400, detail=str(exc))
|
|
except RuntimeError as exc:
|
|
raise HTTPException(status_code=409, detail=str(exc))
|
|
except Exception as exc:
|
|
logger.exception("UDB setFlag failed: key=%s flag=%s: %s", nodeKey, flag, exc)
|
|
raise HTTPException(status_code=500, detail=str(exc))
|
|
|
|
effective = _computeEffectiveAfterWrite(rootIf, context, node, flag)
|
|
|
|
import json
|
|
from modules.shared.auditLogger import audit_logger
|
|
from modules.datamodels.datamodelAudit import AuditCategory
|
|
audit_logger.logEvent(
|
|
userId=str(context.user.id),
|
|
mandateId=context.mandateId,
|
|
category=AuditCategory.PERMISSION.value,
|
|
action="udb_flag_changed",
|
|
details=json.dumps({
|
|
"nodeKey": nodeKey,
|
|
"flag": flag,
|
|
"value": value,
|
|
"resetDescendants": len(resetIds),
|
|
"nodeKind": type(node).__name__,
|
|
}),
|
|
)
|
|
|
|
return {
|
|
"nodeKey": nodeKey,
|
|
"flag": flag,
|
|
"value": value,
|
|
"effective": effective,
|
|
"resetDescendantIds": resetIds,
|
|
}
|
|
|
|
|
|
def _validateFlagValue(flag: str, value: Any, context: RequestContext) -> Any:
|
|
"""Validate the incoming value matches the flag's expected shape.
|
|
|
|
Returns the validated value (possibly normalised) or raises HTTPException.
|
|
"""
|
|
if value is None:
|
|
return None
|
|
# neutralize / ragIndexEnabled
|
|
if isinstance(value, bool):
|
|
return value
|
|
raise HTTPException(
|
|
status_code=400,
|
|
detail=f"Invalid value for flag {flag!r}: expected bool or null, got {type(value).__name__}",
|
|
)
|
|
|
|
|
|
def _computeEffectiveAfterWrite(rootIf: Any, context: RequestContext,
|
|
node: Any, flag: str) -> Any:
|
|
"""Recompute the node's effective value after the write.
|
|
|
|
Re-loads the relevant recordsets so the cascade resets are visible.
|
|
"""
|
|
from modules.datamodels.datamodelDataSource import DataSource
|
|
from modules.datamodels.datamodelFeatureDataSource import FeatureDataSource
|
|
userId = str(context.user.id)
|
|
allDs = rootIf.db.getRecordset(DataSource, recordFilter={"userId": userId}) or []
|
|
fdsFilter: Dict[str, Any] = {}
|
|
featureInstanceId = getattr(node, "featureInstanceId", None)
|
|
if featureInstanceId:
|
|
fdsFilter["featureInstanceId"] = featureInstanceId
|
|
allFds = rootIf.db.getRecordset(FeatureDataSource, recordFilter=fdsFilter) or []
|
|
try:
|
|
return node.getEffectiveFlag(flag, allDs, allFds, mode="aggregate")
|
|
except Exception as exc:
|
|
logger.warning("Effective-after-write failed for %s flag=%s: %s",
|
|
getattr(node, "key", "?"), flag, exc)
|
|
return None
|