Security Hardening: Catalog Registration Scope Enforcement - #6247
Security Hardening: Catalog Registration Scope Enforcement#6247Lang-Akshay wants to merge 15 commits into
Conversation
c19e6b0 to
b2a9ef0
Compare
msureshkumar88
left a comment
There was a problem hiding this comment.
Thanks for tackling this — the shift to private-by-default with explicit ownership tracking is a solid direction, and the ownership-transfer-on-deletion flow with the atomic refuse-if-no-owner fallback is a genuinely good safety net. Found a few gaps that are worth addressing before merge, plus some smaller notes.
Blocking
1. _resolve_registration_scope() doesn't check allow_public_visibility (mcpgateway/services/catalog_service.py:62)
Every other gateway-creation path in admin.py calls _check_public_visibility_allowed() before allowing visibility="public". The new catalog registration path never references allow_public_visibility at all, so with ALLOW_PUBLIC_VISIBILITY=false, registering a catalog server with visibility="public" still succeeds. Since this PR's whole purpose is scope enforcement, this specific control should be wired in here too (CWE-284/CWE-863).
2. transfer_gateway_ownership() updates team_id but not visibility (mcpgateway/services/gateway_service.py:5589)
Transferring a public/private gateway to a team sets gateway.team_id but leaves visibility unchanged. The actual access-scoping query (gateway_service.py:2538-2556) only grants team access when visibility is "team"/"public" — it doesn't derive scope from team_id alone. So the transfer silently doesn't do what it says: the gateway's effective access scope is unchanged, no error is raised, and the existing test (test_gateway_service_transfer.py:1680-1729) only asserts team_id changed, not visibility, so this ships uncaught. (Confirmed this isn't a privilege-escalation path — private gateways stay gated by owner_email regardless — but it does defeat the endpoint's stated purpose.)
3. Deactivated users can inherit gateway ownership (mcpgateway/services/email_auth_service.py:2109)
The alternate-owner lookup in delete_user() filters on EmailTeamMember.is_active only, never EmailUser.is_active. Since deactivating a user (update_user(is_active=False)) doesn't touch their EmailTeamMember rows, a disabled account can still be picked as the new owner of a deleted teammate's gateway — which undercuts the PR's goal of preventing unmanageable/orphaned gateways, since nobody can log into a disabled account. The explicit transfer_gateway_ownership() endpoint already does check is_active for the target — worth aligning the two.
4. Orphan-owner error gets swallowed into a generic 500 (mcpgateway/routers/email_auth.py:913)
The new ValueError("...would become orphaned and no fallback owner is available") is a good, actionable error, but the router's except Exception catches it and returns a generic "Failed to delete user" 500 — the admin never sees the real reason (configure platform_admin_email or reassign manually). The admin-UI equivalent (admin.py ~8669-8671) does surface str(e); worth matching that on the API path.
Suggestions
_resolve_registration_scope()reimplements team-membership validation instead of reusingTeamManagementService.get_user_role()/verify_team_for_user(), whichadmin_add_gatewayalready uses for the same check. Consolidating would prevent the two paths from drifting apart (this PR is itself an example of that drift — see #1).bulk_register_servers()validates the batch's scope once up front, thenregister_catalog_server()re-runs the identical validation (2 queries + a commit) per item in the loop — for a 50-item batch that's 50 redundant round-trips for validation already proven valid.transfer_gateway_ownership()runs two near-identical team-membership queries whenvisibility=="team"and an explicittarget_team_idis passed — the second appears to re-check what the first already validated.- No deny-path test for a non-admin calling
POST /admin/gateways/{id}/transfer-ownership— worth adding given this endpoint can reassign any gateway system-wide. - Visibility/
team_idextraction logic in the registration modal is duplicated betweenmodals.js(submitApiKeyForm) and the inline script inmcp_registry_partial.html— could drift if only one gets updated later.
Minor
- The PR references only an internal
github.ibm.comtracker issue; no public issue /Closes #NNNlink, which makes this harder to trace on the public repo.
No Alembic migration is touched, which looks correct — no schema change is actually needed for this fix. Scope of touched files otherwise looks tight and on-topic.
de6be63 to
c53e5ff
Compare
There was a problem hiding this comment.
Thanks for the thorough review @msureshkumar88 — all blocking findings and suggestions have been addressed in the latest commit.
Blocking
-
_resolve_registration_scope()missingallow_public_visibilitycheck — Fixed ✅. Added the guard immediately aftervisibilityis resolved:visibility="public"with ateam_idnow raisesCatalogRegistrationPermissionErrorwhenALLOW_PUBLIC_VISIBILITY=false, matching every other creation path inadmin.py. Test added:test_resolve_scope_blocks_public_when_flag_disabled. -
transfer_gateway_ownership()leavesvisibilityunchanged — Fixed ✅. Whentarget_team_idis supplied and the gateway (or any linked tool/resource/prompt) hasvisibility="private", it is coerced to"team"so the team can actually see it after transfer. Test added:test_transfer_private_gateway_to_team_coerces_visibility. -
Deactivated users can inherit gateway ownership — Fixed ✅. The alternate-owner query now JOINs
EmailUserand filtersEmailUser.is_active == Truealongside the existingEmailTeamMember.is_activecheck. Applied to both the gateway fallback path and the team-ownership transfer path indelete_user(). -
Orphan-owner error swallowed into 500 — Fixed ✅. Added a dedicated
except ValueErrorbranch before the genericexcept ExceptioninDELETE /admin/users/{email}, returning HTTP 409 withstr(e)as the detail. The admin now sees the actionable message ("configureplatform_admin_emailor reassign manually"). Test added:test_delete_user_orphan_value_error_returns_409.
Suggestions
-
Duplicate team-membership validation — Consolidated. The two serial raw queries in
_resolve_registration_scope(team existence + membership) are replaced by a single JOIN, mirroring the pattern used byTeamManagementService.verify_team_for_user(). Making the methodasyncto call it directly would cascade through two callers; the JOIN achieves the same consolidation without that change. -
Redundant per-item scope validation in bulk — Fixed.
_resolve_registration_scoperuns once inbulk_register_serversand the resolved(visibility, team_id)tuple is forwarded via a new_resolved_scopekwarg onregister_catalog_server, skipping re-validation for every item in the batch. -
Two near-identical team-membership queries in
transfer_gateway_ownership()— The second query (lines 5576–5584) covers the case wherevisibility != "team"but an explicittarget_team_idis provided, which the first query does not evaluate. They guard different conditions so the duplication is load-bearing; left as-is to avoid silently dropping the cross-team membership check. -
No deny-path test for non-admin on the transfer endpoint — Added:
test_transfer_gateway_ownership_non_admin_deniedasserts HTTP 403 whencheck_admin_permissionreturnsFalse. -
submitApiKeyForm/mcp_registry_partial.htmlduplication — The UI changes in this PR have been reverted; this is now out of scope for this branch.
gandhipratik203
left a comment
There was a problem hiding this comment.
I had overlapping comments to Suresh. LGTM once the blocking ones are resolved!
2ef6f70 to
769ab8a
Compare
…ation Add CatalogRegistrationPermissionError and _resolve_registration_scope to enforce caller-owned, least-privilege catalog registration. - Add optional visibility and team_id fields to CatalogServerRegisterRequest, CatalogServerRegisterBody, and CatalogBulkRegisterRequest schemas - Require authenticated context (created_by, owner_email, token_teams) as keyword-only args in register_catalog_server and bulk_register_servers - Validate team membership and token scope before gateway persistence - Remove hardcoded visibility=public from both OAuth and initialized registration paths - Default new catalog registrations to visibility=private Signed-off-by: Lang-Akshay <akshay.shinde26@ibm.com>
Pass caller identity (owner_email, created_by, token_teams) from the admin single, admin bulk, and v1 catalog registration handlers into CatalogService. - Resolve (user_email, token_teams) via get_scoped_resource_access_context - Map CatalogRegistrationPermissionError to HTTP 403 - Map v1 body visibility/team_id into service request - Remove old positional team_id derivation in v1 route Signed-off-by: Lang-Akshay <akshay.shinde26@ibm.com>
Add private/team/public visibility selector and team dropdown to the catalog registration modal. Default selection is private. Team selector is hidden unless visibility is team. - Update catalog_partial to pass registration_teams to template - Update registerServerWithApiKey and submitApiKeyForm handlers to include visibility and team_id in the request body Signed-off-by: Lang-Akshay <akshay.shinde26@ibm.com>
Add GatewayOwnershipTransferRequest schema, transfer_gateway_ownership service method, and admin-only POST route for platform admins to reassign gateway ownership. - Validate target user exists and is active - Validate team membership for team-visible gateways - Propagate ownership to linked tools, resources, and prompts - Record audit trail with independent session Signed-off-by: Lang-Akshay <akshay.shinde26@ibm.com>
Transfer all owned gateways before deleting a user. For team-visible gateways, prefer another active team member (deterministic by email). Fall back to platform admin if no team member is available. Refuse deletion if no suitable owner exists. - Transfer linked tools, resources, and prompts with each gateway - Preserve existing deletion rollback behavior on failure Signed-off-by: Lang-Akshay <akshay.shinde26@ibm.com>
Fix all test callers to pass required created_by, owner_email, and token_teams keyword args. Update assertions to match new default private visibility and token_teams parameter. - Fix admin test calls to mock get_scoped_resource_access_context - Fix bulk handler tests to pass http_request parameter - Add missing require_admin_permission import in admin.py Signed-off-by: Lang-Akshay <akshay.shinde26@ibm.com>
Add tests covering: - Default visibility is private, not public - Explicit public visibility is honored - Team visibility without team_id is rejected - Foreign team not in token scope is rejected - Public-only tokens cannot create private registrations - Unknown/empty owner is rejected - Owner email matches authenticated caller - Bulk rejects invalid scope before any registration Signed-off-by: Lang-Akshay <akshay.shinde26@ibm.com>
Signed-off-by: Lang-Akshay <akshay.shinde26@ibm.com>
- Add pylint disable=singleton-comparison for SQLAlchemy == True filters - Remove unused http_request parameter from transfer route - Add missing require_admin_permission import - Regenerate .secrets.baseline for shifted line numbers Signed-off-by: Lang-Akshay <akshay.shinde26@ibm.com>
The delete_user method gained a db.execute(select(DbGateway)) call for gateway ownership transfer, but 4 tests used execute.side_effect with finite lists that didn't account for this new query. The exhausted iterator raised StopIteration, which becomes RuntimeError in async context. Add mock_no_gateways to the side_effect lists at the correct position. Signed-off-by: Lang-Akshay <akshay.shinde26@ibm.com>
…n error handling Cover all 87 previously-missing diff lines across 5 files: - gateway_service: transfer_gateway_ownership method (6 tests) - email_auth_service: gateway transfer during user deletion (3 tests) - catalog_service: team membership validation in _resolve_registration_scope (3 tests) - catalog router: CatalogRegistrationPermissionError → 403 (1 test) - admin: transfer endpoint, catalog permission errors, team loading (6 tests) Diff coverage: 40% → 100%. Signed-off-by: Lang-Akshay <akshay.shinde26@ibm.com>
Signed-off-by: Lang-Akshay <akshay.shinde26@ibm.com>
- Enforce ALLOW_PUBLIC_VISIBILITY in _resolve_registration_scope - Coerce private->team visibility on gateway ownership transfer - Filter EmailUser.is_active in ownership fallback during user deletion - Return 409 (not 500) for orphan-owner ValueError on DELETE /admin/users - Collapse two serial team-membership queries to one JOIN - Skip scope re-validation in bulk_register_servers via _resolved_scope - Add non-admin 403 deny-path test for transfer-ownership endpoint - Revert UI-only changes (modals.js, mcp_registry_partial.html) Signed-off-by: Lang-Akshay <akshay.shinde26@ibm.com>
Signed-off-by: Lang-Akshay <akshay.shinde26@ibm.com>
Restrict gateway ownership transfers to the caller's token teams and require gateways.create for admin catalog registration routes. Add deny-path regression coverage for scoped transfers and insufficient gateway permissions. Signed-off-by: Lang-Akshay <akshay.shinde26@ibm.com>
7e23cc9 to
472b8bc
Compare
Security harden catalog server registration to enforce caller-owned scope and least-privilege defaults across all registration paths. All changes are backend-only; no UI files are modified.
What changed
Catalog registration scope
visibility="private"withowner_emailderived from the authenticated caller — no forced-public, ownerless gatewaysALLOW_PUBLIC_VISIBILITY=falsenow enforced on the catalog path (_resolve_registration_scope); previously only admin UI handlers checked this flag_resolve_registration_scopereplaced with a single JOIN, matchingTeamManagementServicesemantics and eliminating a redundant round-trip(visibility, team_id)tuple into each per-server call via_resolved_scope, removing N redundant DB queries from the loopGateway ownership transfer
POST /admin/gateways/{id}/transfer-ownershipendpoint (admin-only) validates target user status and team membership, propagates ownership to linked tools/resources/prompts, and writes an audit trailvisibilityto"team"on the gateway and all linked entities — without this, team members could not access the gateway after transferSafe user deletion
EmailUserto exclude deactivated accounts (previously onlyEmailTeamMember.is_activewas checked)ValueErrorsurfaces as HTTP 409 with the actionable message onDELETE /admin/users/{email}; previously swallowed into a generic 500Tests added
test_gateway_service_transfer.py— full service-layer transfer coverage (target not found, not a member, success, linked entity propagation, private→team visibility coercion)ALLOW_PUBLIC_VISIBILITY=false, non-admin on transfer endpointNo schema or migration changes
No new columns or tables; no Alembic migration required.
Internal reference: https://github.ibm.com/contextforge-org/internal_issues/issues/497