Skip to content

Commit e37afb4

Browse files
authored
Merge pull request #1711 from cnoe-io/fix/rag-mcp-tool-openfga-authz
feat(rbac): OpenFGA authz, team-invoke + org-wide sharing for custom MCP tools
2 parents 7ad035b + 70303aa commit e37afb4

19 files changed

Lines changed: 858 additions & 87 deletions

File tree

ai_platform_engineering/knowledge_bases/rag/common/src/common/models/rag.py

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -317,6 +317,10 @@ class MCPToolConfig(OwnedResourceMixin, BaseModel):
317317
description: str = Field(default="", description="Tool description shown to the LLM agent")
318318
parallel_searches: List[ParallelSearch] = Field(default_factory=lambda: [ParallelSearch(label="results")], description="One or more parallel sub-searches. Response is a dict keyed by label.")
319319
allow_runtime_filters: bool = Field(default=False, description="If True, expose a 'filters' parameter so the LLM can pass extra filters per-call.")
320+
shared_with_org: bool = Field(
321+
default=False,
322+
description="If True, every organization member may call/use this tool (in addition to the owner and shared teams). The OpenFGA projection grants organization#member reader/user/caller.",
323+
)
320324
enabled: bool = True
321325
created_at: int = Field(default=0, description="Unix timestamp of creation")
322326
updated_at: int = Field(default=0, description="Unix timestamp of last update")

ai_platform_engineering/knowledge_bases/rag/server/src/server/rbac.py

Lines changed: 91 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -538,6 +538,97 @@ async def _openfga_check_org_admin(user_context: UserContext) -> bool:
538538
return await _openfga_check_object(user_context, "can_manage", "organization", _caipe_org_key())
539539

540540

541+
# ============================================================================
542+
# Custom MCP tool authorization (spec 2026-06-03-unified-shareable-resource-rbac)
543+
# ============================================================================
544+
#
545+
# Custom MCP tool management (POST/PUT/DELETE /v1/mcp/custom-tools) is a
546+
# group-owned, shareable resource. Authorization for human callers is resolved
547+
# through OpenFGA on the `mcp_tool` type — NOT the legacy coarse `require_role`
548+
# gate, which can never elevate a human above READONLY (see `rbac.py` role
549+
# assignment and `rag/README.md`: "tool authorization comes from OpenFGA
550+
# relationships"). Service principals already carrying the coarse ADMIN role
551+
# (admin client-credentials tokens, CAIPE_UNSAFE_RBAC_BYPASS) keep working so
552+
# existing automation is not regressed.
553+
# assisted-by Cursor claude-opus-4.8
554+
555+
556+
async def authorize_mcp_tool_manage(user_context: UserContext, tool_id: str) -> None:
557+
"""Authorize an update/delete of an existing custom MCP tool.
558+
559+
Allowed when the caller:
560+
- already holds the coarse ADMIN role (admin client-credentials token or the
561+
emergency CAIPE_UNSAFE_RBAC_BYPASS), preserving prior behavior; OR
562+
- is an organization admin in OpenFGA (`organization#can_manage`); OR
563+
- can manage this tool in OpenFGA (`mcp_tool:<tool_id>#can_manage` — i.e. the
564+
tool owner, an owner-team admin, or an org admin).
565+
566+
Fails CLOSED: raises ``HTTPException(403)`` when the caller is not authorized
567+
and ``HTTPException(503)`` when the OpenFGA PDP is unavailable or not
568+
configured, so a PDP outage can never silently grant a write.
569+
"""
570+
if has_permission(user_context.role, Role.ADMIN):
571+
return
572+
if not _openfga_http_url() or not _openfga_user(user_context):
573+
raise HTTPException(
574+
status_code=503,
575+
detail="Authorization service is temporarily unavailable",
576+
)
577+
try:
578+
if await _openfga_check_org_admin(user_context):
579+
return
580+
if await _openfga_check_object(user_context, "can_manage", "mcp_tool", tool_id):
581+
return
582+
except Exception as exc: # noqa: BLE001 — fail closed on PDP errors
583+
logger.warning("OpenFGA mcp_tool can_manage check failed: %s", exc)
584+
raise HTTPException(
585+
status_code=503,
586+
detail="Authorization service is temporarily unavailable",
587+
) from exc
588+
raise HTTPException(
589+
status_code=403,
590+
detail="You do not have permission to manage this MCP tool. Only the tool's owner, an owner-team admin, or an organization admin may modify it.",
591+
)
592+
593+
594+
async def authorize_mcp_tool_create(user_context: UserContext, owner_team_slug: Optional[str]) -> None:
595+
"""Authorize creation of a new custom MCP tool.
596+
597+
The tool does not exist yet, so there are no per-resource tuples to check.
598+
Authorization mirrors the BFF first-set rule (spec US6): the caller must be
599+
an organization admin OR a member of the team they are assigning as owner
600+
(``team:<owner_team_slug>#can_use``). Coarse-ADMIN service principals are
601+
allowed first to preserve prior automation behavior.
602+
603+
Fails CLOSED with 403 (not authorized) / 503 (PDP unavailable).
604+
"""
605+
if has_permission(user_context.role, Role.ADMIN):
606+
return
607+
if not _openfga_http_url() or not _openfga_user(user_context):
608+
raise HTTPException(
609+
status_code=503,
610+
detail="Authorization service is temporarily unavailable",
611+
)
612+
normalized_owner = owner_team_slug.strip() if isinstance(owner_team_slug, str) else None
613+
try:
614+
if await _openfga_check_org_admin(user_context):
615+
return
616+
if normalized_owner and await _openfga_check_object(
617+
user_context, "can_use", "team", normalized_owner
618+
):
619+
return
620+
except Exception as exc: # noqa: BLE001 — fail closed on PDP errors
621+
logger.warning("OpenFGA mcp_tool create authorization failed: %s", exc)
622+
raise HTTPException(
623+
status_code=503,
624+
detail="Authorization service is temporarily unavailable",
625+
) from exc
626+
raise HTTPException(
627+
status_code=403,
628+
detail="You must be an organization admin or a member of the owner team to create this MCP tool.",
629+
)
630+
631+
541632
async def _openfga_list_objects(
542633
user_context: UserContext,
543634
relation: str,

ai_platform_engineering/knowledge_bases/rag/server/src/server/restapi.py

Lines changed: 26 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -52,6 +52,8 @@
5252
get_permissions,
5353
get_auth_manager,
5454
_authenticate_from_token,
55+
authorize_mcp_tool_create,
56+
authorize_mcp_tool_manage,
5557
check_datasource_access,
5658
derive_team_for_request,
5759
get_accessible_datasource_ids,
@@ -1972,8 +1974,14 @@ async def list_mcp_tools(user: UserContext = Depends(require_role(Role.READONLY)
19721974

19731975

19741976
@app.post("/v1/mcp/custom-tools", tags=["MCP Tools"])
1975-
async def create_mcp_tool(config: MCPToolConfig, user: UserContext = Depends(require_role(Role.ADMIN))):
1976-
"""Create a new custom MCP search tool. The tool_id must be unique and not reserved."""
1977+
async def create_mcp_tool(config: MCPToolConfig, user: UserContext = Depends(require_authenticated_user)):
1978+
"""Create a new custom MCP search tool. The tool_id must be unique and not reserved.
1979+
1980+
Authorization is OpenFGA-based (spec 2026-06-03-unified-shareable-resource-rbac):
1981+
the caller must be an org admin or a member of the owner team. Coarse-ADMIN
1982+
service principals are still permitted for backward compatibility.
1983+
"""
1984+
await authorize_mcp_tool_create(user, getattr(config, "owner_team_slug", None))
19771985
if not metadata_storage:
19781986
raise HTTPException(status_code=500, detail="Server not initialized")
19791987
if config.tool_id in RESERVED_TOOL_IDS:
@@ -1991,8 +1999,14 @@ async def create_mcp_tool(config: MCPToolConfig, user: UserContext = Depends(req
19911999

19922000

19932001
@app.put("/v1/mcp/custom-tools/{tool_id}", tags=["MCP Tools"])
1994-
async def update_mcp_tool(tool_id: str, config: MCPToolConfig, user: UserContext = Depends(require_role(Role.ADMIN))):
1995-
"""Update an existing MCP search tool configuration (including the seeded 'search' tool)."""
2002+
async def update_mcp_tool(tool_id: str, config: MCPToolConfig, user: UserContext = Depends(require_authenticated_user)):
2003+
"""Update an existing MCP search tool configuration (including the seeded 'search' tool).
2004+
2005+
Authorization is OpenFGA-based: the caller must hold `mcp_tool#can_manage`
2006+
(owner, owner-team admin, or org admin). Coarse-ADMIN service principals are
2007+
still permitted for backward compatibility.
2008+
"""
2009+
await authorize_mcp_tool_manage(user, tool_id)
19962010
if not metadata_storage:
19972011
raise HTTPException(status_code=500, detail="Server not initialized")
19982012
if tool_id in RESERVED_TOOL_IDS:
@@ -2011,8 +2025,14 @@ async def update_mcp_tool(tool_id: str, config: MCPToolConfig, user: UserContext
20112025

20122026

20132027
@app.delete("/v1/mcp/custom-tools/{tool_id}", tags=["MCP Tools"])
2014-
async def delete_mcp_tool(tool_id: str, user: UserContext = Depends(require_role(Role.ADMIN))):
2015-
"""Delete a custom MCP search tool. Reserved tool IDs (e.g. 'search') cannot be deleted."""
2028+
async def delete_mcp_tool(tool_id: str, user: UserContext = Depends(require_authenticated_user)):
2029+
"""Delete a custom MCP search tool. Reserved tool IDs (e.g. 'search') cannot be deleted.
2030+
2031+
Authorization is OpenFGA-based: the caller must hold `mcp_tool#can_manage`
2032+
(owner, owner-team admin, or org admin). Coarse-ADMIN service principals are
2033+
still permitted for backward compatibility.
2034+
"""
2035+
await authorize_mcp_tool_manage(user, tool_id)
20162036
if not metadata_storage:
20172037
raise HTTPException(status_code=500, detail="Server not initialized")
20182038
if tool_id in RESERVED_TOOL_IDS:

ai_platform_engineering/knowledge_bases/rag/server/tests/test_e2e.py

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,19 @@
1515
import uuid
1616

1717

18+
# These tests run against a live RAG server started via docker-compose (see the
19+
# module docstring). They mutate real state and assert that the server's runtime
20+
# config matches ENABLE_GRAPH_RAG, so they cannot pass in a plain unit run. They
21+
# are opt-in: set RAG_E2E=1 (with a matching stack up) to enable them. By default
22+
# they are skipped so `pytest tests/` stays green without a live stack.
23+
# assisted-by Cursor claude-opus-4-7
24+
_RAG_E2E_ENABLED = os.getenv("RAG_E2E", "").strip().lower() in {"1", "true", "yes", "on"}
25+
pytestmark = pytest.mark.skipif(
26+
not _RAG_E2E_ENABLED,
27+
reason="Live-stack e2e tests are opt-in; set RAG_E2E=1 with a running docker-compose stack to enable",
28+
)
29+
30+
1831
class TestRAGEndToEnd:
1932
"""End-to-end tests for RAG server functionality."""
2033

Lines changed: 182 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,182 @@
1+
"""Tests for OpenFGA-based custom MCP tool authorization in the RAG server.
2+
3+
Covers `authorize_mcp_tool_create` / `authorize_mcp_tool_manage`, which replaced
4+
the legacy coarse `require_role(Role.ADMIN)` gate on the
5+
POST/PUT/DELETE /v1/mcp/custom-tools endpoints (spec
6+
2026-06-03-unified-shareable-resource-rbac). Human callers are READONLY at the
7+
coarse layer, so authorization is resolved through OpenFGA on the `mcp_tool`
8+
and `team` types.
9+
"""
10+
11+
from __future__ import annotations
12+
13+
import pytest
14+
from fastapi import HTTPException
15+
16+
from common.models.rbac import Role, UserContext
17+
from server import rbac
18+
19+
20+
def _user(subject: str | None = "alice-sub", role: str = Role.READONLY) -> UserContext:
21+
return UserContext(
22+
subject=subject,
23+
email="alice@example.com",
24+
role=role,
25+
is_authenticated=True,
26+
groups=[],
27+
)
28+
29+
30+
@pytest.fixture(autouse=True)
31+
def _openfga_configured(monkeypatch: pytest.MonkeyPatch) -> None:
32+
monkeypatch.setenv("OPENFGA_HTTP", "http://openfga")
33+
monkeypatch.setenv("CAIPE_ORG_KEY", "caipe")
34+
35+
async def _no_org_admin(user: UserContext) -> bool:
36+
return False
37+
38+
async def _deny_all(user: UserContext, relation: str, object_type: str, object_id: str) -> bool:
39+
return False
40+
41+
monkeypatch.setattr(rbac, "_openfga_check_org_admin", _no_org_admin, raising=False)
42+
monkeypatch.setattr(rbac, "_openfga_check_object", _deny_all, raising=False)
43+
44+
45+
# ---------------------------------------------------------------------------
46+
# authorize_mcp_tool_manage (update / delete)
47+
# ---------------------------------------------------------------------------
48+
49+
50+
@pytest.mark.asyncio
51+
async def test_manage_allows_coarse_admin_without_pdp(monkeypatch: pytest.MonkeyPatch) -> None:
52+
async def _explode(*_args, **_kwargs): # pragma: no cover - must not run
53+
raise AssertionError("coarse ADMIN must not hit the PDP")
54+
55+
monkeypatch.setattr(rbac, "_openfga_check_org_admin", _explode, raising=False)
56+
monkeypatch.setattr(rbac, "_openfga_check_object", _explode, raising=False)
57+
58+
await rbac.authorize_mcp_tool_manage(_user(role=Role.ADMIN), "tool-x")
59+
60+
61+
@pytest.mark.asyncio
62+
async def test_manage_allows_org_admin(monkeypatch: pytest.MonkeyPatch) -> None:
63+
async def _org_admin(user: UserContext) -> bool:
64+
return True
65+
66+
monkeypatch.setattr(rbac, "_openfga_check_org_admin", _org_admin, raising=False)
67+
68+
await rbac.authorize_mcp_tool_manage(_user(), "tool-x")
69+
70+
71+
@pytest.mark.asyncio
72+
async def test_manage_allows_can_manage(monkeypatch: pytest.MonkeyPatch) -> None:
73+
calls: list[tuple[str, str, str]] = []
74+
75+
async def _check(user: UserContext, relation: str, object_type: str, object_id: str) -> bool:
76+
calls.append((relation, object_type, object_id))
77+
return relation == "can_manage" and object_type == "mcp_tool" and object_id == "tool-x"
78+
79+
monkeypatch.setattr(rbac, "_openfga_check_object", _check, raising=False)
80+
81+
await rbac.authorize_mcp_tool_manage(_user(), "tool-x")
82+
assert ("can_manage", "mcp_tool", "tool-x") in calls
83+
84+
85+
@pytest.mark.asyncio
86+
async def test_manage_denies_when_not_authorized() -> None:
87+
with pytest.raises(HTTPException) as exc:
88+
await rbac.authorize_mcp_tool_manage(_user(), "tool-x")
89+
assert exc.value.status_code == 403
90+
91+
92+
@pytest.mark.asyncio
93+
async def test_manage_fails_closed_on_pdp_error(monkeypatch: pytest.MonkeyPatch) -> None:
94+
async def _boom(user: UserContext) -> bool:
95+
raise RuntimeError("openfga down")
96+
97+
monkeypatch.setattr(rbac, "_openfga_check_org_admin", _boom, raising=False)
98+
99+
with pytest.raises(HTTPException) as exc:
100+
await rbac.authorize_mcp_tool_manage(_user(), "tool-x")
101+
assert exc.value.status_code == 503
102+
103+
104+
@pytest.mark.asyncio
105+
async def test_manage_fails_closed_when_pdp_not_configured(monkeypatch: pytest.MonkeyPatch) -> None:
106+
monkeypatch.delenv("OPENFGA_HTTP", raising=False)
107+
with pytest.raises(HTTPException) as exc:
108+
await rbac.authorize_mcp_tool_manage(_user(), "tool-x")
109+
assert exc.value.status_code == 503
110+
111+
112+
@pytest.mark.asyncio
113+
async def test_manage_fails_closed_without_stable_subject() -> None:
114+
with pytest.raises(HTTPException) as exc:
115+
await rbac.authorize_mcp_tool_manage(_user(subject=None), "tool-x")
116+
assert exc.value.status_code == 503
117+
118+
119+
# ---------------------------------------------------------------------------
120+
# authorize_mcp_tool_create
121+
# ---------------------------------------------------------------------------
122+
123+
124+
@pytest.mark.asyncio
125+
async def test_create_allows_coarse_admin_without_pdp(monkeypatch: pytest.MonkeyPatch) -> None:
126+
async def _explode(*_args, **_kwargs): # pragma: no cover - must not run
127+
raise AssertionError("coarse ADMIN must not hit the PDP")
128+
129+
monkeypatch.setattr(rbac, "_openfga_check_org_admin", _explode, raising=False)
130+
monkeypatch.setattr(rbac, "_openfga_check_object", _explode, raising=False)
131+
132+
await rbac.authorize_mcp_tool_create(_user(role=Role.ADMIN), "eti-sre-admins")
133+
134+
135+
@pytest.mark.asyncio
136+
async def test_create_allows_org_admin(monkeypatch: pytest.MonkeyPatch) -> None:
137+
async def _org_admin(user: UserContext) -> bool:
138+
return True
139+
140+
monkeypatch.setattr(rbac, "_openfga_check_org_admin", _org_admin, raising=False)
141+
142+
await rbac.authorize_mcp_tool_create(_user(), None)
143+
144+
145+
@pytest.mark.asyncio
146+
async def test_create_allows_owner_team_member(monkeypatch: pytest.MonkeyPatch) -> None:
147+
calls: list[tuple[str, str, str]] = []
148+
149+
async def _check(user: UserContext, relation: str, object_type: str, object_id: str) -> bool:
150+
calls.append((relation, object_type, object_id))
151+
return relation == "can_use" and object_type == "team" and object_id == "eti-sre-admins"
152+
153+
monkeypatch.setattr(rbac, "_openfga_check_object", _check, raising=False)
154+
155+
await rbac.authorize_mcp_tool_create(_user(), " eti-sre-admins ")
156+
assert ("can_use", "team", "eti-sre-admins") in calls
157+
158+
159+
@pytest.mark.asyncio
160+
async def test_create_denies_non_member() -> None:
161+
with pytest.raises(HTTPException) as exc:
162+
await rbac.authorize_mcp_tool_create(_user(), "eti-sre-admins")
163+
assert exc.value.status_code == 403
164+
165+
166+
@pytest.mark.asyncio
167+
async def test_create_denies_when_no_owner_team_and_not_org_admin() -> None:
168+
with pytest.raises(HTTPException) as exc:
169+
await rbac.authorize_mcp_tool_create(_user(), None)
170+
assert exc.value.status_code == 403
171+
172+
173+
@pytest.mark.asyncio
174+
async def test_create_fails_closed_on_pdp_error(monkeypatch: pytest.MonkeyPatch) -> None:
175+
async def _boom(user: UserContext) -> bool:
176+
raise RuntimeError("openfga down")
177+
178+
monkeypatch.setattr(rbac, "_openfga_check_org_admin", _boom, raising=False)
179+
180+
with pytest.raises(HTTPException) as exc:
181+
await rbac.authorize_mcp_tool_create(_user(), "eti-sre-admins")
182+
assert exc.value.status_code == 503

0 commit comments

Comments
 (0)