Skip to content

Security Hardening: Catalog Registration Scope Enforcement - #6247

Open
Lang-Akshay wants to merge 15 commits into
mainfrom
fix/catalog-registration-security-hardening
Open

Security Hardening: Catalog Registration Scope Enforcement#6247
Lang-Akshay wants to merge 15 commits into
mainfrom
fix/catalog-registration-security-hardening

Conversation

@Lang-Akshay

@Lang-Akshay Lang-Akshay commented Aug 14, 2026

Copy link
Copy Markdown
Collaborator

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

  • Registrations default to visibility="private" with owner_email derived from the authenticated caller — no forced-public, ownerless gateways
  • Token scope validated before gateway creation; foreign teams and public-only token misuse rejected with 403
  • ALLOW_PUBLIC_VISIBILITY=false now enforced on the catalog path (_resolve_registration_scope); previously only admin UI handlers checked this flag
  • Two serial team-membership DB queries in _resolve_registration_scope replaced with a single JOIN, matching TeamManagementService semantics and eliminating a redundant round-trip
  • Bulk registration validates scope once upfront and passes the resolved (visibility, team_id) tuple into each per-server call via _resolved_scope, removing N redundant DB queries from the loop

Gateway ownership transfer

  • New POST /admin/gateways/{id}/transfer-ownership endpoint (admin-only) validates target user status and team membership, propagates ownership to linked tools/resources/prompts, and writes an audit trail
  • Transferring a private gateway to a team coerces visibility to "team" on the gateway and all linked entities — without this, team members could not access the gateway after transfer

Safe user deletion

  • Before permanent deletion, all owned gateways are transferred atomically: deletion is refused if any transfer cannot be completed
  • Fallback owner selection now joins EmailUser to exclude deactivated accounts (previously only EmailTeamMember.is_active was checked)
  • Orphan-owner ValueError surfaces as HTTP 409 with the actionable message on DELETE /admin/users/{email}; previously swallowed into a generic 500

Tests 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)
  • Deny-path regression tests: public-only token, foreign team, ALLOW_PUBLIC_VISIBILITY=false, non-admin on transfer endpoint
  • Orphan 409 regression test on the API router path

No 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

@Lang-Akshay
Lang-Akshay force-pushed the fix/catalog-registration-security-hardening branch from c19e6b0 to b2a9ef0 Compare August 14, 2026 12:56

@msureshkumar88 msureshkumar88 left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

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 reusing TeamManagementService.get_user_role()/verify_team_for_user(), which admin_add_gateway already 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, then register_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 when visibility=="team" and an explicit target_team_id is 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_id extraction logic in the registration modal is duplicated between modals.js (submitApiKeyForm) and the inline script in mcp_registry_partial.html — could drift if only one gets updated later.

Minor

  • The PR references only an internal github.ibm.com tracker issue; no public issue / Closes #NNN link, 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.

@Lang-Akshay
Lang-Akshay force-pushed the fix/catalog-registration-security-hardening branch from de6be63 to c53e5ff Compare August 17, 2026 23:02

@Lang-Akshay Lang-Akshay left a comment

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

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

Thanks for the thorough review @msureshkumar88 — all blocking findings and suggestions have been addressed in the latest commit.

Blocking

  1. _resolve_registration_scope() missing allow_public_visibility check — Fixed ✅. Added the guard immediately after visibility is resolved: visibility="public" with a team_id now raises CatalogRegistrationPermissionError when ALLOW_PUBLIC_VISIBILITY=false, matching every other creation path in admin.py. Test added: test_resolve_scope_blocks_public_when_flag_disabled.

  2. transfer_gateway_ownership() leaves visibility unchanged — Fixed ✅. When target_team_id is supplied and the gateway (or any linked tool/resource/prompt) has visibility="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.

  3. Deactivated users can inherit gateway ownership — Fixed ✅. The alternate-owner query now JOINs EmailUser and filters EmailUser.is_active == True alongside the existing EmailTeamMember.is_active check. Applied to both the gateway fallback path and the team-ownership transfer path in delete_user().

  4. Orphan-owner error swallowed into 500 — Fixed ✅. Added a dedicated except ValueError branch before the generic except Exception in DELETE /admin/users/{email}, returning HTTP 409 with str(e) as the detail. The admin now sees the actionable message ("configure platform_admin_email or 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 by TeamManagementService.verify_team_for_user(). Making the method async to 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_scope runs once in bulk_register_servers and the resolved (visibility, team_id) tuple is forwarded via a new _resolved_scope kwarg on register_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 where visibility != "team" but an explicit target_team_id is 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_denied asserts HTTP 403 when check_admin_permission returns False.

  • submitApiKeyForm / mcp_registry_partial.html duplication — The UI changes in this PR have been reverted; this is now out of scope for this branch.

@gandhipratik203 gandhipratik203 left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

I had overlapping comments to Suresh. LGTM once the blocking ones are resolved!

@gandhipratik203 gandhipratik203 self-assigned this Aug 18, 2026
@Lang-Akshay
Lang-Akshay force-pushed the fix/catalog-registration-security-hardening branch from 2ef6f70 to 769ab8a Compare August 19, 2026 12:28
…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>
@Lang-Akshay
Lang-Akshay force-pushed the fix/catalog-registration-security-hardening branch from 7e23cc9 to 472b8bc Compare August 21, 2026 14:48
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants