# Copyright (c) 2026 PowerOn AG # 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 json 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) from modules.dbHelpers.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.datamodelFeatures 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