Skip to content

Commit c5d0eaa

Browse files
author
Olivier Gintrand
committed
fix: enforce access control on all meta-server handlers
All 12 meta-server tool handlers now enforce visibility-based access control using user_email and token_teams from the JWT context. Previously fixed (execute_tool, authorize_gateway, authorize_all_gateways) already passed user context correctly. The following handlers were bypassing access control entirely: - _list_tools: now passes user_email/token_teams to ToolService.list_tools() - _search_tools: replaced raw db.query(Tool) with ToolService.list_tools() - _stub_describe_tool: passes user_email/token_teams to MetaToolService - _get_similar_tools: filters vector search results against accessible tool set via ToolService.list_tools() - _list_resources: applies ResourceService._apply_access_control() to query - _read_resource: checks ResourceService._check_resource_access() before returning content - _list_prompts: applies PromptService._apply_access_control() to query - _get_prompt: checks PromptService._check_prompt_access() before returning content Adds _extract_user_context() static helper for consistent extraction of (user_email, token_teams, request_headers) from handler kwargs. _get_tool_categories remains hardcoded to actor_scope=public_user which is already restrictive by default. Signed-off-by: Olivier Gintrand <olivier.gintrand@forterro.com>
1 parent a589acc commit c5d0eaa

1 file changed

Lines changed: 130 additions & 19 deletions

File tree

mcpgateway/meta_server/service.py

Lines changed: 130 additions & 19 deletions
Original file line numberDiff line numberDiff line change
@@ -264,6 +264,24 @@ def _to_snake(key: str) -> str:
264264

265265
return {_to_snake(k): v for k, v in arguments.items()}
266266

267+
@staticmethod
268+
def _extract_user_context(kwargs: Dict[str, Any]) -> tuple:
269+
"""Extract access-control parameters from handler kwargs.
270+
271+
``handle_meta_tool_call`` passes ``user_email``, ``token_teams``,
272+
and ``request_headers`` through to every handler. This helper
273+
provides a single extraction point so handlers don't repeat the
274+
pattern.
275+
276+
Returns:
277+
Tuple of (user_email, token_teams, request_headers).
278+
"""
279+
return (
280+
kwargs.get("user_email"),
281+
kwargs.get("token_teams"),
282+
kwargs.get("request_headers"),
283+
)
284+
267285
# ------------------------------------------------------------------
268286
# Implemented handlers
269287
# ------------------------------------------------------------------
@@ -297,6 +315,9 @@ async def _search_tools(self, arguments: Dict[str, Any], **kwargs: Any) -> Dict[
297315
tags = arguments.get("tags", [])
298316
include_metrics = arguments.get("include_metrics", False)
299317

318+
# -- Extract user context for access control --
319+
user_email, token_teams, _ = self._extract_user_context(kwargs)
320+
300321
# -- Step 1: Semantic search --
301322
semantic_results = []
302323
try:
@@ -309,39 +330,44 @@ async def _search_tools(self, arguments: Dict[str, Any], **kwargs: Any) -> Dict[
309330
# -- Step 2: Keyword fallback search --
310331
keyword_results = []
311332
try:
333+
from mcpgateway.services.tool_service import ToolService as _KwToolService # pylint: disable=import-outside-toplevel
334+
_kw_ts = _KwToolService()
335+
312336
db_gen = get_db()
313337
db = next(db_gen)
314338
try:
315-
search_pattern = f"%{query}%"
316-
keyword_tools = (
317-
db.query(Tool)
318-
.filter(
319-
Tool.enabled.is_(True),
320-
or_(
321-
Tool._computed_name.ilike(search_pattern),
322-
Tool.description.ilike(search_pattern),
323-
),
324-
)
325-
.limit(limit)
326-
.all()
339+
# Use ToolService.list_tools for consistent access control
340+
kw_result = await _kw_ts.list_tools(
341+
db=db,
342+
include_inactive=False,
343+
limit=limit,
344+
user_email=user_email,
345+
token_teams=token_teams,
327346
)
347+
kw_tools_list, _ = kw_result if isinstance(kw_result, tuple) else (kw_result, None)
328348

329349
query_lower = query.lower()
330-
for tool in keyword_tools:
350+
search_pattern = query_lower
351+
for tool in kw_tools_list:
352+
tool_name = getattr(tool, "name", "")
353+
tool_desc = getattr(tool, "description", "") or ""
354+
# Filter to only matching tools
355+
if search_pattern not in tool_name.lower() and search_pattern not in tool_desc.lower():
356+
continue
331357
# Score 1.0 for exact name match, 0.5 for partial match
332-
if tool._computed_name.lower() == query_lower:
358+
if tool_name.lower() == query_lower:
333359
score = 1.0
334-
elif query_lower in tool.name.lower():
360+
elif query_lower in tool_name.lower():
335361
score = 0.7
336362
else:
337363
score = 0.5
338364

339365
keyword_results.append(
340366
ToolSearchResult(
341-
tool_name=tool.name,
342-
description=tool.description,
343-
server_id=tool.gateway_id,
344-
server_name=tool.gateway.name if tool.gateway else None,
367+
tool_name=tool_name,
368+
description=tool_desc,
369+
server_id=getattr(tool, "gateway_id", None),
370+
server_name=None,
345371
similarity_score=score,
346372
)
347373
)
@@ -414,6 +440,9 @@ async def _get_similar_tools(self, arguments: Dict[str, Any], **kwargs: Any) ->
414440
tool_name = arguments.get("tool_name", "")
415441
limit = arguments.get("limit", 10)
416442

443+
# -- Extract user context for access control --
444+
user_email, token_teams, _ = self._extract_user_context(kwargs)
445+
417446
if not tool_name:
418447
return GetSimilarToolsResponse(
419448
reference_tool=tool_name,
@@ -501,6 +530,34 @@ async def _get_similar_tools(self, arguments: Dict[str, Any], **kwargs: Any) ->
501530
# -- Step 4: Filter out the reference tool itself --
502531
similar_results = [r for r in similar_results if r.tool_name != tool_name][:limit]
503532

533+
# -- Step 4.5: Apply access-control filtering --
534+
# Build set of tool names the user can access, then discard the rest.
535+
if user_email is not None or token_teams is not None:
536+
try:
537+
from mcpgateway.services.tool_service import ToolService as _AcToolService # pylint: disable=import-outside-toplevel
538+
539+
_ac_ts = _AcToolService()
540+
db_gen = get_db()
541+
db = next(db_gen)
542+
try:
543+
ac_result = await _ac_ts.list_tools(
544+
db=db,
545+
include_inactive=False,
546+
limit=0,
547+
user_email=user_email,
548+
token_teams=token_teams,
549+
)
550+
ac_tools_list, _ = ac_result if isinstance(ac_result, tuple) else (ac_result, None)
551+
accessible_names = {getattr(t, "name", "") for t in ac_tools_list}
552+
similar_results = [r for r in similar_results if r.tool_name in accessible_names]
553+
finally:
554+
try:
555+
next(db_gen)
556+
except StopIteration:
557+
pass
558+
except Exception as e:
559+
logger.warning(f"Access control filtering failed for similar tools: {e}")
560+
504561
# -- Step 5: Apply scope filtering --
505562
filtered_results = self._apply_scope_filtering(similar_results, arguments.get("scope"))
506563

@@ -738,6 +795,9 @@ async def _list_tools(self, arguments: Dict[str, Any], **kwargs: Any) -> Dict[st
738795
sort_order = arguments.get("sort_order", "desc")
739796
include_schema = arguments.get("include_schema", False)
740797

798+
# -- Extract user context for access control --
799+
user_email, token_teams, _ = self._extract_user_context(kwargs)
800+
741801
# -- Step 1: Query tools from database using ToolService --
742802
# First-Party
743803
from mcpgateway.services.tool_service import ToolService
@@ -759,6 +819,8 @@ async def _list_tools(self, arguments: Dict[str, Any], **kwargs: Any) -> Dict[st
759819
tags=tags if tags else None,
760820
gateway_id=server_id,
761821
limit=query_limit,
822+
user_email=user_email,
823+
token_teams=token_teams,
762824
)
763825

764826
# Extract tools from result (could be tuple or dict)
@@ -838,6 +900,8 @@ async def _stub_describe_tool(self, arguments: Dict[str, Any], **kwargs: Any) ->
838900
# First-Party
839901
from mcpgateway.services.meta_tool_service import MetaToolService
840902

903+
user_email, token_teams, _ = self._extract_user_context(kwargs)
904+
841905
try:
842906
db_gen = get_db()
843907
db = next(db_gen)
@@ -847,6 +911,8 @@ async def _stub_describe_tool(self, arguments: Dict[str, Any], **kwargs: Any) ->
847911
tool_name=arguments.get("tool_name", ""),
848912
include_metrics=arguments.get("include_metrics", False),
849913
scope=arguments.get("scope"),
914+
user_email=user_email,
915+
token_teams=token_teams,
850916
)
851917
return result.model_dump(by_alias=True)
852918
finally:
@@ -1278,18 +1344,26 @@ async def _list_resources(self, arguments: Dict[str, Any], **kwargs: Any) -> Dic
12781344
ListResourcesResponse as dict.
12791345
"""
12801346
from mcpgateway.db import Resource # pylint: disable=import-outside-toplevel
1347+
from mcpgateway.services.resource_service import ResourceService as _RsService # pylint: disable=import-outside-toplevel
12811348

12821349
limit = arguments.get("limit", 50)
12831350
offset = arguments.get("offset", 0)
12841351
tags = arguments.get("tags", [])
12851352
mime_type = arguments.get("mime_type")
12861353

1354+
# Extract user context for access control
1355+
user_email, token_teams, _ = self._extract_user_context(kwargs)
1356+
12871357
try:
12881358
db_gen = get_db()
12891359
db = next(db_gen)
12901360
try:
12911361
query = db.query(Resource).filter(Resource.enabled.is_(True))
12921362

1363+
# Apply access control
1364+
_rs = _RsService()
1365+
query = await _rs._apply_access_control(query, db, user_email, token_teams)
1366+
12931367
if mime_type:
12941368
query = query.filter(Resource.mime_type == mime_type)
12951369

@@ -1347,6 +1421,7 @@ async def _read_resource(self, arguments: Dict[str, Any], **kwargs: Any) -> Dict
13471421
"""
13481422
from mcpgateway.db import Resource # pylint: disable=import-outside-toplevel
13491423
from mcpgateway.services.observability_service import ObservabilityService, current_trace_id # pylint: disable=import-outside-toplevel
1424+
from mcpgateway.services.resource_service import ResourceService as _RsService # pylint: disable=import-outside-toplevel
13501425

13511426
uri = arguments.get("uri", "")
13521427
if not uri:
@@ -1356,6 +1431,9 @@ async def _read_resource(self, arguments: Dict[str, Any], **kwargs: Any) -> Dict
13561431
text="Error: uri is required",
13571432
).model_dump(by_alias=True)
13581433

1434+
# Extract user context for access control
1435+
user_email, token_teams, _ = self._extract_user_context(kwargs)
1436+
13591437
start_time = time.monotonic()
13601438
success = False
13611439
error_message = None
@@ -1400,6 +1478,16 @@ async def _read_resource(self, arguments: Dict[str, Any], **kwargs: Any) -> Dict
14001478
text=error_message,
14011479
).model_dump(by_alias=True)
14021480

1481+
# Check access control
1482+
_rs = _RsService()
1483+
if not await _rs._check_resource_access(db, resource, user_email, token_teams):
1484+
error_message = f"Resource not found: {uri}"
1485+
return ReadResourceResponse(
1486+
uri=uri,
1487+
name="",
1488+
text=error_message,
1489+
).model_dump(by_alias=True)
1490+
14031491
text_content = resource.text_content
14041492
if text_content is None and resource.binary_content is not None:
14051493
text_content = "(binary content — not displayable as text)"
@@ -1457,16 +1545,25 @@ async def _list_prompts(self, arguments: Dict[str, Any], **kwargs: Any) -> Dict[
14571545
ListPromptsResponse as dict.
14581546
"""
14591547
from mcpgateway.db import Prompt # pylint: disable=import-outside-toplevel
1548+
from mcpgateway.services.prompt_service import PromptService as _PsService # pylint: disable=import-outside-toplevel
14601549

14611550
limit = arguments.get("limit", 50)
14621551
offset = arguments.get("offset", 0)
14631552
tags = arguments.get("tags", [])
14641553

1554+
# Extract user context for access control
1555+
user_email, token_teams, _ = self._extract_user_context(kwargs)
1556+
14651557
try:
14661558
db_gen = get_db()
14671559
db = next(db_gen)
14681560
try:
14691561
query = db.query(Prompt).filter(Prompt.enabled.is_(True))
1562+
1563+
# Apply access control
1564+
_ps = _PsService()
1565+
query = await _ps._apply_access_control(query, db, user_email, token_teams)
1566+
14701567
all_prompts = query.order_by(Prompt.created_at.desc()).all()
14711568

14721569
# Apply tag filtering in Python (tags stored as JSON)
@@ -1519,6 +1616,7 @@ async def _get_prompt(self, arguments: Dict[str, Any], **kwargs: Any) -> Dict[st
15191616
"""
15201617
from mcpgateway.db import Prompt # pylint: disable=import-outside-toplevel
15211618
from mcpgateway.services.observability_service import ObservabilityService, current_trace_id # pylint: disable=import-outside-toplevel
1619+
from mcpgateway.services.prompt_service import PromptService as _PsService # pylint: disable=import-outside-toplevel
15221620

15231621
name = arguments.get("name", "")
15241622
prompt_args = arguments.get("arguments", {})
@@ -1530,6 +1628,9 @@ async def _get_prompt(self, arguments: Dict[str, Any], **kwargs: Any) -> Dict[st
15301628
description="Error: name is required",
15311629
).model_dump(by_alias=True)
15321630

1631+
# Extract user context for access control
1632+
user_email, token_teams, _ = self._extract_user_context(kwargs)
1633+
15331634
start_time = time.monotonic()
15341635
success = False
15351636
error_message = None
@@ -1575,6 +1676,16 @@ async def _get_prompt(self, arguments: Dict[str, Any], **kwargs: Any) -> Dict[st
15751676
description=error_message,
15761677
).model_dump(by_alias=True)
15771678

1679+
# Check access control
1680+
_ps = _PsService()
1681+
if not await _ps._check_prompt_access(db, prompt, user_email, token_teams):
1682+
error_message = f"Prompt not found: {name}"
1683+
return GetPromptResponse(
1684+
name=name,
1685+
template="",
1686+
description=error_message,
1687+
).model_dump(by_alias=True)
1688+
15781689
rendered = None
15791690
if prompt_args:
15801691
try:

0 commit comments

Comments
 (0)