Repository navigation
fix: enforce ownership on configuration import updates - #7243
Conversation
Lang-Akshay
left a comment
There was a problem hiding this comment.
LGTM ✅
| Area | Rating | Key Issue |
|---|---|---|
| Blocking Changes | 0 | None |
| Security | 🟢 Clear | SECURITY_VERDICT=CLEAR; cross-tenant update vulnerability resolved |
| Issue Coverage | 🟢 Complete | Linked issue requirements fully satisfied |
| Code Quality | 🟢 High | Clean implementation, adheres to coding standards |
| Breaking Change | Yes | Documented requirement to grant admin.import globally |
| Scope | 🟢 In scope | All modifications strictly target import ownership enforcement |
| Test Context | Excluded | Test contents were not loaded |
msureshkumar88
left a comment
There was a problem hiding this comment.
The ownership guards and authenticated email propagation address the reported issue. No schema migration is required. Two blockers remain.
blocking | mcpgateway/services/tool_service.py:2654 | Separate conflict queries permit an unchecked cross-owner overwrite during concurrent publication (CWE-367/CWE-863) | Load conflicts once, authorize those rows, and mutate the same checked row map.
`_find_unowned_tool_names()` checks one snapshot. `_process_tool_chunk()` queries again at line 2798. A tool published between these queries is absent from `unowned_names`. The update at line 2952 therefore proceeds without checking its ownership. I reproduced this with two real SQLite sessions and the actual bulk method. The publisher commits immediately before the second query. The result reports `updated=1`, `failed=0`, and zero ownership checks. The persisted URL changes while `owner_email` remains the victim's email. Audit and cache side effects are mocked; the ownership spy denies if called. Consolidate loading and authorization within one asynchronous chunk operation. Reject new collisions instead of reloading unchecked rows. This also removes the redundant conflict query.
blocking | tests/unit/mcpgateway/services/test_tool_service.py:9593 | Security tests mock ownership decisions and omit the required live-gateway regression | Add database-backed authorization tests and reproduce the two-team scenario against a running gateway.
The current tool tests return the same rows for both queries and miss the race above. The route test at `tests/unit/mcpgateway/middleware/test_rbac.py:2436` forces permission denial and verifies decorator arguments. The gateway test verifies identity forwarding rather than persisted denial. Add real role grants and authenticated requests. Verify that denied imports preserve victim fields and ownership across tools, resources, prompts, and gateways. Include unauthenticated requests, insufficient permissions, token narrowing, authorized owner/admin updates, and concurrent tool publication. The PR marks its required live-gateway test as pending.
Validation: two focused selections pass, with 79 and 207 tests respectively; the selections overlap. The deterministic race reproducer confirms the persisted unauthorized overwrite. Full validation and a live HTTP reproduction remain unchecked.
POST /v1/import with conflict_strategy=update overwrote tools, resources, prompts and gateways owned by other teams. Bulk registration looked up public rows by name with no tenant predicate and never checked ownership. The import call to update_gateway() omitted user_email, which skipped its ownership guard. Signed-off-by: prakhar-singh1928 <prakhar.singh1928@ibm.com>
register_tools_bulk loaded conflicts twice. A tool published between the two queries was updated without an ownership check. Load conflicts once per chunk and reuse the result. Reject any name that appears after the load. Add database-backed ownership tests for tools, resources and prompts. Add a live-gateway test for the two-tenant import case. Refs: PSIRT 4084216 Signed-off-by: prakhar-singh1928 <prakhar.singh1928@ibm.com>
Signed-off-by: prakhar-singh1928 <prakhar.singh1928@ibm.com>
f8b5543 to
1784350
Compare
msureshkumar88
left a comment
There was a problem hiding this comment.
What this change does: Configuration import now checks ownership before updating existing tools, resources, prompts, and gateways. REST import routes require global permission grants, and Admin UI import passes the authenticated email.
The previously reproduced tool publication race is closed. Authorization and mutation use the same loaded rows. Late collisions cannot introduce an unchecked update.
Database-backed tests verify denial, unchanged ownership, and owner/admin success for tools, resources, and prompts. Four black-box tests pass against running gateway subprocesses. They cover direct-write denial, team-scoped import denial, cross-team import denial, and owner/admin import success.
Validation at 1784350:
- Focused selection: 276 passed.
- Broader affected suites: 3,636 passed, 10 skipped, one existing plugin test failed.
- Unchanged base d2d1424: 3,620 passed, 10 skipped, the same plugin test failed.
- New live-gateway tests: four passed.
- Changed application files and the new live test pass Ruff. Existing test-file lint findings also occur on the base.
- GitHub checks: 35 successful, five skipped, no failing or pending checks.
No remaining blocking finding in the reviewed diff. Code review approved.
Not checked: the complete local pre-merge validation gate or production PostgreSQL concurrency. This approval does not waive the AGENTS.md merge gate.
d2fb16a
Pull Request
🔗 Related Issue
Closes: https://github.ibm.com/contextforge-org/internal_issues/issues/1134
📝 Summary
POST /v1/importwithconflict_strategy=updateoverwrote tools, resources, prompts and gateways that belong to other teams.Root causes:
register_tools_bulk,register_resources_bulkandregister_prompts_bulklooked up public rows by name with no tenant predicate. They changed the matched row without an ownership check.ImportServicecalledupdate_gateway()withoutuser_email, which skipped its ownership guard.admin.importroutes had noglobal_only=True, socheck_any_teamlet a team-scoped role satisfy them."unknown", asimported_by.Changes:
user_emailto the three bulk registration methods. Before each update, runcheck_resource_ownership(). ReportPermission deniedfor rows the caller does not own.ImportServiceto the bulk methods and toupdate_gateway().global_only=Trueon the fouradmin.importroutes.imported_byin the Admin UI import.CHANGELOG.md.Behavior change: a team-scoped role that holds
admin.importnow receives HTTP 403. Operators must move that grant to a global role.📏 Reviewability
triage🏷️ Type of Change
🧪 Verification
Targeted run of the affected suites:
Result: 3632 passed, 10 skipped, 1 failed.
The failure is
test_get_prompt_post_hook_policy_does_not_duplicate_prompt_messages. It fails the same way on unmodifiedmainwhen run with these files, and passes when run alone.Deny-path regression tests added:
ImportServicepassesuser_emailto the bulk methods and toupdate_gateway().admin.importroutes call the permission check withteam_id=Noneandcheck_any_team=False.make lintruff checkreports the same 8 findings on unmodifiedmain.make testmake coverageLive-gateway black-box test: pending. It reproduces the two-team scenario from the issue against a running gateway.
✅ Checklist
make black isort pre-commit)📓 Notes (optional)
routers/toolops_router.pygatesenrich_a_toolonadmin.system_config.toolops_altk_service.pythen callsupdate_tool()withoutuser_email. That path rewrites only name and description. Track it in a separate issue.updatewith no caller identity. They now pass an authorized caller. This is the intended behavior change.rufffindings andblackformatting differences exist in the touched files on unmodifiedmain. This PR does not change them.