nothingworry commited on
Commit
b65ef75
·
1 Parent(s): 5bf8ced

feat: add RBAC enforcement for MCP tools and API endpoints

Browse files
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
- return TenantContext(tenant_id=tenant_id, user_id=user_id, metadata=metadata)
 
 
 
 
 
 
 
 
 
 
 
 
 
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
+