Spaces:
Sleeping
Sleeping
Commit
·
b65ef75
1
Parent(s):
5bf8ced
feat: add RBAC enforcement for MCP tools and API endpoints
Browse files- backend/api/routes/admin.py +13 -4
- backend/api/routes/analytics.py +16 -5
- backend/api/routes/rag.py +16 -5
- backend/api/utils/access_control.py +23 -0
- backend/mcp_server/common/access_control.py +69 -0
- backend/mcp_server/common/tenant.py +18 -1
- backend/mcp_server/common/utils.py +15 -0
- backend/tests/test_access_control.py +55 -0
- backend/tests/test_api_endpoints.py +24 -4
backend/api/routes/admin.py
CHANGED
|
@@ -10,6 +10,7 @@ from backend.api.storage.rules_store import RulesStore
|
|
| 10 |
from backend.api.storage.analytics_store import AnalyticsStore
|
| 11 |
from backend.api.services.rule_enhancer import RuleEnhancer
|
| 12 |
from backend.api.services.document_ingestion import extract_text_from_file_bytes
|
|
|
|
| 13 |
|
| 14 |
router = APIRouter()
|
| 15 |
logger = logging.getLogger(__name__)
|
|
@@ -132,7 +133,8 @@ async def add_redflag_rule(
|
|
| 132 |
payload: Optional[RulePayload] = None,
|
| 133 |
rule: Optional[str] = None,
|
| 134 |
x_tenant_id: str = Header(None),
|
| 135 |
-
enhance: bool = Query(True, description="Use LLM to enhance the rule before saving")
|
|
|
|
| 136 |
):
|
| 137 |
"""
|
| 138 |
Adds a new red-flag rule to this tenant.
|
|
@@ -148,6 +150,7 @@ async def add_redflag_rule(
|
|
| 148 |
|
| 149 |
if not x_tenant_id:
|
| 150 |
raise HTTPException(status_code=400, detail="Missing tenant ID")
|
|
|
|
| 151 |
|
| 152 |
rule_value = payload.rule if payload else rule
|
| 153 |
if not rule_value:
|
|
@@ -235,7 +238,8 @@ async def add_redflag_rule(
|
|
| 235 |
async def add_redflag_rules_bulk(
|
| 236 |
payload: BulkRulePayload,
|
| 237 |
x_tenant_id: str = Header(None),
|
| 238 |
-
enhance: bool = Query(True, description="Use LLM to enhance rules before saving")
|
|
|
|
| 239 |
):
|
| 240 |
"""
|
| 241 |
Adds multiple rules in one call.
|
|
@@ -247,6 +251,7 @@ async def add_redflag_rules_bulk(
|
|
| 247 |
"""
|
| 248 |
if not x_tenant_id:
|
| 249 |
raise HTTPException(status_code=400, detail="Missing tenant ID")
|
|
|
|
| 250 |
|
| 251 |
if not payload.rules:
|
| 252 |
raise HTTPException(status_code=400, detail="No rules provided")
|
|
@@ -337,7 +342,8 @@ async def add_redflag_rules_bulk(
|
|
| 337 |
@router.delete("/rules/{rule}")
|
| 338 |
async def delete_redflag_rule(
|
| 339 |
rule: str,
|
| 340 |
-
x_tenant_id: str = Header(None)
|
|
|
|
| 341 |
):
|
| 342 |
"""
|
| 343 |
Deletes a red-flag rule for this tenant.
|
|
@@ -345,6 +351,7 @@ async def delete_redflag_rule(
|
|
| 345 |
|
| 346 |
if not x_tenant_id:
|
| 347 |
raise HTTPException(status_code=400, detail="Missing tenant ID")
|
|
|
|
| 348 |
|
| 349 |
deleted = rules_store.delete_rule(x_tenant_id, rule)
|
| 350 |
if not deleted:
|
|
@@ -425,7 +432,8 @@ async def get_tool_logs(
|
|
| 425 |
async def upload_rules_from_file(
|
| 426 |
file: UploadFile = File(...),
|
| 427 |
x_tenant_id: str = Header(None),
|
| 428 |
-
enhance: bool = Query(True, description="Use LLM to enhance rules before saving")
|
|
|
|
| 429 |
):
|
| 430 |
"""
|
| 431 |
Upload rules from a file (TXT, PDF, DOC, DOCX).
|
|
@@ -433,6 +441,7 @@ async def upload_rules_from_file(
|
|
| 433 |
"""
|
| 434 |
if not x_tenant_id:
|
| 435 |
raise HTTPException(status_code=400, detail="Missing tenant ID")
|
|
|
|
| 436 |
|
| 437 |
if not file.filename:
|
| 438 |
raise HTTPException(status_code=400, detail="No file provided")
|
|
|
|
| 10 |
from backend.api.storage.analytics_store import AnalyticsStore
|
| 11 |
from backend.api.services.rule_enhancer import RuleEnhancer
|
| 12 |
from backend.api.services.document_ingestion import extract_text_from_file_bytes
|
| 13 |
+
from ..utils.access_control import require_api_permission
|
| 14 |
|
| 15 |
router = APIRouter()
|
| 16 |
logger = logging.getLogger(__name__)
|
|
|
|
| 133 |
payload: Optional[RulePayload] = None,
|
| 134 |
rule: Optional[str] = None,
|
| 135 |
x_tenant_id: str = Header(None),
|
| 136 |
+
enhance: bool = Query(True, description="Use LLM to enhance the rule before saving"),
|
| 137 |
+
x_user_role: str = Header("viewer")
|
| 138 |
):
|
| 139 |
"""
|
| 140 |
Adds a new red-flag rule to this tenant.
|
|
|
|
| 150 |
|
| 151 |
if not x_tenant_id:
|
| 152 |
raise HTTPException(status_code=400, detail="Missing tenant ID")
|
| 153 |
+
require_api_permission(x_user_role, "manage_rules")
|
| 154 |
|
| 155 |
rule_value = payload.rule if payload else rule
|
| 156 |
if not rule_value:
|
|
|
|
| 238 |
async def add_redflag_rules_bulk(
|
| 239 |
payload: BulkRulePayload,
|
| 240 |
x_tenant_id: str = Header(None),
|
| 241 |
+
enhance: bool = Query(True, description="Use LLM to enhance rules before saving"),
|
| 242 |
+
x_user_role: str = Header("viewer")
|
| 243 |
):
|
| 244 |
"""
|
| 245 |
Adds multiple rules in one call.
|
|
|
|
| 251 |
"""
|
| 252 |
if not x_tenant_id:
|
| 253 |
raise HTTPException(status_code=400, detail="Missing tenant ID")
|
| 254 |
+
require_api_permission(x_user_role, "manage_rules")
|
| 255 |
|
| 256 |
if not payload.rules:
|
| 257 |
raise HTTPException(status_code=400, detail="No rules provided")
|
|
|
|
| 342 |
@router.delete("/rules/{rule}")
|
| 343 |
async def delete_redflag_rule(
|
| 344 |
rule: str,
|
| 345 |
+
x_tenant_id: str = Header(None),
|
| 346 |
+
x_user_role: str = Header("viewer")
|
| 347 |
):
|
| 348 |
"""
|
| 349 |
Deletes a red-flag rule for this tenant.
|
|
|
|
| 351 |
|
| 352 |
if not x_tenant_id:
|
| 353 |
raise HTTPException(status_code=400, detail="Missing tenant ID")
|
| 354 |
+
require_api_permission(x_user_role, "manage_rules")
|
| 355 |
|
| 356 |
deleted = rules_store.delete_rule(x_tenant_id, rule)
|
| 357 |
if not deleted:
|
|
|
|
| 432 |
async def upload_rules_from_file(
|
| 433 |
file: UploadFile = File(...),
|
| 434 |
x_tenant_id: str = Header(None),
|
| 435 |
+
enhance: bool = Query(True, description="Use LLM to enhance rules before saving"),
|
| 436 |
+
x_user_role: str = Header("viewer")
|
| 437 |
):
|
| 438 |
"""
|
| 439 |
Upload rules from a file (TXT, PDF, DOC, DOCX).
|
|
|
|
| 441 |
"""
|
| 442 |
if not x_tenant_id:
|
| 443 |
raise HTTPException(status_code=400, detail="Missing tenant ID")
|
| 444 |
+
require_api_permission(x_user_role, "manage_rules")
|
| 445 |
|
| 446 |
if not file.filename:
|
| 447 |
raise HTTPException(status_code=400, detail="No file provided")
|
backend/api/routes/analytics.py
CHANGED
|
@@ -3,6 +3,7 @@ from typing import Optional
|
|
| 3 |
from datetime import datetime, timedelta
|
| 4 |
|
| 5 |
from ..storage.analytics_store import AnalyticsStore
|
|
|
|
| 6 |
|
| 7 |
router = APIRouter()
|
| 8 |
|
|
@@ -13,7 +14,8 @@ analytics_store = AnalyticsStore()
|
|
| 13 |
@router.get("/overview")
|
| 14 |
async def analytics_overview(
|
| 15 |
x_tenant_id: str = Header(None),
|
| 16 |
-
days: int = Query(30, description="Number of days to look back")
|
|
|
|
| 17 |
):
|
| 18 |
"""
|
| 19 |
Returns an overview of analytics for the dashboard.
|
|
@@ -22,6 +24,7 @@ async def analytics_overview(
|
|
| 22 |
|
| 23 |
if not x_tenant_id:
|
| 24 |
raise HTTPException(status_code=400, detail="Missing tenant ID")
|
|
|
|
| 25 |
|
| 26 |
since_timestamp = int((datetime.now() - timedelta(days=days)).timestamp()) if days else None
|
| 27 |
|
|
@@ -45,7 +48,8 @@ async def analytics_overview(
|
|
| 45 |
@router.get("/tool-usage")
|
| 46 |
async def analytics_tool_usage(
|
| 47 |
x_tenant_id: str = Header(None),
|
| 48 |
-
days: int = Query(30, description="Number of days to look back")
|
|
|
|
| 49 |
):
|
| 50 |
"""
|
| 51 |
Returns how often each tool (RAG, Web, Admin, LLM) was used with detailed stats.
|
|
@@ -54,6 +58,7 @@ async def analytics_tool_usage(
|
|
| 54 |
|
| 55 |
if not x_tenant_id:
|
| 56 |
raise HTTPException(status_code=400, detail="Missing tenant ID")
|
|
|
|
| 57 |
|
| 58 |
since_timestamp = int((datetime.now() - timedelta(days=days)).timestamp()) if days else None
|
| 59 |
tool_usage = analytics_store.get_tool_usage_stats(x_tenant_id, since_timestamp)
|
|
@@ -69,7 +74,8 @@ async def analytics_tool_usage(
|
|
| 69 |
async def analytics_redflags(
|
| 70 |
x_tenant_id: str = Header(None),
|
| 71 |
limit: int = Query(50, description="Maximum number of violations to return"),
|
| 72 |
-
days: int = Query(30, description="Number of days to look back")
|
|
|
|
| 73 |
):
|
| 74 |
"""
|
| 75 |
Returns red-flag violations for this tenant.
|
|
@@ -78,6 +84,7 @@ async def analytics_redflags(
|
|
| 78 |
|
| 79 |
if not x_tenant_id:
|
| 80 |
raise HTTPException(status_code=400, detail="Missing tenant ID")
|
|
|
|
| 81 |
|
| 82 |
since_timestamp = int((datetime.now() - timedelta(days=days)).timestamp()) if days else None
|
| 83 |
redflags = analytics_store.get_redflag_violations(x_tenant_id, limit, since_timestamp)
|
|
@@ -97,7 +104,8 @@ async def analytics_redflags(
|
|
| 97 |
@router.get("/activity")
|
| 98 |
async def analytics_activity(
|
| 99 |
x_tenant_id: str = Header(None),
|
| 100 |
-
days: int = Query(30, description="Number of days to look back")
|
|
|
|
| 101 |
):
|
| 102 |
"""
|
| 103 |
Returns general tenant activity statistics.
|
|
@@ -106,6 +114,7 @@ async def analytics_activity(
|
|
| 106 |
|
| 107 |
if not x_tenant_id:
|
| 108 |
raise HTTPException(status_code=400, detail="Missing tenant ID")
|
|
|
|
| 109 |
|
| 110 |
since_timestamp = int((datetime.now() - timedelta(days=days)).timestamp()) if days else None
|
| 111 |
activity = analytics_store.get_activity_summary(x_tenant_id, since_timestamp)
|
|
@@ -120,7 +129,8 @@ async def analytics_activity(
|
|
| 120 |
@router.get("/rag-quality")
|
| 121 |
async def analytics_rag_quality(
|
| 122 |
x_tenant_id: str = Header(None),
|
| 123 |
-
days: int = Query(30, description="Number of days to look back")
|
|
|
|
| 124 |
):
|
| 125 |
"""
|
| 126 |
Returns RAG quality metrics including recall/precision indicators.
|
|
@@ -129,6 +139,7 @@ async def analytics_rag_quality(
|
|
| 129 |
|
| 130 |
if not x_tenant_id:
|
| 131 |
raise HTTPException(status_code=400, detail="Missing tenant ID")
|
|
|
|
| 132 |
|
| 133 |
since_timestamp = int((datetime.now() - timedelta(days=days)).timestamp()) if days else None
|
| 134 |
rag_quality = analytics_store.get_rag_quality_metrics(x_tenant_id, since_timestamp)
|
|
|
|
| 3 |
from datetime import datetime, timedelta
|
| 4 |
|
| 5 |
from ..storage.analytics_store import AnalyticsStore
|
| 6 |
+
from ..utils.access_control import require_api_permission
|
| 7 |
|
| 8 |
router = APIRouter()
|
| 9 |
|
|
|
|
| 14 |
@router.get("/overview")
|
| 15 |
async def analytics_overview(
|
| 16 |
x_tenant_id: str = Header(None),
|
| 17 |
+
days: int = Query(30, description="Number of days to look back"),
|
| 18 |
+
x_user_role: str = Header("viewer")
|
| 19 |
):
|
| 20 |
"""
|
| 21 |
Returns an overview of analytics for the dashboard.
|
|
|
|
| 24 |
|
| 25 |
if not x_tenant_id:
|
| 26 |
raise HTTPException(status_code=400, detail="Missing tenant ID")
|
| 27 |
+
require_api_permission(x_user_role, "view_analytics")
|
| 28 |
|
| 29 |
since_timestamp = int((datetime.now() - timedelta(days=days)).timestamp()) if days else None
|
| 30 |
|
|
|
|
| 48 |
@router.get("/tool-usage")
|
| 49 |
async def analytics_tool_usage(
|
| 50 |
x_tenant_id: str = Header(None),
|
| 51 |
+
days: int = Query(30, description="Number of days to look back"),
|
| 52 |
+
x_user_role: str = Header("viewer")
|
| 53 |
):
|
| 54 |
"""
|
| 55 |
Returns how often each tool (RAG, Web, Admin, LLM) was used with detailed stats.
|
|
|
|
| 58 |
|
| 59 |
if not x_tenant_id:
|
| 60 |
raise HTTPException(status_code=400, detail="Missing tenant ID")
|
| 61 |
+
require_api_permission(x_user_role, "view_analytics")
|
| 62 |
|
| 63 |
since_timestamp = int((datetime.now() - timedelta(days=days)).timestamp()) if days else None
|
| 64 |
tool_usage = analytics_store.get_tool_usage_stats(x_tenant_id, since_timestamp)
|
|
|
|
| 74 |
async def analytics_redflags(
|
| 75 |
x_tenant_id: str = Header(None),
|
| 76 |
limit: int = Query(50, description="Maximum number of violations to return"),
|
| 77 |
+
days: int = Query(30, description="Number of days to look back"),
|
| 78 |
+
x_user_role: str = Header("viewer")
|
| 79 |
):
|
| 80 |
"""
|
| 81 |
Returns red-flag violations for this tenant.
|
|
|
|
| 84 |
|
| 85 |
if not x_tenant_id:
|
| 86 |
raise HTTPException(status_code=400, detail="Missing tenant ID")
|
| 87 |
+
require_api_permission(x_user_role, "view_analytics")
|
| 88 |
|
| 89 |
since_timestamp = int((datetime.now() - timedelta(days=days)).timestamp()) if days else None
|
| 90 |
redflags = analytics_store.get_redflag_violations(x_tenant_id, limit, since_timestamp)
|
|
|
|
| 104 |
@router.get("/activity")
|
| 105 |
async def analytics_activity(
|
| 106 |
x_tenant_id: str = Header(None),
|
| 107 |
+
days: int = Query(30, description="Number of days to look back"),
|
| 108 |
+
x_user_role: str = Header("viewer")
|
| 109 |
):
|
| 110 |
"""
|
| 111 |
Returns general tenant activity statistics.
|
|
|
|
| 114 |
|
| 115 |
if not x_tenant_id:
|
| 116 |
raise HTTPException(status_code=400, detail="Missing tenant ID")
|
| 117 |
+
require_api_permission(x_user_role, "view_analytics")
|
| 118 |
|
| 119 |
since_timestamp = int((datetime.now() - timedelta(days=days)).timestamp()) if days else None
|
| 120 |
activity = analytics_store.get_activity_summary(x_tenant_id, since_timestamp)
|
|
|
|
| 129 |
@router.get("/rag-quality")
|
| 130 |
async def analytics_rag_quality(
|
| 131 |
x_tenant_id: str = Header(None),
|
| 132 |
+
days: int = Query(30, description="Number of days to look back"),
|
| 133 |
+
x_user_role: str = Header("viewer")
|
| 134 |
):
|
| 135 |
"""
|
| 136 |
Returns RAG quality metrics including recall/precision indicators.
|
|
|
|
| 139 |
|
| 140 |
if not x_tenant_id:
|
| 141 |
raise HTTPException(status_code=400, detail="Missing tenant ID")
|
| 142 |
+
require_api_permission(x_user_role, "view_analytics")
|
| 143 |
|
| 144 |
since_timestamp = int((datetime.now() - timedelta(days=days)).timestamp()) if days else None
|
| 145 |
rag_quality = analytics_store.get_rag_quality_metrics(x_tenant_id, since_timestamp)
|
backend/api/routes/rag.py
CHANGED
|
@@ -9,6 +9,7 @@ from api.services.document_ingestion import (
|
|
| 9 |
normalize_text,
|
| 10 |
extract_text_from_file_bytes
|
| 11 |
)
|
|
|
|
| 12 |
|
| 13 |
router = APIRouter()
|
| 14 |
rag_client = RAGClient()
|
|
@@ -58,7 +59,8 @@ async def rag_search(
|
|
| 58 |
@router.post("/ingest")
|
| 59 |
async def rag_ingest(
|
| 60 |
req: IngestRequest,
|
| 61 |
-
x_tenant_id: str = Header(None)
|
|
|
|
| 62 |
):
|
| 63 |
"""
|
| 64 |
Legacy ingestion endpoint - simple content ingestion.
|
|
@@ -67,6 +69,7 @@ async def rag_ingest(
|
|
| 67 |
|
| 68 |
if not x_tenant_id:
|
| 69 |
raise HTTPException(status_code=400, detail="Missing tenant ID")
|
|
|
|
| 70 |
|
| 71 |
try:
|
| 72 |
result = await rag_client.ingest(req.content, x_tenant_id)
|
|
@@ -82,7 +85,8 @@ async def rag_ingest(
|
|
| 82 |
@router.post("/ingest-document")
|
| 83 |
async def rag_ingest_document(
|
| 84 |
req: DocumentIngestRequest,
|
| 85 |
-
x_tenant_id: Optional[str] = Header(None)
|
|
|
|
| 86 |
):
|
| 87 |
"""
|
| 88 |
Enhanced document ingestion endpoint matching the system prompt specification.
|
|
@@ -110,6 +114,7 @@ async def rag_ingest_document(
|
|
| 110 |
tenant_id = req.tenant_id or x_tenant_id
|
| 111 |
if not tenant_id:
|
| 112 |
raise HTTPException(status_code=400, detail="Missing tenant ID")
|
|
|
|
| 113 |
|
| 114 |
try:
|
| 115 |
# Prepare ingestion payload (async for URL fetching)
|
|
@@ -141,7 +146,8 @@ async def rag_ingest_document(
|
|
| 141 |
async def rag_ingest_file(
|
| 142 |
file: UploadFile = File(...),
|
| 143 |
x_tenant_id: Optional[str] = Header(None),
|
| 144 |
-
tenant_id: Optional[str] = Form(None)
|
|
|
|
| 145 |
):
|
| 146 |
"""
|
| 147 |
File upload endpoint for binary files (PDF, DOCX, TXT, MD).
|
|
@@ -159,6 +165,7 @@ async def rag_ingest_file(
|
|
| 159 |
tenant_id_value = tenant_id or x_tenant_id
|
| 160 |
if not tenant_id_value:
|
| 161 |
raise HTTPException(status_code=400, detail="Missing tenant ID")
|
|
|
|
| 162 |
|
| 163 |
try:
|
| 164 |
# Read file bytes
|
|
@@ -225,13 +232,15 @@ async def rag_list(
|
|
| 225 |
@router.delete("/delete/{document_id}")
|
| 226 |
async def rag_delete(
|
| 227 |
document_id: int,
|
| 228 |
-
x_tenant_id: str = Header(None)
|
|
|
|
| 229 |
):
|
| 230 |
"""
|
| 231 |
Delete a specific document by ID from tenant knowledge base.
|
| 232 |
"""
|
| 233 |
if not x_tenant_id:
|
| 234 |
raise HTTPException(status_code=400, detail="Missing tenant ID")
|
|
|
|
| 235 |
|
| 236 |
try:
|
| 237 |
result = await rag_client.delete_document(x_tenant_id, document_id)
|
|
@@ -253,13 +262,15 @@ async def rag_delete(
|
|
| 253 |
|
| 254 |
@router.delete("/delete-all")
|
| 255 |
async def rag_delete_all(
|
| 256 |
-
x_tenant_id: str = Header(None)
|
|
|
|
| 257 |
):
|
| 258 |
"""
|
| 259 |
Delete all documents for a tenant.
|
| 260 |
"""
|
| 261 |
if not x_tenant_id:
|
| 262 |
raise HTTPException(status_code=400, detail="Missing tenant ID")
|
|
|
|
| 263 |
|
| 264 |
try:
|
| 265 |
result = await rag_client.delete_all_documents(x_tenant_id)
|
|
|
|
| 9 |
normalize_text,
|
| 10 |
extract_text_from_file_bytes
|
| 11 |
)
|
| 12 |
+
from ..utils.access_control import require_api_permission
|
| 13 |
|
| 14 |
router = APIRouter()
|
| 15 |
rag_client = RAGClient()
|
|
|
|
| 59 |
@router.post("/ingest")
|
| 60 |
async def rag_ingest(
|
| 61 |
req: IngestRequest,
|
| 62 |
+
x_tenant_id: str = Header(None),
|
| 63 |
+
x_user_role: str = Header("viewer")
|
| 64 |
):
|
| 65 |
"""
|
| 66 |
Legacy ingestion endpoint - simple content ingestion.
|
|
|
|
| 69 |
|
| 70 |
if not x_tenant_id:
|
| 71 |
raise HTTPException(status_code=400, detail="Missing tenant ID")
|
| 72 |
+
require_api_permission(x_user_role, "ingest_documents")
|
| 73 |
|
| 74 |
try:
|
| 75 |
result = await rag_client.ingest(req.content, x_tenant_id)
|
|
|
|
| 85 |
@router.post("/ingest-document")
|
| 86 |
async def rag_ingest_document(
|
| 87 |
req: DocumentIngestRequest,
|
| 88 |
+
x_tenant_id: Optional[str] = Header(None),
|
| 89 |
+
x_user_role: str = Header("viewer")
|
| 90 |
):
|
| 91 |
"""
|
| 92 |
Enhanced document ingestion endpoint matching the system prompt specification.
|
|
|
|
| 114 |
tenant_id = req.tenant_id or x_tenant_id
|
| 115 |
if not tenant_id:
|
| 116 |
raise HTTPException(status_code=400, detail="Missing tenant ID")
|
| 117 |
+
require_api_permission(x_user_role, "ingest_documents")
|
| 118 |
|
| 119 |
try:
|
| 120 |
# Prepare ingestion payload (async for URL fetching)
|
|
|
|
| 146 |
async def rag_ingest_file(
|
| 147 |
file: UploadFile = File(...),
|
| 148 |
x_tenant_id: Optional[str] = Header(None),
|
| 149 |
+
tenant_id: Optional[str] = Form(None),
|
| 150 |
+
x_user_role: str = Header("viewer")
|
| 151 |
):
|
| 152 |
"""
|
| 153 |
File upload endpoint for binary files (PDF, DOCX, TXT, MD).
|
|
|
|
| 165 |
tenant_id_value = tenant_id or x_tenant_id
|
| 166 |
if not tenant_id_value:
|
| 167 |
raise HTTPException(status_code=400, detail="Missing tenant ID")
|
| 168 |
+
require_api_permission(x_user_role, "ingest_documents")
|
| 169 |
|
| 170 |
try:
|
| 171 |
# Read file bytes
|
|
|
|
| 232 |
@router.delete("/delete/{document_id}")
|
| 233 |
async def rag_delete(
|
| 234 |
document_id: int,
|
| 235 |
+
x_tenant_id: str = Header(None),
|
| 236 |
+
x_user_role: str = Header("viewer")
|
| 237 |
):
|
| 238 |
"""
|
| 239 |
Delete a specific document by ID from tenant knowledge base.
|
| 240 |
"""
|
| 241 |
if not x_tenant_id:
|
| 242 |
raise HTTPException(status_code=400, detail="Missing tenant ID")
|
| 243 |
+
require_api_permission(x_user_role, "delete_documents")
|
| 244 |
|
| 245 |
try:
|
| 246 |
result = await rag_client.delete_document(x_tenant_id, document_id)
|
|
|
|
| 262 |
|
| 263 |
@router.delete("/delete-all")
|
| 264 |
async def rag_delete_all(
|
| 265 |
+
x_tenant_id: str = Header(None),
|
| 266 |
+
x_user_role: str = Header("viewer")
|
| 267 |
):
|
| 268 |
"""
|
| 269 |
Delete all documents for a tenant.
|
| 270 |
"""
|
| 271 |
if not x_tenant_id:
|
| 272 |
raise HTTPException(status_code=400, detail="Missing tenant ID")
|
| 273 |
+
require_api_permission(x_user_role, "delete_documents")
|
| 274 |
|
| 275 |
try:
|
| 276 |
result = await rag_client.delete_all_documents(x_tenant_id)
|
backend/api/utils/access_control.py
ADDED
|
@@ -0,0 +1,23 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
from __future__ import annotations
|
| 2 |
+
|
| 3 |
+
from fastapi import HTTPException
|
| 4 |
+
|
| 5 |
+
from backend.mcp_server.common import access_control as shared_access
|
| 6 |
+
|
| 7 |
+
|
| 8 |
+
def require_api_permission(role_header: str | None, action: str) -> str:
|
| 9 |
+
"""
|
| 10 |
+
Normalize the caller role from headers and ensure it can perform the action.
|
| 11 |
+
Raises HTTPException 403 if not permitted.
|
| 12 |
+
Returns the normalized role for downstream logging if needed.
|
| 13 |
+
"""
|
| 14 |
+
role = shared_access.normalize_role(role_header)
|
| 15 |
+
if not shared_access.role_allows(role, action):
|
| 16 |
+
allowed_roles = shared_access.describe_allowed_roles(action)
|
| 17 |
+
raise HTTPException(
|
| 18 |
+
status_code=403,
|
| 19 |
+
detail=f"Role '{role}' lacks permission for '{action}'. Allowed roles: {allowed_roles}."
|
| 20 |
+
)
|
| 21 |
+
return role
|
| 22 |
+
|
| 23 |
+
|
backend/mcp_server/common/access_control.py
ADDED
|
@@ -0,0 +1,69 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
from __future__ import annotations
|
| 2 |
+
|
| 3 |
+
from typing import Optional, Set
|
| 4 |
+
|
| 5 |
+
# Role hierarchy used across MCP server and FastAPI routes
|
| 6 |
+
VALID_ROLES = ("viewer", "editor", "admin", "owner")
|
| 7 |
+
ROLE_ORDER = {role: idx for idx, role in enumerate(VALID_ROLES)}
|
| 8 |
+
|
| 9 |
+
# Permission matrix defining which roles can perform which enterprise actions
|
| 10 |
+
PERMISSIONS: dict[str, Set[str]] = {
|
| 11 |
+
"manage_rules": {"owner", "admin"},
|
| 12 |
+
"ingest_documents": {"owner", "admin", "editor"},
|
| 13 |
+
"delete_documents": {"owner", "admin"},
|
| 14 |
+
"view_analytics": {"owner", "admin"},
|
| 15 |
+
}
|
| 16 |
+
|
| 17 |
+
# Mapping of MCP tool names to enterprise actions
|
| 18 |
+
TOOL_PERMISSION_MAP: dict[str, str] = {
|
| 19 |
+
"admin.addRule": "manage_rules",
|
| 20 |
+
"admin.deleteRule": "manage_rules",
|
| 21 |
+
"rag.ingest": "ingest_documents",
|
| 22 |
+
"rag.delete": "delete_documents",
|
| 23 |
+
}
|
| 24 |
+
|
| 25 |
+
|
| 26 |
+
def normalize_role(raw_value: Optional[str]) -> str:
|
| 27 |
+
"""
|
| 28 |
+
Normalize an inbound role string. Defaults to 'viewer' when undefined or invalid.
|
| 29 |
+
"""
|
| 30 |
+
if not raw_value:
|
| 31 |
+
return "viewer"
|
| 32 |
+
value = raw_value.strip().lower()
|
| 33 |
+
if value not in VALID_ROLES:
|
| 34 |
+
return "viewer"
|
| 35 |
+
return value
|
| 36 |
+
|
| 37 |
+
|
| 38 |
+
def allowed_roles_for(action: str) -> Set[str]:
|
| 39 |
+
"""
|
| 40 |
+
Return the set of roles that can execute the given action.
|
| 41 |
+
If the action is unknown, all roles are allowed.
|
| 42 |
+
"""
|
| 43 |
+
return PERMISSIONS.get(action, set(VALID_ROLES))
|
| 44 |
+
|
| 45 |
+
|
| 46 |
+
def role_allows(role: str, action: str) -> bool:
|
| 47 |
+
"""
|
| 48 |
+
Check whether the supplied role has permission for the action.
|
| 49 |
+
Unknown actions default to allow-all to avoid accidental lockouts.
|
| 50 |
+
"""
|
| 51 |
+
allowed = allowed_roles_for(action)
|
| 52 |
+
return role in allowed if allowed else True
|
| 53 |
+
|
| 54 |
+
|
| 55 |
+
def describe_allowed_roles(action: str) -> str:
|
| 56 |
+
"""
|
| 57 |
+
Return a human-friendly description of which roles are allowed for an action.
|
| 58 |
+
"""
|
| 59 |
+
allowed = sorted(allowed_roles_for(action), key=lambda r: ROLE_ORDER.get(r, 0))
|
| 60 |
+
return ", ".join(allowed)
|
| 61 |
+
|
| 62 |
+
|
| 63 |
+
def get_required_action_for_tool(tool_name: str) -> Optional[str]:
|
| 64 |
+
"""
|
| 65 |
+
Look up the enterprise action that applies to a tool name, if any.
|
| 66 |
+
"""
|
| 67 |
+
return TOOL_PERMISSION_MAP.get(tool_name)
|
| 68 |
+
|
| 69 |
+
|
backend/mcp_server/common/tenant.py
CHANGED
|
@@ -4,6 +4,8 @@ import re
|
|
| 4 |
from dataclasses import dataclass
|
| 5 |
from typing import Any, Mapping, Optional
|
| 6 |
|
|
|
|
|
|
|
| 7 |
|
| 8 |
class TenantValidationError(ValueError):
|
| 9 |
"""Raised when tenant metadata is missing or malformed."""
|
|
@@ -17,6 +19,7 @@ class TenantContext:
|
|
| 17 |
tenant_id: str
|
| 18 |
user_id: Optional[str] = None
|
| 19 |
metadata: Optional[dict[str, Any]] = None
|
|
|
|
| 20 |
|
| 21 |
|
| 22 |
def _extract_tenant_id(payload: Mapping[str, Any]) -> str:
|
|
@@ -43,6 +46,7 @@ def build_tenant_context(payload: Mapping[str, Any]) -> TenantContext:
|
|
| 43 |
tenant_id = _normalize_tenant_id(_extract_tenant_id(payload))
|
| 44 |
user_id: Optional[str] = None
|
| 45 |
metadata: Optional[dict[str, Any]] = None
|
|
|
|
| 46 |
|
| 47 |
for key in ("user_id", "userId"):
|
| 48 |
if key in payload and isinstance(payload[key], str):
|
|
@@ -53,5 +57,18 @@ def build_tenant_context(payload: Mapping[str, Any]) -> TenantContext:
|
|
| 53 |
if isinstance(meta_candidate, dict):
|
| 54 |
metadata = meta_candidate
|
| 55 |
|
| 56 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 57 |
|
|
|
|
| 4 |
from dataclasses import dataclass
|
| 5 |
from typing import Any, Mapping, Optional
|
| 6 |
|
| 7 |
+
from .access_control import normalize_role
|
| 8 |
+
|
| 9 |
|
| 10 |
class TenantValidationError(ValueError):
|
| 11 |
"""Raised when tenant metadata is missing or malformed."""
|
|
|
|
| 19 |
tenant_id: str
|
| 20 |
user_id: Optional[str] = None
|
| 21 |
metadata: Optional[dict[str, Any]] = None
|
| 22 |
+
role: str = "viewer"
|
| 23 |
|
| 24 |
|
| 25 |
def _extract_tenant_id(payload: Mapping[str, Any]) -> str:
|
|
|
|
| 46 |
tenant_id = _normalize_tenant_id(_extract_tenant_id(payload))
|
| 47 |
user_id: Optional[str] = None
|
| 48 |
metadata: Optional[dict[str, Any]] = None
|
| 49 |
+
role: str = "viewer"
|
| 50 |
|
| 51 |
for key in ("user_id", "userId"):
|
| 52 |
if key in payload and isinstance(payload[key], str):
|
|
|
|
| 57 |
if isinstance(meta_candidate, dict):
|
| 58 |
metadata = meta_candidate
|
| 59 |
|
| 60 |
+
# Extract role from payload or metadata (if provided)
|
| 61 |
+
role_candidates = [
|
| 62 |
+
payload.get("role"),
|
| 63 |
+
payload.get("user_role"),
|
| 64 |
+
payload.get("userRole"),
|
| 65 |
+
]
|
| 66 |
+
if metadata:
|
| 67 |
+
role_candidates.append(metadata.get("role"))
|
| 68 |
+
for candidate in role_candidates:
|
| 69 |
+
if isinstance(candidate, str):
|
| 70 |
+
role = normalize_role(candidate)
|
| 71 |
+
break
|
| 72 |
+
|
| 73 |
+
return TenantContext(tenant_id=tenant_id, user_id=user_id, metadata=metadata, role=role)
|
| 74 |
|
backend/mcp_server/common/utils.py
CHANGED
|
@@ -6,6 +6,7 @@ from typing import Any, Awaitable, Callable, Mapping, Optional
|
|
| 6 |
from .logging import log_tool_usage
|
| 7 |
from .tenant import TenantContext, TenantValidationError, build_tenant_context
|
| 8 |
from . import memory
|
|
|
|
| 9 |
|
| 10 |
|
| 11 |
class ToolValidationError(ValueError):
|
|
@@ -16,6 +17,10 @@ class ToolExecutionError(RuntimeError):
|
|
| 16 |
"""Raised for unexpected runtime failures."""
|
| 17 |
|
| 18 |
|
|
|
|
|
|
|
|
|
|
|
|
|
| 19 |
Payload = Mapping[str, Any]
|
| 20 |
ToolHandler = Callable[[TenantContext, Payload], Awaitable[dict[str, Any]] | dict[str, Any]]
|
| 21 |
|
|
@@ -110,6 +115,16 @@ async def execute_tool(
|
|
| 110 |
try:
|
| 111 |
# Tenant context still comes from the original payload
|
| 112 |
context = build_tenant_context(payload)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 113 |
result = await maybe_await(handler(context, mutable_payload))
|
| 114 |
latency_ms = int((time.perf_counter() - start) * 1000)
|
| 115 |
|
|
|
|
| 6 |
from .logging import log_tool_usage
|
| 7 |
from .tenant import TenantContext, TenantValidationError, build_tenant_context
|
| 8 |
from . import memory
|
| 9 |
+
from . import access_control
|
| 10 |
|
| 11 |
|
| 12 |
class ToolValidationError(ValueError):
|
|
|
|
| 17 |
"""Raised for unexpected runtime failures."""
|
| 18 |
|
| 19 |
|
| 20 |
+
class AuthorizationError(ToolValidationError):
|
| 21 |
+
"""Raised when the caller request payload lacks required permissions."""
|
| 22 |
+
|
| 23 |
+
|
| 24 |
Payload = Mapping[str, Any]
|
| 25 |
ToolHandler = Callable[[TenantContext, Payload], Awaitable[dict[str, Any]] | dict[str, Any]]
|
| 26 |
|
|
|
|
| 115 |
try:
|
| 116 |
# Tenant context still comes from the original payload
|
| 117 |
context = build_tenant_context(payload)
|
| 118 |
+
|
| 119 |
+
# Enforce role-based permissions for sensitive tool actions
|
| 120 |
+
required_action = access_control.get_required_action_for_tool(tool_name)
|
| 121 |
+
if required_action and not access_control.role_allows(context.role, required_action):
|
| 122 |
+
allowed_roles = access_control.describe_allowed_roles(required_action)
|
| 123 |
+
raise AuthorizationError(
|
| 124 |
+
f"Role '{context.role}' is not permitted to perform '{required_action}'. "
|
| 125 |
+
f"Allowed roles: {allowed_roles}."
|
| 126 |
+
)
|
| 127 |
+
|
| 128 |
result = await maybe_await(handler(context, mutable_payload))
|
| 129 |
latency_ms = int((time.perf_counter() - start) * 1000)
|
| 130 |
|
backend/tests/test_access_control.py
ADDED
|
@@ -0,0 +1,55 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import sys
|
| 2 |
+
from pathlib import Path
|
| 3 |
+
import pytest
|
| 4 |
+
|
| 5 |
+
# Ensure backend package is importable
|
| 6 |
+
backend_dir = Path(__file__).parent.parent
|
| 7 |
+
sys.path.insert(0, str(backend_dir))
|
| 8 |
+
|
| 9 |
+
from mcp_server.common import access_control
|
| 10 |
+
from mcp_server.common.utils import execute_tool
|
| 11 |
+
|
| 12 |
+
|
| 13 |
+
@pytest.mark.asyncio
|
| 14 |
+
async def test_execute_tool_denies_without_permission():
|
| 15 |
+
async def handler(context, payload):
|
| 16 |
+
return {"ok": True}
|
| 17 |
+
|
| 18 |
+
payload = {
|
| 19 |
+
"tenant_id": "tenant123",
|
| 20 |
+
"session_id": "s1",
|
| 21 |
+
"role": "viewer",
|
| 22 |
+
}
|
| 23 |
+
|
| 24 |
+
result = await execute_tool("rag.ingest", payload, handler)
|
| 25 |
+
assert result["status"] == "error"
|
| 26 |
+
assert result["error_type"] == "validation_error"
|
| 27 |
+
assert "not permitted" in result["message"]
|
| 28 |
+
|
| 29 |
+
|
| 30 |
+
@pytest.mark.asyncio
|
| 31 |
+
async def test_execute_tool_allows_authorized_role():
|
| 32 |
+
async def handler(context, payload):
|
| 33 |
+
return {"ok": True}
|
| 34 |
+
|
| 35 |
+
payload = {
|
| 36 |
+
"tenant_id": "tenant123",
|
| 37 |
+
"session_id": "s1",
|
| 38 |
+
"role": "admin",
|
| 39 |
+
}
|
| 40 |
+
|
| 41 |
+
result = await execute_tool("rag.ingest", payload, handler)
|
| 42 |
+
assert result["status"] == "ok"
|
| 43 |
+
assert result["data"]["ok"] is True
|
| 44 |
+
|
| 45 |
+
|
| 46 |
+
def test_normalize_role_defaults_to_viewer():
|
| 47 |
+
assert access_control.normalize_role(None) == "viewer"
|
| 48 |
+
assert access_control.normalize_role("ADMIN") == "admin"
|
| 49 |
+
assert access_control.normalize_role("unknown") == "viewer"
|
| 50 |
+
|
| 51 |
+
|
| 52 |
+
def test_role_allows_matrix():
|
| 53 |
+
assert access_control.role_allows("owner", "manage_rules")
|
| 54 |
+
assert not access_control.role_allows("viewer", "manage_rules")
|
| 55 |
+
|
backend/tests/test_api_endpoints.py
CHANGED
|
@@ -34,7 +34,7 @@ def test_analytics_overview_endpoint(client):
|
|
| 34 |
"""Test /analytics/overview endpoint."""
|
| 35 |
response = client.get(
|
| 36 |
"/analytics/overview",
|
| 37 |
-
headers={"x-tenant-id": "test_tenant"},
|
| 38 |
params={"days": 30}
|
| 39 |
)
|
| 40 |
|
|
@@ -51,7 +51,7 @@ def test_analytics_tool_usage_endpoint(client):
|
|
| 51 |
"""Test /analytics/tool-usage endpoint."""
|
| 52 |
response = client.get(
|
| 53 |
"/analytics/tool-usage",
|
| 54 |
-
headers={"x-tenant-id": "test_tenant"},
|
| 55 |
params={"days": 30}
|
| 56 |
)
|
| 57 |
|
|
@@ -66,7 +66,7 @@ def test_analytics_rag_quality_endpoint(client):
|
|
| 66 |
"""Test /analytics/rag-quality endpoint."""
|
| 67 |
response = client.get(
|
| 68 |
"/analytics/rag-quality",
|
| 69 |
-
headers={"x-tenant-id": "test_tenant"},
|
| 70 |
params={"days": 30}
|
| 71 |
)
|
| 72 |
|
|
@@ -80,7 +80,7 @@ def test_admin_rules_with_regex(client):
|
|
| 80 |
"""Test adding admin rule with regex pattern and severity."""
|
| 81 |
response = client.post(
|
| 82 |
"/admin/rules",
|
| 83 |
-
headers={"x-tenant-id": "test_tenant"},
|
| 84 |
json={
|
| 85 |
"rule": "Block password queries",
|
| 86 |
"pattern": ".*password.*",
|
|
@@ -200,3 +200,23 @@ def test_admin_tenants_endpoints(client):
|
|
| 200 |
response = client.delete("/admin/tenants/new_tenant")
|
| 201 |
assert response.status_code == 200
|
| 202 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 34 |
"""Test /analytics/overview endpoint."""
|
| 35 |
response = client.get(
|
| 36 |
"/analytics/overview",
|
| 37 |
+
headers={"x-tenant-id": "test_tenant", "x-user-role": "owner"},
|
| 38 |
params={"days": 30}
|
| 39 |
)
|
| 40 |
|
|
|
|
| 51 |
"""Test /analytics/tool-usage endpoint."""
|
| 52 |
response = client.get(
|
| 53 |
"/analytics/tool-usage",
|
| 54 |
+
headers={"x-tenant-id": "test_tenant", "x-user-role": "owner"},
|
| 55 |
params={"days": 30}
|
| 56 |
)
|
| 57 |
|
|
|
|
| 66 |
"""Test /analytics/rag-quality endpoint."""
|
| 67 |
response = client.get(
|
| 68 |
"/analytics/rag-quality",
|
| 69 |
+
headers={"x-tenant-id": "test_tenant", "x-user-role": "owner"},
|
| 70 |
params={"days": 30}
|
| 71 |
)
|
| 72 |
|
|
|
|
| 80 |
"""Test adding admin rule with regex pattern and severity."""
|
| 81 |
response = client.post(
|
| 82 |
"/admin/rules",
|
| 83 |
+
headers={"x-tenant-id": "test_tenant", "x-user-role": "owner"},
|
| 84 |
json={
|
| 85 |
"rule": "Block password queries",
|
| 86 |
"pattern": ".*password.*",
|
|
|
|
| 200 |
response = client.delete("/admin/tenants/new_tenant")
|
| 201 |
assert response.status_code == 200
|
| 202 |
|
| 203 |
+
|
| 204 |
+
def test_analytics_requires_admin_role(client):
|
| 205 |
+
"""Ensure analytics endpoints enforce RBAC."""
|
| 206 |
+
response = client.get(
|
| 207 |
+
"/analytics/overview",
|
| 208 |
+
headers={"x-tenant-id": "test_tenant", "x-user-role": "viewer"},
|
| 209 |
+
params={"days": 7}
|
| 210 |
+
)
|
| 211 |
+
assert response.status_code == 403
|
| 212 |
+
|
| 213 |
+
|
| 214 |
+
def test_admin_rules_requires_admin_role(client):
|
| 215 |
+
"""Ensure rule uploads enforce RBAC."""
|
| 216 |
+
response = client.post(
|
| 217 |
+
"/admin/rules",
|
| 218 |
+
headers={"x-tenant-id": "test_tenant", "x-user-role": "viewer"},
|
| 219 |
+
json={"rule": "No passwords"}
|
| 220 |
+
)
|
| 221 |
+
assert response.status_code == 403
|
| 222 |
+
|