feat(rbac): add tools.execute permission to team-scoped viewer role#3882
feat(rbac): add tools.execute permission to team-scoped viewer role#3882crivetimihai merged 8 commits intoIBM:mainfrom
Conversation
|
Thanks @kimsehwan96 — this addresses #3878 cleanly. One concern: the
What are your thoughts? |
|
@crivetimihai Thanks, good point. Docs update: Both I still think there's a reasonable argument for viewer having tools.execute — most MCP tools carry their own auth (OAuth, etc.), so the gateway-level restriction can feel like double-gating. But I understand the concern about breaking the read-only contract across all deployments. What's your take — would you prefer keeping viewer strictly read-only, or is there room to reconsider? If you'd rather keep it as-is, I can close this PR. |
marekdano
left a comment
There was a problem hiding this comment.
Summary
This PR addresses issue #3881 by adding tools.execute permission to the team-scoped viewer role. This enables team members to execute MCP tools without requiring the developer role, which grants full CRUD permissions.
✅ Technical Implementation: Excellent
✅ Security Model: Correct Design
This is a least-privilege security enhancement, not a breaking change. Here's why:
Context from Issue #3881
PR #3390 added servers.use to viewer roles, allowing them to connect to Virtual Servers. However, without tools.execute, this permission was effectively useless for MCP workflows. The only alternative was granting the developer role, which includes full CRUD permissions—creating unnecessary operational risk.
This PR's Approach
- Team-scoped only:
viewer(team members) gainstools.execute - Global unchanged:
platform_viewer(auto-assigned, no team membership) remains read-only - Visibility filtering enforced: Team viewers can only execute:
- Own team's tools ✅
- Public tools ✅
- NOT other teams' private tools (blocked by Layer 1 scoping) ❌
Operational Benefits
- Prevents forcing admins to grant
developerrole just for tool execution - Reduces risk of accidental configuration changes
- Enables safe MCP tool usage for team members
- Addresses real multi-team deployment needs
✅ Test Coverage: Comprehensive
Updated tests verify:
- Team-scoped viewer can execute tools via
/rpc tools/call✅ - Cookie-based session tokens work correctly ✅
- Cross-team isolation maintained (Layer 1 scoping) ✅
- Permission matrix tests updated ✅
- E2E MCP RBAC transport tests updated ✅
📝 Minor Suggestion
Consider adding a note in CHANGELOG.md for release notes:
### Enhanced
- **RBAC:** Added `tools.execute` permission to team-scoped `viewer` role, enabling team members to execute MCP tools without requiring `developer` role (which grants full CRUD permissions). Addresses #3881. `platform_viewer` (global scope) remains read-only.Otherwise LGTM 🚀
Signed-off-by: kimsehwan96 <sktpghks138@gmail.com>
|
@marekdano Thanks for the review! |
|
@marekdano Thanks! I think CI fails with two issues
Would you like me to rebase and push the fixes, or will you handle it on your end? |
|
@kimsehwan96 - yes, I can confirm
"""merge grant_source and viewer_tools_execute heads
Revision ID: 6e09506295a9
Revises: 225bde88217e, cbedf4e580e0
Create Date: 2026-04-02 12:09:28.269751
"""
from typing import Sequence, Union
from alembic import op
import sqlalchemy as sa
# revision identifiers, used by Alembic.
revision: str = '6e09506295a9'
down_revision: Union[str, Sequence[str], None] = ('225bde88217e', 'cbedf4e580e0')
branch_labels: Union[str, Sequence[str], None] = None
depends_on: Union[str, Sequence[str], None] = None
def upgrade() -> None:
"""Upgrade schema."""
pass
def downgrade() -> None:
"""Downgrade schema."""
pass
|
cc0ab26 to
43352fd
Compare
Signed-off-by: kimsehwan96 <sktpghks138@gmail.com>
|
@marekdano I rebased it from main branch and re-run Now on there is no conflict. Thanks! |
Signed-off-by: kimsehwan96 <sktpghks138@gmail.com>
43352fd to
f5f50ef
Compare
|
I've done rebase again because of conflicts in |
marekdano
left a comment
There was a problem hiding this comment.
Summary
This PR adds tools.execute permission to the team-scoped viewer role, allowing team members to invoke MCP tools without requiring the developer role (which also grants mutation permissions). The platform_viewer role is intentionally NOT modified.
Architecture Alignment
The change correctly maintains the two-layer security model:
- Layer 1 (Token Scoping): Controls visibility - unchanged
- Layer 2 (RBAC): Controls actions - viewer now has
tools.executein team scope
✅ Appropriate Scope Limitation
- ✅ Team-scoped
viewergetstools.execute - ✅ Global
platform_viewerdoes NOT gettools.execute - ✅ Maintains principle of least privilege for unauthenticated/public access
✅ Test Coverage
- Positive path: Developer and viewer can execute team tools
- Deny path: Cross-team isolation maintained
- Cookie session auth: Tested for both developer and viewer
- RPC endpoint:
/rpc tools/calltested with proper error codes
Migration File
# mcpgateway/alembic/versions/cbedf4e580e0_add_tools_execute_to_viewer_role.py
revision: str = "cbedf4e580e0"
down_revision: Union[str, Sequence[str], None] = "a7f3c9e1b2d4" # ✅ Correct head
ROLE_PERMISSION_ADDITIONS = {
"viewer": ["tools.execute"], # Only viewer, not platform_viewer
}Bootstrap Changes
# mcpgateway/bootstrap_db.py
{
"name": "viewer",
"description": "Read access and tool execution within team scope", # Updated
"scope": "team",
"permissions": [
# ... existing permissions ...
"tools.execute", # ✅ Added
# ...
],
}Conclusion
This is a well-implemented feature that appropriately extends the viewer role capabilities while maintaining security boundaries. The change is:
- ✅ Backward-compatible
- ✅ Properly tested with comprehensive coverage
- ✅ Follows all project conventions
- ✅ Maintains security invariants
- ✅ Includes proper documentation updates
- ✅ Uses idempotent migration pattern
LGTM 🚀
f5f50ef to
cdd52aa
Compare
|
@marekdano The CI fails with I added new line in the Another conflict was made.. I will rebase it. |
Signed-off-by: kimsehwan96 <sktpghks138@gmail.com>
cdd52aa to
25c9166
Compare
|
Rebased onto latest main. Resolved conflicts in:
|
Signed-off-by: kimsehwan96 <sktpghks138@gmail.com>
25c9166 to
91864b4
Compare
|
Starting review, please don't push anymore. |
Allow team members with the viewer role to execute MCP tools within their team scope without requiring the developer role, which also grants mutation permissions (create/update/delete). platform_viewer (global, auto-assigned) is intentionally not modified. Signed-off-by: kimsehwan96 <sktpghks138@gmail.com>
Signed-off-by: kimsehwan96 <sktpghks138@gmail.com>
…crets baseline Update the viewer tools.execute migration (cbedf4e580e0) down_revision from 615af4ab94b4 to a7f3c9e1b2d4 to resolve the multiple-heads issue after upstream main advanced. Refresh .secrets.baseline line numbers. Signed-off-by: kimsehwan96 <kimsehwan96@gmail.com> Signed-off-by: kimsehwan96 <sktpghks138@gmail.com>
The MUTATION_PERMISSIONS list excludes permissions in VIEWER_PERMISSIONS, so tools.execute (now granted to team-scoped viewer) was no longer covered by the parametrized platform_viewer deny test. Add an explicit enforcement-level test to verify platform_viewer is denied tools.execute. Signed-off-by: Mihai Criveti <crivetimihai@gmail.com>
The POST /tools JSON API expects visibility as a field on the nested ToolCreate schema (tool.visibility), not at the request body top level. When placed at the top level, FastAPI ignores it and register_tool falls back to visibility="public", so the tests were inadvertently creating public tools instead of team-scoped ones. Move visibility inside the tool object for all four affected test payloads (developer and viewer, both Bearer and cookie variants). Also apply Black formatting to the migration text() calls. Signed-off-by: Mihai Criveti <crivetimihai@gmail.com>
Several docs still described the team-scoped viewer role as read-only or unable to execute tools, which is no longer accurate after adding tools.execute. Update multitenancy architecture doc, SSO Entra role mapping, SSO Entra ID tutorial, and stale test section comments. Signed-off-by: Mihai Criveti <crivetimihai@gmail.com>
Two more pre-existing tests (developer list-visibility and cross-team isolation) also had visibility at the request body top level instead of inside the tool object. Apply the same fix so all six tool-creation payloads in the file now place visibility inside the ToolCreate schema. Signed-off-by: Mihai Criveti <crivetimihai@gmail.com>
91864b4 to
8b12fd4
Compare
…assertions Migration: add AND scope = :scope to the role lookup query so the migration cannot accidentally modify a custom role named "viewer" at a different scope. Change ROLE_PERMISSION_ADDITIONS from a dict to a list of (name, scope, permissions) tuples to carry the scope through. Playwright viewer allow-path tests: assert the created tool has visibility="team" (guards against the top-level-visibility regression), assert HTTP 200, and assert a JSON-RPC "result" key is present instead of merely checking error_code != -32003. Signed-off-by: Mihai Criveti <crivetimihai@gmail.com>
crivetimihai
left a comment
There was a problem hiding this comment.
Nice work, @kimsehwan96 — clean implementation overall. The design makes sense: letting team-scoped viewers execute tools without promoting them to developer (which also grants mutation permissions) is the right least-privilege move, and keeping platform_viewer read-only is the correct call.
I rebased onto main (one commit behind, trivial .secrets.baseline conflict) and added a few commits on top during review:
What I changed
Critical fix — Playwright test payloads (visibility placement)
All six Playwright tests that create team-scoped tools via POST /tools (JSON API) were placing visibility: "team" at the request body top level instead of inside the nested tool object. The create_tool endpoint parses tool: ToolCreate as a nested body param, so top-level visibility is silently ignored and register_tool falls back to visibility="public". I verified this against the live deployment — top-level produces a public tool, inside-tool produces a team-scoped one. Moved visibility into the tool object for all six payloads (4 from this PR, 2 pre-existing).
Migration scope safety
The role lookup query used WHERE name = :name LIMIT 1 without filtering by scope. If an operator had created a custom role also named "viewer" at global scope, the migration could modify the wrong one. Added AND scope = :scope and changed the config from a dict to (name, scope, permissions) tuples.
Stronger viewer allow-path assertions
The viewer tests asserted error_code != -32003, which passes on any non-RBAC error (including tool-not-found or internal errors). Strengthened to assert visibility == "team" on the created tool, HTTP 200 on the RPC response, and "result" in body to prove successful execution.
Deny-path coverage for platform_viewer
Since tools.execute is now in VIEWER_PERMISSIONS, it drops out of MUTATION_PERMISSIONS (which excludes viewer perms). The parametrized test_platform_viewer_denies_mutation no longer covers it. Added an explicit test_platform_viewer_denied_tools_execute enforcement-level test.
Doc drift
Updated three docs that still described viewer as read-only / unable to execute tools: multitenancy.md, sso-entra-role-mapping.md, sso-microsoft-entra-id-tutorial.md. Also fixed stale "Read-Only" section headers in the permission matrix test.
What looks good as-is
- Migration is idempotent, uses parameterized queries, handles both SQLite and PostgreSQL, and downgrade correctly reverses the upgrade.
bootstrap_db.pyand migration agree on the change. All permission sources of truth (bootstrap, migration, AGENTS.md, rbac.md, test constants) are consistent.- The e2e test
test_viewer_denied_tools_execute_on_default_endpointcorrectly preserves the deny-path on the global/mcpendpoint. - The meta-test
test_bootstrap_roles_match_test_constantsnow explicitly verifiesviewer_set - platform_viewer_set == {"tools.execute"}. - No privilege escalation path —
tools.executegates only tool invocation and does not chain into write access or admin capabilities.
282 unit tests pass. LGTM.
…3882) * feat(rbac): add tools.execute permission to team-scoped viewer role Allow team members with the viewer role to execute MCP tools within their team scope without requiring the developer role, which also grants mutation permissions (create/update/delete). platform_viewer (global, auto-assigned) is intentionally not modified. Signed-off-by: kimsehwan96 <sktpghks138@gmail.com> * docs: update changelog (#3882) Signed-off-by: kimsehwan96 <sktpghks138@gmail.com> * fix: sync alembic down_revision to latest upstream head and update secrets baseline Update the viewer tools.execute migration (cbedf4e580e0) down_revision from 615af4ab94b4 to a7f3c9e1b2d4 to resolve the multiple-heads issue after upstream main advanced. Refresh .secrets.baseline line numbers. Signed-off-by: kimsehwan96 <kimsehwan96@gmail.com> Signed-off-by: kimsehwan96 <sktpghks138@gmail.com> * test(rbac): add deny-path test for platform_viewer tools.execute The MUTATION_PERMISSIONS list excludes permissions in VIEWER_PERMISSIONS, so tools.execute (now granted to team-scoped viewer) was no longer covered by the parametrized platform_viewer deny test. Add an explicit enforcement-level test to verify platform_viewer is denied tools.execute. Signed-off-by: Mihai Criveti <crivetimihai@gmail.com> * fix(test): move visibility inside tool object in Playwright RBAC tests The POST /tools JSON API expects visibility as a field on the nested ToolCreate schema (tool.visibility), not at the request body top level. When placed at the top level, FastAPI ignores it and register_tool falls back to visibility="public", so the tests were inadvertently creating public tools instead of team-scoped ones. Move visibility inside the tool object for all four affected test payloads (developer and viewer, both Bearer and cookie variants). Also apply Black formatting to the migration text() calls. Signed-off-by: Mihai Criveti <crivetimihai@gmail.com> * docs: update viewer role description across docs and test comments Several docs still described the team-scoped viewer role as read-only or unable to execute tools, which is no longer accurate after adding tools.execute. Update multitenancy architecture doc, SSO Entra role mapping, SSO Entra ID tutorial, and stale test section comments. Signed-off-by: Mihai Criveti <crivetimihai@gmail.com> * fix(test): fix remaining visibility placement in Playwright RBAC tests Two more pre-existing tests (developer list-visibility and cross-team isolation) also had visibility at the request body top level instead of inside the tool object. Apply the same fix so all six tool-creation payloads in the file now place visibility inside the ToolCreate schema. Signed-off-by: Mihai Criveti <crivetimihai@gmail.com> * fix(rbac): scope-safe migration query and stronger viewer allow-path assertions Migration: add AND scope = :scope to the role lookup query so the migration cannot accidentally modify a custom role named "viewer" at a different scope. Change ROLE_PERMISSION_ADDITIONS from a dict to a list of (name, scope, permissions) tuples to carry the scope through. Playwright viewer allow-path tests: assert the created tool has visibility="team" (guards against the top-level-visibility regression), assert HTTP 200, and assert a JSON-RPC "result" key is present instead of merely checking error_code != -32003. Signed-off-by: Mihai Criveti <crivetimihai@gmail.com> --------- Signed-off-by: kimsehwan96 <sktpghks138@gmail.com> Signed-off-by: kimsehwan96 <kimsehwan96@gmail.com> Signed-off-by: Mihai Criveti <crivetimihai@gmail.com> Co-authored-by: Mihai Criveti <crivetimihai@gmail.com>
…3882) * feat(rbac): add tools.execute permission to team-scoped viewer role Allow team members with the viewer role to execute MCP tools within their team scope without requiring the developer role, which also grants mutation permissions (create/update/delete). platform_viewer (global, auto-assigned) is intentionally not modified. Signed-off-by: kimsehwan96 <sktpghks138@gmail.com> * docs: update changelog (#3882) Signed-off-by: kimsehwan96 <sktpghks138@gmail.com> * fix: sync alembic down_revision to latest upstream head and update secrets baseline Update the viewer tools.execute migration (cbedf4e580e0) down_revision from 615af4ab94b4 to a7f3c9e1b2d4 to resolve the multiple-heads issue after upstream main advanced. Refresh .secrets.baseline line numbers. Signed-off-by: kimsehwan96 <kimsehwan96@gmail.com> Signed-off-by: kimsehwan96 <sktpghks138@gmail.com> * test(rbac): add deny-path test for platform_viewer tools.execute The MUTATION_PERMISSIONS list excludes permissions in VIEWER_PERMISSIONS, so tools.execute (now granted to team-scoped viewer) was no longer covered by the parametrized platform_viewer deny test. Add an explicit enforcement-level test to verify platform_viewer is denied tools.execute. Signed-off-by: Mihai Criveti <crivetimihai@gmail.com> * fix(test): move visibility inside tool object in Playwright RBAC tests The POST /tools JSON API expects visibility as a field on the nested ToolCreate schema (tool.visibility), not at the request body top level. When placed at the top level, FastAPI ignores it and register_tool falls back to visibility="public", so the tests were inadvertently creating public tools instead of team-scoped ones. Move visibility inside the tool object for all four affected test payloads (developer and viewer, both Bearer and cookie variants). Also apply Black formatting to the migration text() calls. Signed-off-by: Mihai Criveti <crivetimihai@gmail.com> * docs: update viewer role description across docs and test comments Several docs still described the team-scoped viewer role as read-only or unable to execute tools, which is no longer accurate after adding tools.execute. Update multitenancy architecture doc, SSO Entra role mapping, SSO Entra ID tutorial, and stale test section comments. Signed-off-by: Mihai Criveti <crivetimihai@gmail.com> * fix(test): fix remaining visibility placement in Playwright RBAC tests Two more pre-existing tests (developer list-visibility and cross-team isolation) also had visibility at the request body top level instead of inside the tool object. Apply the same fix so all six tool-creation payloads in the file now place visibility inside the ToolCreate schema. Signed-off-by: Mihai Criveti <crivetimihai@gmail.com> * fix(rbac): scope-safe migration query and stronger viewer allow-path assertions Migration: add AND scope = :scope to the role lookup query so the migration cannot accidentally modify a custom role named "viewer" at a different scope. Change ROLE_PERMISSION_ADDITIONS from a dict to a list of (name, scope, permissions) tuples to carry the scope through. Playwright viewer allow-path tests: assert the created tool has visibility="team" (guards against the top-level-visibility regression), assert HTTP 200, and assert a JSON-RPC "result" key is present instead of merely checking error_code != -32003. Signed-off-by: Mihai Criveti <crivetimihai@gmail.com> --------- Signed-off-by: kimsehwan96 <sktpghks138@gmail.com> Signed-off-by: kimsehwan96 <kimsehwan96@gmail.com> Signed-off-by: Mihai Criveti <crivetimihai@gmail.com> Co-authored-by: Mihai Criveti <crivetimihai@gmail.com>
🔗 Related Issue
Closes #3881
📝 Summary
Add tools.execute permission to the team-scoped viewer role so team members can invoke MCP tools without requiring the developer role (which also grants create/update/delete permissions). platform_viewer (global) is not modified.
🏷️ Type of Change
🧪 Verification
make lintmake testmake coverageI also checked e2e / playwright test in local environment.
✅ Checklist
make black isort pre-commit)📓 Notes (optional)