fix(rbac) admin user visibility issue for personal teams#3413
fix(rbac) admin user visibility issue for personal teams#3413crivetimihai merged 19 commits intomainfrom
Conversation
7ddb828 to
a051971
Compare
madhav165
left a comment
There was a problem hiding this comment.
PR #3413 Review: Admin User Visibility Issue for Personal Teams
Overview
This PR fixes a security vulnerability where platform admins could see, edit, and delete personal teams belonging to
other users. The fix changes admin endpoints to call list_teams(include_personal=False) and then separately fetches
only the admin's own personal team via a new get_user_personal_team() service method. It also adds a UI banner when
admins view non-member teams.
Correctness
The core fix is sound - switching from include_personal=True to include_personal=False at the admin layer is the right
approach, and the decision to keep the service layer unchanged preserves flexibility.
Issues found:
- Duplicated filter logic across 4 endpoints (admin_get_all_team_ids, admin_search_teams, admin_teams_partial_html,
admin_list_teams). Each has a near-identical block checking is_active, visibility, and search query match for the
personal team. This is a maintenance risk - if the filtering logic changes, all 4 places need updating. Consider
extracting a helper like _should_include_personal_team(team, include_inactive, visibility, search_query) -> bool. - Inconsistent search matching logic between endpoints:
- admin_get_all_team_ids (line ~65): checks name and slug only
- admin_search_teams (line ~93): checks name, slug, and description
- admin_teams_partial_html (line ~124): checks name, slug, and description
- admin_list_teams (line ~148): checks name, slug, and description
The admin_get_all_team_ids endpoint is missing the description check, which is inconsistent.
- is_admin_user computed twice in admin_ui: once at line 15 of the diff, and again inline at lines 40-41. The second
occurrence should use the is_admin_user variable already computed.
Security
- The fix correctly blocks access to other users' personal teams across all 4 admin team endpoints.
- The get_user_personal_team service method correctly filters by created_by, is_personal, and is_active.
- Minor concern: The PR description mentions returning 404 for attempts to access other users' personal teams via
view/edit/delete endpoints, but I don't see those checks in the diff. The diff only covers listing endpoints. Are the
view/edit/delete guards in a separate commit or missing?
Code Quality
- f-string in logger: team_management_service.py line 183 uses f"Failed to get personal team for user {user_email}:
{e}" - should use lazy logging: logger.error("Failed to get personal team for user %s: %s", user_email, e) per project
conventions. - Template variable redundancy: The admin_viewing_non_member_team computation in the template context (lines 39-44)
re-derives admin status instead of using is_admin_user.
Test Coverage
- Strong coverage: 4 service-level tests + 4 banner tests + 7 security regression tests + 12 filter edge case tests =
comprehensive. - Tests cover the happy path, edge cases (inactive, wrong visibility, search mismatch, limit reached), and both
admin/non-admin paths. - The existing tests were properly updated with get_user_personal_team = AsyncMock(return_value=None) to avoid
breakage. - Trailing whitespace in several test lines (visible in diff) - should be cleaned with make black.
Recommendations
- Extract the personal team filter logic into a shared helper to eliminate the 4x duplication
- Fix the inconsistent search matching in admin_get_all_team_ids (add description check)
- Use the is_admin_user variable in the template context instead of re-computing it
- Fix the f-string logger to use lazy %s formatting
- Clarify: Are view/edit/delete endpoint guards included in this PR or in a follow-up?
Verdict
The security fix itself is correct and well-tested. The main concerns are code duplication and a minor inconsistency
in search logic. These should be addressed before merging to prevent divergence bugs in the future.
6f6bde2 to
abcd05f
Compare
|
Hi @madhav165, thanks for the review. Addressed all the requested changes:
Regarding the view/edit/delete endpoints, they already have the security code that checks and does not allow admin users to edit a user's personal team. In the PR, I have mentioned it for UI, since the admin user will not be able to list the teams in the first place, so no risk of other actions as well. |
98687ab to
7e57617
Compare
madhav165
left a comment
There was a problem hiding this comment.
Review Feedback
The fix correctly identifies the security issue and the admin-layer approach is reasonable, but the current implementation has a structural problem worth addressing before merge.
Core Issue: Two queries + manual append breaks pagination
All four admin endpoints (admin_get_all_team_ids, admin_search_teams, admin_teams_partial_html, admin_list_teams) follow this pattern:
result = await team_service.list_teams(include_personal=False, ...)
data = result["data"]
admin_personal_team = await team_service.get_user_personal_team(user_email)
if admin_personal_team and not any(t.id == admin_personal_team.id for t in data):
if team_service.should_include_personal_team(...) and len(data) < per_page:
data.append(admin_personal_team)Problems:
- Pagination metadata is wrong.
total_items,total_pages, andhas_nextfrom the first query don't account for the appended personal team. The UI count will be off by one. - Personal team can silently disappear. If
len(data) == per_page, the guard blocks it from being added — it won't appear on any page. - Code duplication. The same block is copy-pasted 4 times, and the
admin_list_teamsvariant already has slightly different hardcoded args (True, Nonevs request params). - Two queries instead of one for every admin team listing request.
Suggested approach: single query in the service layer
In list_teams (and get_all_team_ids), add a personal_owner_email parameter:
async def list_teams(self, ..., include_personal=False, personal_owner_email=None, ...):
...
if not include_personal:
if personal_owner_email:
query = query.where(
or_(EmailTeam.is_personal.is_(False), EmailTeam.created_by == personal_owner_email)
)
else:
query = query.where(EmailTeam.is_personal.is_(False))Then admin.py call sites become:
result = await team_service.list_teams(include_personal=False, personal_owner_email=user_email, ...)This fixes all four problems: pagination is correct, no silent disappearance, no duplication, one query. The new get_user_personal_team and should_include_personal_team methods can be removed. The admin_viewing_non_member_team banner logic and UI changes are fine as-is.
b17519c to
7078873
Compare
|
Hi @madhav165, Thanks for the thorough review, all points are valid and have been addressed.
Fix applied: Added personal_owner_email: Optional[str] = None to both list_teams() and get_all_team_ids() in the service layer. When set alongside include_personal=False, the SQL query becomes:
All 4 admin endpoints now make a single call with personal_owner_email=user_email. Pagination is automatically correct, no manual append needed, no silent disappearance possible. All filters (active, visibility, search) apply uniformly via SQL. |
|
Thanks @kevalmahajan — good fix for admin visibility of personal teams. The PR is substantial (+1104 lines) — please make sure test coverage is thorough for all edge cases. |
b49eb3f to
35ef7f3
Compare
madhav165
left a comment
There was a problem hiding this comment.
Good fix overall. The core security change is correct — switching from include_personal=True to include_personal=False, personal_owner_email=user_email across all four admin endpoints cleanly isolates personal teams at the query level without breaking the service layer for other callers.
A few things were addressed during review:
Admin banner behavior fixed: The banner message said "Defaulting to All Teams" but selected_team_id was not actually being reset — the page would filter by the selected team and return empty content. Fixed by resetting selected_team_id = None when an admin selects a non-member team, so the content genuinely defaults to All Teams as stated.
Service layer tests replaced: test_list_teams_personal_owner_email and test_get_all_team_ids_personal_owner_email were checking compiled SQL strings for column name presence ("is_personal" in compiled, "created_by" in compiled), which is fragile and does not verify filtering behavior. Replaced with real in-memory SQLite tests that insert teams with different is_personal/created_by values and assert which ones are returned.
One note on the asymmetry between admin and non-admin paths: non-admins with an invalid team_id silently default to All Teams (same as before this PR), while admins now get the banner. This is intentional — admins may legitimately attempt to select a non-member team for governance, so the explanation is warranted.
5a741b6 to
672fa07
Compare
Signed-off-by: Keval Mahajan <mahajankeval23@gmail.com>
Signed-off-by: Keval Mahajan <mahajankeval23@gmail.com>
Signed-off-by: Keval Mahajan <mahajankeval23@gmail.com>
Signed-off-by: Keval Mahajan <mahajankeval23@gmail.com>
Signed-off-by: Keval Mahajan <mahajankeval23@gmail.com>
Signed-off-by: Keval Mahajan <mahajankeval23@gmail.com>
Signed-off-by: Keval Mahajan <mahajankeval23@gmail.com>
Signed-off-by: Keval Mahajan <mahajankeval23@gmail.com>
Signed-off-by: Keval Mahajan <mahajankeval23@gmail.com>
…al team while excluding other users' personal teams. Signed-off-by: Keval Mahajan <mahajankeval23@gmail.com>
Signed-off-by: Keval Mahajan <mahajankeval23@gmail.com>
Signed-off-by: Keval Mahajan <mahajankeval23@gmail.com>
Signed-off-by: Keval Mahajan <mahajankeval23@gmail.com>
…gile SQL tests with behavioral tests Signed-off-by: Madhav Kandukuri <madhav165@gmail.com>
…ndant variable - Update tests that expected 403 for non-member team access to match new behavior: non-admins get selected_team_id silently reset, team-scoped admins see banner with content defaulting to All Teams - Fix banner test to use team-scoped admin token (token_teams set) so the unrestricted-admin bypass doesn't skip the membership check - Remove redundant _is_admin variable, reuse is_admin_user instead Signed-off-by: Mihai Criveti <crivetimihai@gmail.com>
…face update failures - admin_get_team_edit: return 404 when team is personal and caller is not the owner (defense-in-depth for personal team isolation) - admin_update_team: check return value from update_team(); surface error instead of false success when service refuses the operation (e.g. personal team protection) - Fix existing tests that mocked update_team with return_value=None (now correctly use True for success path) - Add deny-path tests for personal team edit/update rejection - Rename misleading test_admin_cannot_see_private_teams_not_member_of to test_admin_teams_partial_excludes_other_personal_teams Signed-off-by: Mihai Criveti <crivetimihai@gmail.com>
672fa07 to
07f9817
Compare
crivetimihai
left a comment
There was a problem hiding this comment.
Reviewed and approved. Changes look correct and well-tested.
What was fixed during review:
- Rebased onto main, resolved 2 merge conflicts in admin.py
- Fixed broken banner test (needed team-scoped admin token, not unrestricted)
- Updated 3 pre-existing tests expecting 403 to match new silent-reset behavior
- Removed redundant _is_admin variable (reused is_admin_user)
- Added personal team guard to admin_get_team_edit() (returns 404 for non-owners)
- Fixed admin_update_team() to check update_team() return value (was showing false success for personal teams)
- Added 4 deny-path tests for direct personal team access
- Renamed misleading test name
- Fixed existing tests mocking update_team with None instead of True
Security: No SQL injection, XSS, or authorization bypass risks. Service layer correctly blocks personal team mutations. Direct-access endpoints now also guard against personal team disclosure.
All 1171 admin/team tests pass.
- admin_get_team_edit: block ALL personal teams (not just non-owner), since update_team() rejects all personal team updates anyway. Showing an edit form that can never succeed is misleading. - admin_delete_team: check return value from delete_team(). The service catches its own ValueError internally and returns False — the error never propagated to the admin endpoint, causing false success messages. - Fix test_admin_delete_team_success to mock return_value=True - Add test_admin_delete_team_rejected_personal_team deny-path test - Update edit tests to expect 403 for all personal teams Signed-off-by: Mihai Criveti <crivetimihai@gmail.com>
…ddition Commit 3e3bccc added ID columns to admin UI tables (agents, gateways, tools), shifting all subsequent column indices by +1. Update all hardcoded .nth() selectors in playwright tests to match the new layout. Signed-off-by: Mihai Criveti <crivetimihai@gmail.com>
test_mock_subprocess_creation used a regular Mock for process.stdout, causing _pump_stdout() background task to fail with "object Mock can't be used in 'await' expression". Use AsyncMock with readline returning EOF (b"") so the pump task completes cleanly. Signed-off-by: Mihai Criveti <crivetimihai@gmail.com>
* fix personal teams visibility for admin users Signed-off-by: Keval Mahajan <mahajankeval23@gmail.com> * Fix ux for admin users for team access Signed-off-by: Keval Mahajan <mahajankeval23@gmail.com> * linting fixes Signed-off-by: Keval Mahajan <mahajankeval23@gmail.com> * add regression tests for admin visibility Signed-off-by: Keval Mahajan <mahajankeval23@gmail.com> * added regression tests Signed-off-by: Keval Mahajan <mahajankeval23@gmail.com> * fix test cases Signed-off-by: Keval Mahajan <mahajankeval23@gmail.com> * resolve duplications and other minor issues Signed-off-by: Keval Mahajan <mahajankeval23@gmail.com> * fix test case Signed-off-by: Keval Mahajan <mahajankeval23@gmail.com> * add missing test coverage Signed-off-by: Keval Mahajan <mahajankeval23@gmail.com> * fix: Ensure admin team views correctly include the admin's own personal team while excluding other users' personal teams. Signed-off-by: Keval Mahajan <mahajankeval23@gmail.com> * add regression tests Signed-off-by: Keval Mahajan <mahajankeval23@gmail.com> * remove dead code Signed-off-by: Keval Mahajan <mahajankeval23@gmail.com> * unused code cleanup Signed-off-by: Keval Mahajan <mahajankeval23@gmail.com> * fix: reset selected_team_id for admin non-member team and replace fragile SQL tests with behavioral tests Signed-off-by: Madhav Kandukuri <madhav165@gmail.com> * fix(rbac): fix tests for new non-member team behavior and remove redundant variable - Update tests that expected 403 for non-member team access to match new behavior: non-admins get selected_team_id silently reset, team-scoped admins see banner with content defaulting to All Teams - Fix banner test to use team-scoped admin token (token_teams set) so the unrestricted-admin bypass doesn't skip the membership check - Remove redundant _is_admin variable, reuse is_admin_user instead Signed-off-by: Mihai Criveti <crivetimihai@gmail.com> * fix(rbac): block direct access to other users' personal teams and surface update failures - admin_get_team_edit: return 404 when team is personal and caller is not the owner (defense-in-depth for personal team isolation) - admin_update_team: check return value from update_team(); surface error instead of false success when service refuses the operation (e.g. personal team protection) - Fix existing tests that mocked update_team with return_value=None (now correctly use True for success path) - Add deny-path tests for personal team edit/update rejection - Rename misleading test_admin_cannot_see_private_teams_not_member_of to test_admin_teams_partial_excludes_other_personal_teams Signed-off-by: Mihai Criveti <crivetimihai@gmail.com> * fix(rbac): block personal team edit/delete and surface errors correctly - admin_get_team_edit: block ALL personal teams (not just non-owner), since update_team() rejects all personal team updates anyway. Showing an edit form that can never succeed is misleading. - admin_delete_team: check return value from delete_team(). The service catches its own ValueError internally and returns False — the error never propagated to the admin endpoint, causing false success messages. - Fix test_admin_delete_team_success to mock return_value=True - Add test_admin_delete_team_rejected_personal_team deny-path test - Update edit tests to expect 403 for all personal teams Signed-off-by: Mihai Criveti <crivetimihai@gmail.com> * fix(tests): update playwright column indices after entity ID column addition Commit 3e3bccc added ID columns to admin UI tables (agents, gateways, tools), shifting all subsequent column indices by +1. Update all hardcoded .nth() selectors in playwright tests to match the new layout. Signed-off-by: Mihai Criveti <crivetimihai@gmail.com> * fix(tests): use AsyncMock for subprocess stdout in StdIOEndpoint test test_mock_subprocess_creation used a regular Mock for process.stdout, causing _pump_stdout() background task to fail with "object Mock can't be used in 'await' expression". Use AsyncMock with readline returning EOF (b"") so the pump task completes cleanly. Signed-off-by: Mihai Criveti <crivetimihai@gmail.com> --------- Signed-off-by: Keval Mahajan <mahajankeval23@gmail.com> Signed-off-by: Madhav Kandukuri <madhav165@gmail.com> Signed-off-by: Mihai Criveti <crivetimihai@gmail.com> Co-authored-by: Madhav Kandukuri <madhav165@gmail.com> Co-authored-by: Mihai Criveti <crivetimihai@gmail.com>
* fix personal teams visibility for admin users Signed-off-by: Keval Mahajan <mahajankeval23@gmail.com> * Fix ux for admin users for team access Signed-off-by: Keval Mahajan <mahajankeval23@gmail.com> * linting fixes Signed-off-by: Keval Mahajan <mahajankeval23@gmail.com> * add regression tests for admin visibility Signed-off-by: Keval Mahajan <mahajankeval23@gmail.com> * added regression tests Signed-off-by: Keval Mahajan <mahajankeval23@gmail.com> * fix test cases Signed-off-by: Keval Mahajan <mahajankeval23@gmail.com> * resolve duplications and other minor issues Signed-off-by: Keval Mahajan <mahajankeval23@gmail.com> * fix test case Signed-off-by: Keval Mahajan <mahajankeval23@gmail.com> * add missing test coverage Signed-off-by: Keval Mahajan <mahajankeval23@gmail.com> * fix: Ensure admin team views correctly include the admin's own personal team while excluding other users' personal teams. Signed-off-by: Keval Mahajan <mahajankeval23@gmail.com> * add regression tests Signed-off-by: Keval Mahajan <mahajankeval23@gmail.com> * remove dead code Signed-off-by: Keval Mahajan <mahajankeval23@gmail.com> * unused code cleanup Signed-off-by: Keval Mahajan <mahajankeval23@gmail.com> * fix: reset selected_team_id for admin non-member team and replace fragile SQL tests with behavioral tests Signed-off-by: Madhav Kandukuri <madhav165@gmail.com> * fix(rbac): fix tests for new non-member team behavior and remove redundant variable - Update tests that expected 403 for non-member team access to match new behavior: non-admins get selected_team_id silently reset, team-scoped admins see banner with content defaulting to All Teams - Fix banner test to use team-scoped admin token (token_teams set) so the unrestricted-admin bypass doesn't skip the membership check - Remove redundant _is_admin variable, reuse is_admin_user instead Signed-off-by: Mihai Criveti <crivetimihai@gmail.com> * fix(rbac): block direct access to other users' personal teams and surface update failures - admin_get_team_edit: return 404 when team is personal and caller is not the owner (defense-in-depth for personal team isolation) - admin_update_team: check return value from update_team(); surface error instead of false success when service refuses the operation (e.g. personal team protection) - Fix existing tests that mocked update_team with return_value=None (now correctly use True for success path) - Add deny-path tests for personal team edit/update rejection - Rename misleading test_admin_cannot_see_private_teams_not_member_of to test_admin_teams_partial_excludes_other_personal_teams Signed-off-by: Mihai Criveti <crivetimihai@gmail.com> * fix(rbac): block personal team edit/delete and surface errors correctly - admin_get_team_edit: block ALL personal teams (not just non-owner), since update_team() rejects all personal team updates anyway. Showing an edit form that can never succeed is misleading. - admin_delete_team: check return value from delete_team(). The service catches its own ValueError internally and returns False — the error never propagated to the admin endpoint, causing false success messages. - Fix test_admin_delete_team_success to mock return_value=True - Add test_admin_delete_team_rejected_personal_team deny-path test - Update edit tests to expect 403 for all personal teams Signed-off-by: Mihai Criveti <crivetimihai@gmail.com> * fix(tests): update playwright column indices after entity ID column addition Commit 3e3bccc added ID columns to admin UI tables (agents, gateways, tools), shifting all subsequent column indices by +1. Update all hardcoded .nth() selectors in playwright tests to match the new layout. Signed-off-by: Mihai Criveti <crivetimihai@gmail.com> * fix(tests): use AsyncMock for subprocess stdout in StdIOEndpoint test test_mock_subprocess_creation used a regular Mock for process.stdout, causing _pump_stdout() background task to fail with "object Mock can't be used in 'await' expression". Use AsyncMock with readline returning EOF (b"") so the pump task completes cleanly. Signed-off-by: Mihai Criveti <crivetimihai@gmail.com> --------- Signed-off-by: Keval Mahajan <mahajankeval23@gmail.com> Signed-off-by: Madhav Kandukuri <madhav165@gmail.com> Signed-off-by: Mihai Criveti <crivetimihai@gmail.com> Co-authored-by: Madhav Kandukuri <madhav165@gmail.com> Co-authored-by: Mihai Criveti <crivetimihai@gmail.com> Signed-off-by: Yosief Eyob <yosiefogbazion@gmail.com>
🐛 Bug-fix PR
Closes #3376
📌 Summary
Fixed a security issue in the admin UI where platform administrators could see and manage personal teams belonging to other users. The issue violated the fundamental security principle that personal teams should only be visible to their owner, even for admins.
Why this matters:
Another minor fix: All other private teams are still listed for the admin user for governance (managing members, editing, deleting) but when the admin user tries to see the content for that private team which he is not a member of, in that case, error banner will be displayed and will be defaulted to All Teams.
🔁 Reproduction Steps
Original Bug:
admin@example.com(platform admin) anduser@example.com(regular user)personal-admin,personal-user)admin@example.comto the admin UI at/admin/admin/teams- List all teams/admin/teams/{team_id}- View team details/admin/teams/{team_id}- Edit team/admin/teams/{team_id}- Delete teamExpected behavior: Admin should only see:
personal-admin)Actual behavior (bug): Admin could see:
personal-admin)personal-user,personal-alice, etc.)🐞 Root Cause
The issue existed in
mcpgateway/admin.pyacross four endpoints that usedTeamManagementService.list_teams():The problem:
include_personal=Truereturned ALL personal teams in the databaseAffected endpoints:
GET /admin/teams- Team listing pageGET /admin/teams/{team_id}- Team details pagePUT /admin/teams/{team_id}- Team edit endpointDELETE /admin/teams/{team_id}- Team deletion endpoint💡 Fix Description
Access model updated:
Implementation changes:
Security Guarantees:
After this change:
Admins cannot:
Admins can:
Tests:
11 new test cases (security + UX)
Validates:
Design Decision:
🧪 Verification
make lintmake testmake coverage📐 MCP Compliance (if relevant)
✅ Checklist
make black isort pre-commit)