Skip to content

[FIX][RBAC]: Session tokens denied tools.execute on /rpc and /mcp despite having team-scoped role#3516

Merged
crivetimihai merged 13 commits intomainfrom
bugfix/rpc-team-fallback
Mar 6, 2026
Merged

[FIX][RBAC]: Session tokens denied tools.execute on /rpc and /mcp despite having team-scoped role#3516
crivetimihai merged 13 commits intomainfrom
bugfix/rpc-team-fallback

Conversation

@madhav165
Copy link
Copy Markdown
Collaborator

@madhav165 madhav165 commented Mar 6, 2026

📌 Summary

Session tokens whose tools.execute permission exists only in a team-scoped RBAC role were incorrectly denied on /rpc (tools/call) and /mcp (Streamable HTTP). The same user succeeded on REST endpoints because those use the @require_permission decorator which already applies a check_any_team fallback for session tokens.

Closes #3515

🔁 Reproduction Steps

  1. Set DEFAULT_TEAM_MEMBER_ROLE=developer in your environment
  2. Create a new user and a new team via the admin UI
  3. Log in as that user
  4. Add an MCP server scoped to that team
  5. Go to the Tools screen and run a tool — it fails with -32003 Insufficient permissions. Required: tools.execute

🐞 Root Cause

Two permission-check helpers were missing the check_any_team fallback that the @require_permission decorator applies for session tokens (rbac.py:562–576):

  • _ensure_rpc_permission (main.py) — used by POST /rpc. Cannot use @require_permission at the endpoint level because /rpc is a multi-method dispatcher; permission is resolved after parsing the JSON-RPC body. Called PermissionChecker.has_permission without check_any_team, so _get_user_roles only returned global roles and team roles with scope_id=NULL, missing the user's actual team assignments.

  • _check_streamable_permission call site (streamablehttp_transport.py) — used by POST /mcp. The function already accepted check_any_team but it was always called with the default False. Additionally, token_use was not stored in auth_user_ctx, so the transport had no way to detect session tokens.

💡 Fix Description

Three targeted changes:

  1. PermissionChecker.has_permission (mcpgateway/middleware/rbac.py) — added check_any_team: bool = False parameter and threaded it through to both PermissionService.check_permission call sites. Default is False, preserving all existing callers.

  2. _ensure_rpc_permission (mcpgateway/main.py) — detects session tokens via user.get("token_use") == "session" and passes check_any_team=True to PermissionChecker.has_permission.

  3. Streamable HTTP transport (mcpgateway/transports/streamablehttp_transport.py) — stores token_use in auth_user_ctx so it propagates into the MCP handler context; call_tool now detects session tokens and passes check_any_team=True to _check_streamable_permission.

🔐 Security Rationale for check_any_team=True

A reviewer might ask: does check_any_team=True allow a user with tools.execute in Team A to execute a team-scoped tool owned by Team B?

The answer is no, and here is why the two paths differ:

REST path (@require_permission): Tier 1 team derivation (rbac.py:566) looks up the target resource's team_id from the DB before the RBAC check. For a Team B tool, team_id = Team B is derived, check_any_team stays False, and the check "does user have tools.execute in Team B?" fails → denied at RBAC (Layer 2).

RPC/MCP path (this fix): check_any_team=True means the RBAC check passes (user has tools.execute in Team A). However, tool_service.invoke_tool then calls _has_tool_access_by_visibility (tool_service.py:796–807), which explicitly evaluates tool.team_id in token_teams. For a session token user in Team A only, token_teams = ["team-a-id"] (derived from server-side membership), so "team-b-id" NOT IN ["team-a-id"]ToolNotFoundErrordenied at tool service (Layer 1).

Both paths deny cross-team execution. The REST path catches it at RBAC; the RPC/MCP path catches it at the tool service's visibility check. Public tools (visibility = "public") are correctly accessible to any authenticated user with the permission on both paths — that is the intended behaviour of public visibility.

API tokens carry explicit token_teams JWT claims and never use check_any_team (it stays False). Their scope is fully determined by the token claims, not server-side membership.

🧪 Verification

Check Command Status
Lint suite make lint
Unit tests uv run pytest tests/unit/
New regression tests uv run pytest tests/unit/mcpgateway/test_rpc_permission_team_fallback.py tests/unit/mcpgateway/transports/test_streamable_rpc_permission_fallback.py -v ✅ 11 tests
Manual regression Tools screen works for session-token user with team-scoped developer role

📐 MCP Compliance (if relevant)

  • Matches current MCP spec
  • No breaking change to MCP clients

✅ Checklist

  • Code formatted (make black isort pre-commit)
  • No secrets/credentials committed

@madhav165 madhav165 changed the title fix(rbac): session tokens with team-scoped roles denied on /rpc and /mcp transports [FIX][API]: session tokens with team-scoped roles denied on /rpc and /mcp transports Mar 6, 2026
@madhav165 madhav165 changed the title [FIX][API]: session tokens with team-scoped roles denied on /rpc and /mcp transports [FIX][RBAC]: Session tokens denied tools.execute on /rpc and /mcp despite having team-scoped role Mar 6, 2026
@madhav165 madhav165 added bug Something isn't working ica ICA related issues MUST P1: Non-negotiable, critical requirements without which the product is non-functional or unsafe api REST API Related item labels Mar 6, 2026
@madhav165 madhav165 force-pushed the bugfix/rpc-team-fallback branch from 6652846 to efea4c2 Compare March 6, 2026 14:18
@madhav165 madhav165 marked this pull request as draft March 6, 2026 14:40
@madhav165 madhav165 marked this pull request as ready for review March 6, 2026 15:00
@madhav165 madhav165 added the release-fix Critical bugfix required for the release label Mar 6, 2026
MohanLaksh
MohanLaksh previously approved these changes Mar 6, 2026
Copy link
Copy Markdown
Collaborator

@MohanLaksh MohanLaksh left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Followed the below steps to test the fix:

  • Set DEFAULT_TEAM_MEMBER_ROLE=developer in my .env file
  • Create a new user (user1@example.com) and a new team (Test Team) via the admin UI
  • Logged in as user1@example.com
  • Added Github MCP server scoped to Test team
  • Went to the Tools screen and run a tool (github-tools-get-me)

Successfully able to execute the tool

Works as intended.

APPROVED

madhav165 and others added 12 commits March 6, 2026 18:16
…sure_rpc_permission

Signed-off-by: Madhav Kandukuri <madhav165@gmail.com>
Signed-off-by: Madhav Kandukuri <madhav165@gmail.com>
Signed-off-by: Madhav Kandukuri <madhav165@gmail.com>
…sion tokens

Signed-off-by: Madhav Kandukuri <madhav165@gmail.com>
…_streamable_permission

Signed-off-by: Madhav Kandukuri <madhav165@gmail.com>
…ss check_any_team for session tokens

Signed-off-by: Madhav Kandukuri <madhav165@gmail.com>
…llback

Signed-off-by: Madhav Kandukuri <madhav165@gmail.com>
…_any_team

Signed-off-by: Madhav Kandukuri <madhav165@gmail.com>
…eny-path tests

- streamablehttp_transport.py: drop redundant ternary guard; user_context is
  guaranteed non-None at that point by _should_enforce_streamable_rbac check
- test_rpc_permission_team_fallback.py: replace vacuous admin-bypass smoke test with
  a meaningful assertion that has_permission is called with check_any_team=True for
  admin session tokens (bypass lives inside PermissionService, not _ensure_rpc_permission)
- test_streamable_rpc_permission_fallback.py: add deny-path integration test verifying
  call_tool raises PermissionError when _check_streamable_permission returns False

Signed-off-by: Madhav Kandukuri <madhav165@gmail.com>
…ool execution

Three tests in TestRPCToolExecutionRBAC:

- test_developer_rpc_tools_call_not_denied: developer with team-scoped role
  sends tools/call via /rpc as a session token and must NOT receive -32003
- test_viewer_rpc_tools_call_denied: viewer (no tools.execute) must still
  receive -32003 (deny-path regression guard)
- test_developer_can_list_team_tool: developer can see their team-scoped tool
  in GET /tools (Layer 1 visibility check)

Signed-off-by: Madhav Kandukuri <madhav165@gmail.com>
…_permission

Add two tests covering the db_session and fresh_db_session paths of
PermissionChecker.has_permission to assert check_any_team=True is
forwarded to PermissionService.check_permission.

Signed-off-by: Mihai Criveti <crivetimihai@gmail.com>
…e in _normalize_jwt_payload

Address Codex review findings:
- servers.use check in handle_streamable_http now detects session tokens
  and passes check_any_team=True, matching the call_tool fix.
- _normalize_jwt_payload includes token_use in the returned context dict
  so the re-auth fallback path preserves session-token detection.
- Update existing _normalize_jwt_payload test assertions for the new key.
- Add servers.use session-token regression test.

Signed-off-by: Mihai Criveti <crivetimihai@gmail.com>
Four new E2E tests in TestSessionTokenCookieRBAC:

- test_developer_cookie_rpc_tools_call: session cookie + page.evaluate
  fetch to /rpc — matches the actual Admin UI Tools screen flow
- test_viewer_cookie_rpc_tools_call_denied: viewer deny-path via cookie
- test_developer_cookie_rpc_tools_list: tools.read permission via /rpc
  tools/list (verifies check_any_team applies to all RPC permissions)
- test_cross_team_tool_not_visible: Layer 1 isolation — developer in
  Team A cannot see Team B's tool even with check_any_team=True

Signed-off-by: Mihai Criveti <crivetimihai@gmail.com>
Copy link
Copy Markdown
Member

@crivetimihai crivetimihai left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Review: Approved

Rebased onto main, reviewed for correctness/security/consistency, addressed findings from Codex cross-review, and added additional test coverage. All unit and Playwright E2E tests pass.

Changes made during review

Codex-identified fixes (commit 9eb09ed):

  1. servers.use missing check_any_team (High) — The servers.use check in handle_streamable_http at line 2353 gates access to /servers/{id}/mcp (the primary MCP client URL). Both developer and team_admin roles grant servers.use as a team-scoped permission, so session tokens would be denied at the transport level before even reaching call_tool. Fixed by adding the same session-token detection pattern.

  2. _normalize_jwt_payload drops token_use (Low) — The re-auth fallback path in _get_request_context_or_default uses _normalize_jwt_payload, which didn't include token_use in its output. If that fallback were triggered, check_any_team would silently default to False. Fixed by adding "token_use": token_use to the returned dict. Updated 4 existing test assertions accordingly.

Test coverage additions:

  • test_permission_checker_has_permission_forwards_check_any_team — verifies check_any_team=True forwarding through PermissionChecker.has_permission (db_session path) (commit 9267f28)
  • test_permission_checker_has_permission_forwards_check_any_team_fresh_db — same for the fresh_db_session path (commit 9267f28)
  • test_servers_use_check_passes_check_any_team_for_session_token — unit test for the servers.use fix (commit 9eb09ed)
  • 4 new Playwright E2E cookie-based browser tests in TestSessionTokenCookieRBAC (commit a7331f3):
    • test_developer_cookie_rpc_tools_call — session cookie + page.evaluate(fetch('/rpc')), matching actual Admin UI flow
    • test_viewer_cookie_rpc_tools_call_denied — viewer deny-path via cookie auth
    • test_developer_cookie_rpc_tools_listtools/list RPC verifying fix covers all RPC permissions
    • test_cross_team_tool_not_visible — Layer 1 cross-team isolation: developer in Team A cannot see Team B's tool

Security verification

  • check_any_team=True does NOT enable cross-team access — Layer 1 (_has_tool_access_by_visibility) checks tool.team_id in token_teams
  • API tokens unaffected (scoped to token_use == "session" only)
  • Personal team exclusion intact (_get_user_roles excludes personal teams when include_all_teams=True)
  • Fail-closed: missing token_use defaults to check_any_team=False

Test results

  • 724 unit tests passed (0 failed)
  • 7 Playwright E2E tests passed against live server (3 original + 4 new)
  • rbac.py at 100% coverage

@crivetimihai crivetimihai added this to the Release 1.0.0-RC2 milestone Mar 6, 2026
@crivetimihai crivetimihai merged commit 065d961 into main Mar 6, 2026
39 checks passed
@crivetimihai crivetimihai deleted the bugfix/rpc-team-fallback branch March 6, 2026 22:04
@crivetimihai crivetimihai self-assigned this Mar 9, 2026
MohanLaksh pushed a commit that referenced this pull request Mar 12, 2026
…pite having team-scoped role (#3516)

* test(rbac): add failing tests for session-token check_any_team in _ensure_rpc_permission

Signed-off-by: Madhav Kandukuri <madhav165@gmail.com>

* test(rbac): harden assertions in rpc permission team fallback tests

Signed-off-by: Madhav Kandukuri <madhav165@gmail.com>

* feat(rbac): add check_any_team param to PermissionChecker.has_permission

Signed-off-by: Madhav Kandukuri <madhav165@gmail.com>

* fix(rbac): pass check_any_team=True in _ensure_rpc_permission for session tokens

Signed-off-by: Madhav Kandukuri <madhav165@gmail.com>

* test(rbac): add contract tests for check_any_team threading in _check_streamable_permission

Signed-off-by: Madhav Kandukuri <madhav165@gmail.com>

* fix(rbac): propagate token_use in streamablehttp auth_user_ctx and pass check_any_team for session tokens

Signed-off-by: Madhav Kandukuri <madhav165@gmail.com>

* test(rbac): add deny-path regression tests for rpc team-permission fallback

Signed-off-by: Madhav Kandukuri <madhav165@gmail.com>

* test(rbac): accept **kwargs in _has_permission mocks to forward check_any_team

Signed-off-by: Madhav Kandukuri <madhav165@gmail.com>

* fix(rbac): remove dead null-guard in call_tool and strengthen admin+deny-path tests

- streamablehttp_transport.py: drop redundant ternary guard; user_context is
  guaranteed non-None at that point by _should_enforce_streamable_rbac check
- test_rpc_permission_team_fallback.py: replace vacuous admin-bypass smoke test with
  a meaningful assertion that has_permission is called with check_any_team=True for
  admin session tokens (bypass lives inside PermissionService, not _ensure_rpc_permission)
- test_streamable_rpc_permission_fallback.py: add deny-path integration test verifying
  call_tool raises PermissionError when _check_streamable_permission returns False

Signed-off-by: Madhav Kandukuri <madhav165@gmail.com>

* test(rbac): add #3515 regression Playwright tests for session-token tool execution

Three tests in TestRPCToolExecutionRBAC:

- test_developer_rpc_tools_call_not_denied: developer with team-scoped role
  sends tools/call via /rpc as a session token and must NOT receive -32003
- test_viewer_rpc_tools_call_denied: viewer (no tools.execute) must still
  receive -32003 (deny-path regression guard)
- test_developer_can_list_team_tool: developer can see their team-scoped tool
  in GET /tools (Layer 1 visibility check)

Signed-off-by: Madhav Kandukuri <madhav165@gmail.com>

* test(rbac): verify check_any_team forwarding in PermissionChecker.has_permission

Add two tests covering the db_session and fresh_db_session paths of
PermissionChecker.has_permission to assert check_any_team=True is
forwarded to PermissionService.check_permission.

Signed-off-by: Mihai Criveti <crivetimihai@gmail.com>

* fix(rbac): pass check_any_team for servers.use and propagate token_use in _normalize_jwt_payload

Address Codex review findings:
- servers.use check in handle_streamable_http now detects session tokens
  and passes check_any_team=True, matching the call_tool fix.
- _normalize_jwt_payload includes token_use in the returned context dict
  so the re-auth fallback path preserves session-token detection.
- Update existing _normalize_jwt_payload test assertions for the new key.
- Add servers.use session-token regression test.

Signed-off-by: Mihai Criveti <crivetimihai@gmail.com>

* test(rbac): add browser cookie session-token Playwright tests for #3515

Four new E2E tests in TestSessionTokenCookieRBAC:

- test_developer_cookie_rpc_tools_call: session cookie + page.evaluate
  fetch to /rpc — matches the actual Admin UI Tools screen flow
- test_viewer_cookie_rpc_tools_call_denied: viewer deny-path via cookie
- test_developer_cookie_rpc_tools_list: tools.read permission via /rpc
  tools/list (verifies check_any_team applies to all RPC permissions)
- test_cross_team_tool_not_visible: Layer 1 isolation — developer in
  Team A cannot see Team B's tool even with check_any_team=True

Signed-off-by: Mihai Criveti <crivetimihai@gmail.com>

---------

Signed-off-by: Madhav Kandukuri <madhav165@gmail.com>
Signed-off-by: Mihai Criveti <crivetimihai@gmail.com>
Co-authored-by: Mihai Criveti <crivetimihai@gmail.com>
Yosiefeyob pushed a commit that referenced this pull request Mar 13, 2026
…pite having team-scoped role (#3516)

* test(rbac): add failing tests for session-token check_any_team in _ensure_rpc_permission

Signed-off-by: Madhav Kandukuri <madhav165@gmail.com>

* test(rbac): harden assertions in rpc permission team fallback tests

Signed-off-by: Madhav Kandukuri <madhav165@gmail.com>

* feat(rbac): add check_any_team param to PermissionChecker.has_permission

Signed-off-by: Madhav Kandukuri <madhav165@gmail.com>

* fix(rbac): pass check_any_team=True in _ensure_rpc_permission for session tokens

Signed-off-by: Madhav Kandukuri <madhav165@gmail.com>

* test(rbac): add contract tests for check_any_team threading in _check_streamable_permission

Signed-off-by: Madhav Kandukuri <madhav165@gmail.com>

* fix(rbac): propagate token_use in streamablehttp auth_user_ctx and pass check_any_team for session tokens

Signed-off-by: Madhav Kandukuri <madhav165@gmail.com>

* test(rbac): add deny-path regression tests for rpc team-permission fallback

Signed-off-by: Madhav Kandukuri <madhav165@gmail.com>

* test(rbac): accept **kwargs in _has_permission mocks to forward check_any_team

Signed-off-by: Madhav Kandukuri <madhav165@gmail.com>

* fix(rbac): remove dead null-guard in call_tool and strengthen admin+deny-path tests

- streamablehttp_transport.py: drop redundant ternary guard; user_context is
  guaranteed non-None at that point by _should_enforce_streamable_rbac check
- test_rpc_permission_team_fallback.py: replace vacuous admin-bypass smoke test with
  a meaningful assertion that has_permission is called with check_any_team=True for
  admin session tokens (bypass lives inside PermissionService, not _ensure_rpc_permission)
- test_streamable_rpc_permission_fallback.py: add deny-path integration test verifying
  call_tool raises PermissionError when _check_streamable_permission returns False

Signed-off-by: Madhav Kandukuri <madhav165@gmail.com>

* test(rbac): add #3515 regression Playwright tests for session-token tool execution

Three tests in TestRPCToolExecutionRBAC:

- test_developer_rpc_tools_call_not_denied: developer with team-scoped role
  sends tools/call via /rpc as a session token and must NOT receive -32003
- test_viewer_rpc_tools_call_denied: viewer (no tools.execute) must still
  receive -32003 (deny-path regression guard)
- test_developer_can_list_team_tool: developer can see their team-scoped tool
  in GET /tools (Layer 1 visibility check)

Signed-off-by: Madhav Kandukuri <madhav165@gmail.com>

* test(rbac): verify check_any_team forwarding in PermissionChecker.has_permission

Add two tests covering the db_session and fresh_db_session paths of
PermissionChecker.has_permission to assert check_any_team=True is
forwarded to PermissionService.check_permission.

Signed-off-by: Mihai Criveti <crivetimihai@gmail.com>

* fix(rbac): pass check_any_team for servers.use and propagate token_use in _normalize_jwt_payload

Address Codex review findings:
- servers.use check in handle_streamable_http now detects session tokens
  and passes check_any_team=True, matching the call_tool fix.
- _normalize_jwt_payload includes token_use in the returned context dict
  so the re-auth fallback path preserves session-token detection.
- Update existing _normalize_jwt_payload test assertions for the new key.
- Add servers.use session-token regression test.

Signed-off-by: Mihai Criveti <crivetimihai@gmail.com>

* test(rbac): add browser cookie session-token Playwright tests for #3515

Four new E2E tests in TestSessionTokenCookieRBAC:

- test_developer_cookie_rpc_tools_call: session cookie + page.evaluate
  fetch to /rpc — matches the actual Admin UI Tools screen flow
- test_viewer_cookie_rpc_tools_call_denied: viewer deny-path via cookie
- test_developer_cookie_rpc_tools_list: tools.read permission via /rpc
  tools/list (verifies check_any_team applies to all RPC permissions)
- test_cross_team_tool_not_visible: Layer 1 isolation — developer in
  Team A cannot see Team B's tool even with check_any_team=True

Signed-off-by: Mihai Criveti <crivetimihai@gmail.com>

---------

Signed-off-by: Madhav Kandukuri <madhav165@gmail.com>
Signed-off-by: Mihai Criveti <crivetimihai@gmail.com>
Co-authored-by: Mihai Criveti <crivetimihai@gmail.com>
Signed-off-by: Yosief Eyob <yosiefogbazion@gmail.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

api REST API Related item bug Something isn't working ica ICA related issues MUST P1: Non-negotiable, critical requirements without which the product is non-functional or unsafe release-fix Critical bugfix required for the release

Projects

None yet

Development

Successfully merging this pull request may close these issues.

[BUG]: session tokens with team-scoped roles denied tools.execute on /rpc and /mcp transports

3 participants