Skip to content

Commit 2f7ab34

Browse files
bogdanmariusc10Bogdan-Marius-Catanusjonpspri
authored
fix(security): prevent admin bypass from accessing private resources (#4341)
* fix: prevent admin bypass from accessing private resources - Modified service layer to deny admin bypass for private resources - Admin bypass now only grants access to public and team resources - Updated _apply_visibility_filter in base_service.py to exclude private resources - Updated _check_tool_access, _check_prompt_access, _check_resource_access methods - Added access control check in get_tool() service method - Fixed API endpoints to properly pass user_email when token_teams not set - Used sentinel value pattern to distinguish token_teams states - Updated all related unit tests to reflect new security behavior - Private resources now only accessible to their owners Signed-off-by: Bogdan-Marius-Catanus <bogdan-marius.catanus@ibm.com> * test: add coverage tests for admin bypass and access control paths - Add test_get_prompt_admin_bypass_with_teams_none for GET /prompts/{id} admin bypass (lines 6451-6452) - Add test_get_prompt_with_args_admin_bypass for POST /prompts/get admin bypass (lines 6373-6374) - Add test_read_resource_admin_bypass for GET /resources/{id} admin bypass (lines 5839-5840) - Add test_get_tool_access_denied_raises_not_found for tool access denial (line 3061) - Fix syntax error in test_base_service.py (line 1) - Update .secrets.baseline These tests cover the admin bypass pattern where is_admin=True and token_teams=None grants unrestricted access to public and team resources (but NOT private resources). Also covers the access control path where get_tool raises ToolNotFoundError when access is denied to maintain security by not revealing tool existence. Improves diff coverage from 86% to 95%+ by covering previously untested authorization paths. Signed-off-by: Bogdan-Marius-Catanus <bogdan-marius.catanus@ibm.com> * fix(security): extend admin-bypass private-resource denial across all access paths Extends the original PR's service-level denial so admin bypass (JWT `is_admin=true` + `teams=null`, or non-JWT dev-mode admin) can never see or mutate another user's private resources across any surface. Service-layer access checks: - a2a_service._check_agent_access: admin bypass grants public + team access only, never private. get_agent_by_name / get_agent_card gain user_email / token_teams and enforce the check before returning. - server_service.get_server: new _check_server_access helper; generic ServerNotFoundError + server_access_denied structured log on denial. - gateway_service.get_gateway: new _check_gateway_access helper; same pattern. - prompt_service.get_prompt_details: gates private reads (admin UI path). - resource_service.get_resource_by_id: gates private reads (/resources/{id}/info and admin UI paths). - resource_service.list_resource_templates: admin bypass filters visibility != 'private' from the template enumeration. - tool_service.get_tool: adds token_teams parameter so Layer 1 visibility is evaluated against JWT-scoped teams rather than being widened to the caller's full DB team roles (fixes a scope-widening regression). - completion_service._apply_visibility_scope: admin bypass excludes private so completion suggestions cannot reveal private prompt / resource names. - tag_service._apply_visibility_scope: admin bypass excludes private so the tag enumeration cannot reveal other users' private entity metadata. Endpoint / routing rewire: - main.py: the three inline sentinel_unset blocks replaced with _get_scoped_resource_access_context(), the canonical helper already maintained in auth.py / main.py. Same helper is now threaded into /servers/{id}, /gateways/{id}, /resources/{id}/info, /tools/{id}, /resources/templates/list (REST + JSON-RPC + MCP dispatcher), and the internal A2A agent card path. - admin.py: the five admin detail endpoints (/admin/{servers,gateways, resources,prompts,tools}/{id}) now accept the FastAPI Request and resolve visibility scope via a lazy import of the same helper to avoid the main <-> admin circular import. - base_service._apply_access_control: admin-bypass branch still filters private rows out; docstring updated to state the invariant. Observability: - Each new deny path emits a structured event (`tool_access_denied`, `server_access_denied`, `gateway_access_denied`, `prompt_access_denied`, `resource_access_denied`) so forensics does not rely on HTTP logs. Tests: - New TestDirectGetAccessDenial in tests/unit/mcpgateway/services/ test_authorization_access.py: regressions for every new deny/allow path (server, gateway, a2a by name and card, prompt_details, resource_by_id, tool scope, template enumeration, completion + tag SQL predicates). - Route-level regressions in tests/unit/mcpgateway/test_main.py assert admin-bypass wiring for the REST and JSON-RPC template handlers. - Completion + tag visibility assertions compile the scoped statement with `literal_binds=True` and verify the rendered SQL contains the expected `visibility != 'private'` predicate. - Existing a2a / completion / tag tests that encoded the pre-#4341 admin-sees-everything behavior are updated to the new secure semantics. - Three test_main_extended callers that invoke delete_server / delete_gateway directly now supply a mock Request, matching the new route signatures. Docs / behavior notes: - CHANGELOG.md gains an [Unreleased] breaking-change entry with a migration note for integrators that relied on admin-bypass private reads. - docs/docs/manage/rbac.md access matrix updated: admin bypass no longer reads private, with an admonition pointing at this PR. - docs/docs/architecture/multitenancy.md reconciled with the same owner-only rule for private visibility. Follow-up (tracked separately, not in scope here): - A2A consistency cleanup for a2a_server_service._check_server_access and A2AAgentService.list_tasks -> #4437 Signed-off-by: Jonathan Springer <jps@s390x.com> * refactor(auth): extract per-request auth helpers into auth_context module Hoist Layer-1 visibility helpers (get_rpc_filter_context, get_scoped_resource_access_context, get_token_teams_from_request, get_request_identity, get_user_email, and the internal MCP runtime trust-header helpers) out of main.py into a new mcpgateway/auth_context.py module. admin.py previously reached back via a lazy 'from mcpgateway.main import _get_scoped_resource_access_context' guarded by '# pylint: disable=import-outside-toplevel', creating a static cyclic import (admin -> main -> admin) flagged by pylint R0401. The new module depends only on mcpgateway.auth, breaking the cycle architecturally instead of masking it. auth.py and auth_context.py now carry purpose docstrings documenting the split: auth.py owns the token/session/team model layer (no Request input, reusable from any context), while auth_context.py owns per-request resolution (takes Request, returns the (email, token_teams, is_admin) tuple and the (email, token_teams) scoped-access tuple). This mirrors the two-layer security model described in AGENTS.md. The helpers are now exported under their public (no-underscore) names; tests are updated to patch and reference them by their new names. The Black hook re-collapsed a few multi-line function calls in test_main.py and detect-secrets refreshed .secrets.baseline line numbers; both are byproducts of the rename and contain no behavior change. Signed-off-by: Jonathan Springer <jps@s390x.com> * fix(security): hybrid admin-bypass policy after rebase onto main #4107 After rebasing PR #4341 onto main, the post-#4107 admin-bypass detection (is_admin_bypass_granted) interacted with this PR's invariant in a way the original commits did not anticipate. Resolved per Oracle red-team review using the same two-branch hybrid pattern that a2a_service._visible_agent_ids already established: (None, None) anonymous bypass sees public + team only; (email, None) DB-resolved admin additionally sees the caller's own private rows; another user's private is still denied. Applied in BaseService._apply_access_control, BaseService._apply_visibility_scope (the shared helper used by completion and tag services on main), and the per-resource _check_*_access methods in tool/prompt/resource/a2a services. A2A signature reconciliation: get_agent_by_name now correctly awaits _check_agent_access(db, agent, ...) (the post-#4107 async signature), and get_agent_card delegates its visibility gate up to main.handle_a2a_agent_card so the sync method does not need to call the async helper. Test updates: per-resource test_check_*_access_database_admin_bypass and test_check_agent_access_db_admin_bypass_only_with_unrestricted_token now assert the hybrid (own private allowed, another user's private denied); test_get_entities_by_tag_admin_bypass_sees_all_tagged renamed to _excludes_private and flipped to assert private rows are filtered (the #4106 invariant that the admin-bypass branch is reached at all is still tested implicitly via the compiled predicate); completion/tag _apply_visibility_scope tests now pass db= (required after main moved the helper into BaseService); test_agent_card_admin_bypass_denies_private now exercises _check_agent_access directly; test_get_request_identity_* / test_get_scoped_resource_access_context_* patches now target mcpgateway.auth_context (a latent bug from the auth_context refactor that the rebase surfaced). Verified: 1982 tests pass across test_main, test_main_extended, test_admin, and the 8 service test modules. Signed-off-by: Jonathan Springer <jps@s390x.com> * fix(security): close 6 admin-bypass leak paths flagged by review cycle 1 Followup to PR #4341 review cycle 1. Six independent leak paths or stale test patches were missed when commit 94efc94 ('extend admin-bypass private-resource denial across all access paths') was originally written; the cumulative diff after rebase still permitted admin-bypass reads of other users' private resources via several auxiliary surfaces. Each fix below was identified by parallel Oracle audits and is verified with a focused regression test. B1: a2a_server_service._check_server_access (mcpgateway/services/a2a_server_service.py:36-64). The (None, None) anonymous-bypass branch returned True for any visibility, including private. This was reachable from the internal Rust fallback paths in main.py:8842-8845 and 8915-8916, so a trusted internal admin context with email=null, teams=null could fetch another user's private virtual-server card. Fix: change the bypass branch to 'return server.visibility != "private"', mirroring the hybrid policy in a2a_service._check_agent_access. The (email, None) DB-admin shape falls through to the natural flow which already grants public + team + own-private and denies others' private. Test: test_admin_bypass_denies_private + test_db_admin_with_email_sees_own_private. B2: a2a_service._visible_agent_ids (mcpgateway/services/a2a_service.py:381-389). The bypass check used is_admin_bypass_granted(...), which matches both (None, None) AND the (email, None) DB-admin shape. The docstring stated only (None, None) should be unscoped, but the implementation didn't agree, so DB admins received an unscoped None and could enumerate other users' private agents via list_tasks and list_push_configs_for_dispatch. Fix: replace the helper call with an explicit '(user_email is None and token_teams is None)' literal. The (email, None) shape now falls through to the SQL filter at lines 391-405, which already enforces public + team + own-private. Test: test_db_admin_with_email_runs_filtered_query in TestVisibleAgentIds (uses install_admin_user fixture). B3: token_storage_service._refresh_access_token (mcpgateway/services/token_storage_service.py:205). Refresh fetched a Gateway by ID with no visibility check, then read and decrypted gateway.oauth_config['client_secret']. If a token's gateway_id pointed to a private gateway whose owner had since changed (or had always been someone else), the OAuth client_secret was decrypted and forwarded to a non-owner. Fix: refuse refresh when 'visibility == private and owner_email != token_record.app_user_email'. The check is intentionally narrow (private gateways only) so it does not require team-membership queries — broader RBAC happens at the call sites that issue refreshes. The token owner identity is app_user_email (the ContextForge user), not user_id (the OAuth provider's user id). Tests: test_refresh_denied_for_private_gateway_with_other_owner + test_refresh_allowed_for_private_gateway_owned_by_token_owner. B4: utils/gateway_access.check_gateway_access (mcpgateway/utils/gateway_access.py:80-87). Used 'if is_admin_bypass_granted(...): return True' which granted both anonymous AND DB-admin bypass on private gateways — pre-PR-#4341 semantics. Fix: replace with the same two-branch hybrid pattern used elsewhere — anonymous bypass returns 'visibility != private'; DB-admin bypass adds an own-private carve-out. Tests: flipped test_admin_bypass_denies_private (was test_admin_bypass_with_none_token_teams), added test_admin_bypass_sees_team, rewrote test_database_admin_bypass to cover the own/others split, rewrote test_platform_admin_bypass for the same split. B5: resource_service.list_resource_templates (mcpgateway/services/resource_service.py:4054-4072). The bespoke admin-bypass branch only handled (None, None) and let the (email, None) DB-admin shape fall through to the elif which required token_teams to not be None — net result was no WHERE clause applied and all private templates returned. Fix: add a second elif covering the (email, None) DB-admin shape, applying 'visibility != private OR (visibility == private AND owner_email == user_email)' — same predicate used in BaseService._apply_access_control. Test: test_list_resource_templates_db_admin_includes_own_private_only. B6: stale test patches against removed 'mcpgateway.main._get_*' / '_has_valid_*' symbols (tests/unit/mcpgateway/test_internal_a2a_endpoints.py). After commit 939a1eadd moved the helpers to mcpgateway.auth_context, six patch sites still referenced the old underscored names: lines 172, 177 (was _get_rpc_filter_context) and 1122, 1133, 1170, 1176 (was _has_valid_internal_mcp_runtime_auth_header). All six patches raised AttributeError at test collection time. Fix: rewrite to the public name as imported into main.py's namespace ('mcpgateway.main.get_rpc_filter_context' / 'mcpgateway.main.has_valid_internal_mcp_runtime_auth_header'). Verified: 8417 tests pass across services/, utils/test_gateway_access.py, test_internal_a2a_endpoints.py, test_main.py, test_main_extended.py, test_admin.py. New regression tests added for B1, B2, B3, B5; tests flipped from old to new policy for B1 (admin_bypass_sees_private → denies_private), B4 (admin_bypass_with_none_token_teams → denies_private + sees_team, database_admin_bypass split into own/others, platform_admin_bypass split into own/others). Signed-off-by: Jonathan Springer <jps@s390x.com> * fix(security)+test(security): close residual #4341 review-cycle-1 findings Followup to #1cc1fe65a (the review-cycle-1 blocker batch). The cycle-1 review identified six blockers (all fixed in #1cc1fe65a) plus two real-but-non-blocking bugs and several test-coverage gaps. This commit closes both remaining real bugs and the highest-impact coverage gaps. Real bugs: - POST /prompts/{id} returned 422 instead of 404 on PromptNotFoundError. PromptNotFoundError is a subclass of PromptError, so 'isinstance(ex, (ValueError, PromptError))' matched first and emitted 422 — different from the GET endpoint at line 6208 which correctly maps to 404. The status divergence creates an existence oracle: an attacker can probe arbitrary prompt names via POST and infer existence from the status code. Fixed at mcpgateway/main.py:6155-6160 by adding an explicit PromptNotFoundError check ahead of the broader PromptError branch, mirroring the GET endpoint. - tests/unit/mcpgateway/test_internal_a2a_endpoints.py:851-868 ('test_card_server_fallback_returns_200') patched _check_agent_access to False but expected 200 from the server-fallback path — without setting any visibility on the server, the test passed vacuously regardless of policy. Renamed to '_public_returns_200' and added a sibling 'test_card_server_fallback_admin_bypass_denies_private' that sets visibility=private + owner_email=other and asserts 404, exercising the actual fallback path through A2AServerService._check_server_access (the helper B1 fixed). Coverage gaps closed: - tests/unit/mcpgateway/services/test_base_service.py: 'test_db_admin_bypass_includes_own_private_only' compiles the actual SQLAlchemy WHERE for the (email, None) DB-admin shape and asserts visibility/private/owner_email/admin@example.com all appear — same pattern as the existing 'test_completion_apply_visibility_scope_admin_bypass_excludes_private'. Also added 'test_non_admin_with_email_but_null_token_teams_does_not_bypass' covering the negative case (non-admin (email, None) must fall through to TeamManagementService lookup, not the bypass branch). - tests/unit/mcpgateway/test_main.py: 'test_post_prompt_with_args_not_found_returns_404' regression test for the POST 422->404 fix above. - tests/unit/mcpgateway/test_main.py: 'test_get_tool_admin_bypass_private_returns_404' covers the documented 404-not-403 endpoint behavior. The pattern is intentionally focused (one representative endpoint) — extending to /servers, /gateways, /resources/{uri}/info follows the same shape and can be added by reviewers if desired. - tests/unit/mcpgateway/services/test_authorization_access.py: 'test_tool_access_denied_emits_structured_log_event' patches mcpgateway.services.tool_service.structured_logger and asserts the deny-event shape (event_type='tool_access_denied', resource_type, resource_id, custom_fields with visibility + admin_bypass) so the CHANGELOG forensics claim is regression-tested. - tests/unit/mcpgateway/services/test_authorization_access.py: 'test_invoke_tool_private_denial_runs_before_pre_invoke_hook' patches the plugin manager's tool_pre_invoke hook and asserts it is NOT called when the tool is private and the caller is admin-bypass. This protects the critical hook-order property: visibility deny gates plugin side effects (logging, metrics, billing) so plugins do not leak existence/usage information for resources the caller cannot see. Verified: 8424 tests pass across services/, utils/test_gateway_access.py, test_internal_a2a_endpoints.py, test_main.py, test_main_extended.py, test_admin.py (up from 8417 in #1cc1fe65a; +7 new regression tests, no existing tests broken). Signed-off-by: Jonathan Springer <jps@s390x.com> * refactor(security): harden hybrid call sites + restore get_agent_card in-service gate Two safety improvements identified by review-cycle-1 oracle audit (suggestions S5-a, S6-a) plus the privatization of an internal-only helper. No behavior change for the canonical hybrid policy; all changes prevent classes of future regressions. S5-a (inline is_user_admin): the second hybrid branch in tool/prompt/resource/a2a/base services and utils/gateway_access.check_gateway_access used 'is_admin_bypass_granted(db, user_email, token_teams)'. The preceding clauses ('token_teams is None and user_email and ...') already exclude the (None, None) anonymous-bypass shape, so the helper call is effectively 'is_user_admin(db, user_email)'. The broader name invites the exact misuse that produced the original B2 bug in _visible_agent_ids (where is_admin_bypass_granted matched both shapes and bypassed the per-agent SQL filter for DB admins). Inlined is_user_admin at all 8 call sites across 6 files. The only remaining caller of is_admin_bypass_granted is mcpgateway/main.py:5817 (SSE generator), where the broader semantics are intentional. S6-a (get_agent_card async + in-service gate): get_agent_card was the lone outlier among the visibility-gated service methods after the rebase fixup commit 17a79ff29 — it was a sync unscoped fetcher with the gate moved up to the call site in main.handle_internal_a2a_agent_card. Every other service (tool_service.get_tool, prompt_service.get_prompt_details, resource_service.get_resource_by_id, server_service.get_server, gateway_service.get_gateway, a2a_service.get_agent_by_name) gates inside the service. A future caller of get_agent_card who forgot the upstream gate would have re-introduced a private-agent leak. Made get_agent_card async, added user_email and token_teams parameters, and pushed the await self._check_agent_access(...) call back inside the method. Updated main.handle_internal_a2a_agent_card to call the new signature and removed its now-redundant explicit gate. Test patches updated to use new_callable=AsyncMock; two tests in test_a2a_service.py now await the call. Privatize has_verified_jwt_payload: the helper at mcpgateway/auth_context.py:369 was declared in the public surface of the module docstring but is only consumed inside auth_context.py itself (lines 400 and 438). Renamed to _has_verified_jwt_payload and moved into the private-surface section of the module docstring. No external callers. Verified: 8426 tests pass across services/, utils/test_gateway_access.py, test_internal_a2a_endpoints.py, test_main.py, test_main_extended.py, test_admin.py. No new test failures. Signed-off-by: Jonathan Springer <jps@s390x.com> * docs+test(security): tighten admin-bypass changelog + add endpoint 404 regressions S3-b: the cycle-1 review noted that the CHANGELOG promised a DB-admin own-private carve-out ("Resource owners can still access their own private resources, including DB-resolved admins viewing their own private rows") but that this carve-out is mostly unreachable from public HTTP routes because mcpgateway.auth_context.get_scoped_resource_access_context collapses HTTP admin requests to (None, None). Tightened the CHANGELOG paragraph to scope the carve-out claim to internal/non-HTTP call paths (Rust runtime hop, OAuth refresh, in-process service callers) and added an explicit note that HTTP admin requests are intentionally collapsed by design. Updated the 'What's unchanged' bullet to match. Endpoint coverage: added test_get_server_admin_bypass_private_returns_404 and test_get_gateway_admin_bypass_private_returns_404 to extend the pattern already in place for /tools/{id}. Each test mocks the service to raise the appropriate *NotFoundError and asserts that the route maps to 404 (not 403), preserving the deliberate existence-non-disclosure behavior documented in the CHANGELOG. Verified: 8426 tests pass across the touched suites. Signed-off-by: Jonathan Springer <jps@s390x.com> * fix+test+docs(security): cycle-2 review follow-ups (vacuous test, doc accuracy, test hardening) Addresses findings from PR #4341 cycle-2 multi-agent review. Two real bugs and several test/doc hardening items, all isolated to test files plus one CHANGELOG paragraph and the auth_context module docstring. Vacuous test fix: test_invoke_tool_private_denial_runs_before_pre_invoke_hook patched the wrong mock target. It set 'tool_service._plugin_manager = mock_plugin_manager' and asserted '.tool_pre_invoke.assert_not_called()', but production tool_service.invoke_tool resolves the manager via 'await self._get_plugin_manager(...)' and dispatches via 'plugin_manager.invoke_hook(ToolHookType.TOOL_PRE_INVOKE, ...)'. The mocked attribute and method were both unobserved, so the test passed for any hook ordering — including a regression that called the real hook chain on a denied tool. Rewrote to patch '_get_plugin_manager' to return a manager whose 'invoke_hook' method is the AsyncMock under observation. Same exact bug class (vacuous test) that B6 fixed earlier in this PR. Stale docstring at test_authorization_access.py:1169: said 'get_agent_card itself is the unscoped fetcher — visibility is the caller's responsibility'. After cycle-2's S6-a refactor, get_agent_card gates internally and returns None on deny. Rewrote the docstring to reflect the new in-service enforcement point. CHANGELOG OAuth refresh overclaim: the cycle-2 paragraph on the (email, None) DB-admin own-private carve-out listed 'OAuth token refresh' and 'in-process service callers' as paths that fire it. Verified via code review that token_storage_service._refresh_access_token uses a direct owner check (not the hybrid branch). Narrowed the claim to the single verified path (the trusted internal A2A endpoint via main._get_internal_a2a_scope_context), with explicit qualifiers on what would and would not exercise the carve-out elsewhere. auth_context docstring polish: the cycle-2 S7-a rewrite said 'get_current_user is the single exception' to the 'pure primitives' rule in auth.py, but auth.py also has _inject_userinfo_instate and _propagate_tenant_id which take Request and stash payload state. The same docstring claimed the module 'depends only on mcpgateway.auth' but it also imports 'mcpgateway.config'. Both claims softened to be accurate. Test hardening (per cycle-2 efficacy review): - test_db_admin_with_email_runs_filtered_query (a2a_service): now compiles the second '.filter()' call and asserts the visibility predicate scopes private rows to 'owner_email = caller'. Without this, a regression that re-introduced the unscoped-None path could pass the prior 'result is not None' assertion. - test_list_resource_templates_db_admin_includes_own_private_only (authorization_access): switched from 'admin@example.com' (which trips settings.platform_admin_email and never exercises the DB-resolved code path) to 'dba@test.com' with an explicit 'patch is_user_admin to True'. Also asserts the WHERE clause has exactly one OR — multiple ORs would indicate a too-broad predicate. - test_db_admin_bypass_includes_own_private_only (base_service): same hardening — exact predicate substrings + exactly one OR. - test_get_{tool,server,gateway}_admin_bypass_private_returns_404 (test_main): added 'mock_get.assert_called_once()' so the route test proves the route actually called the patched service rather than just asserting the status code. Verified: 8426 tests pass across services/, utils/test_gateway_access.py, test_internal_a2a_endpoints.py, test_main.py, test_main_extended.py, test_admin.py. Signed-off-by: Jonathan Springer <jps@s390x.com> * test(security): adapt 12 stale tests to PR #4341 surface changes Three classes of test breakage surfaced after running broader suites against the cycle-2 tip. None reflect a code regression — all are tests that referenced internal symbols renamed by the auth_context refactor, an admin endpoint signature that gained a Request parameter, or a token shape that PR #4341 deliberately stopped permitting. Class 1 (3 tests) — stale patches against renamed _get_* helpers, same class as cycle-1 B6: tests/unit/mcpgateway/test_main_helpers.py and test_main_helpers_extra.py. The auth_context refactor moved get_token_teams_from_request and get_rpc_filter_context out of main.py and dropped the leading underscore. Renamed all 10 references; main.py re-exports both names so the patch path still works. Class 2 (2 tests) — admin_get_server signature gained a Request parameter: tests/unit/mcpgateway/test_admin_module.py. PR #4341 added 'request: Request' to admin.admin_get_server so the route can resolve visibility scope via get_scoped_resource_access_context(request, user). Updated both call sites to pass _make_request() and broadened the _fake_get_server signatures with **_kwargs since admin_get_server now forwards user_email and token_teams kwargs. Class 3 (7 e2e tests) — visibility=private tripped the new public-only-token deny rule: tests/e2e/test_main_apis.py. Each test creates a resource with visibility=private then immediately reads/uses it via the same JWT. Because TEST_AUTH_HEADER uses generate_test_jwt() with default teams=[] and is_admin=False (public-only token shape), PR #4341 correctly denies private-row access even for own-private. The tests were pre-#4341 and used 'private' as a default that no longer permits the round trip. Switched all 7 to visibility=public so CRUD verification still works. Other tests in the same suites that exercise non-GET operations on private resources (test_create_server, test_update_server, test_set_server_state) are unchanged because their code paths don't pass through the visibility deny gate. Added inline comments documenting the policy boundary. Verified: 218 passed, 1 skipped across the 4 touched suites. All 12 original failures now green. Signed-off-by: Jonathan Springer <jps@s390x.com> * test(security): close diff-coverage gaps in PR #4341 hybrid policy helpers diff-cover surfaced five PR #4341 lines that no test exercised. All five are policy-critical paths in the new hybrid visibility helpers; without coverage, a regression that only broke one branch could ship undetected. Added 17 new tests across 4 files. Coverage gaps closed: - server_service.py:1022-1044 (was 35.7% diff coverage). New TestServerAccessCheckMatrix in test_authorization_access.py with 7 tests mirroring the TestCheckToolAccess pattern in test_tool_service.py. Each test names the production line(s) it covers (no-user-email deny, public-only token deny, own-private allow, JWT team match, DB team lookup, fall-through deny). - gateway_service.py:2734,2739-2761 (was 32.1%). New TestGatewayAccessCheckMatrix with the same 7-test shape; gateway and server helpers are sibling implementations of the canonical hybrid policy. - base_service.py:216 (was 90%). New test_apply_visibility_scope_db_admin_includes_own_private_only — the existing test_apply_visibility_scope_admin_bypass_excludes_private only covered (None, None); the (email, None) DB-admin branch was unexecuted. Uses the same exact-OR-count predicate guard as the other DB-admin tests so a too-broad predicate fails. - a2a_service.py:1155 (was 91.7%). New test_get_agent_card_returns_none_when_visibility_denies covers the deny path of the cycle-2 S6-a in-service gate. - auth_context.py:215 (was 99.1%). The non-object payload guard in decode_internal_mcp_auth_context had a test, but the test was named 'testdecode_*' (missing underscore separator) so pytest's default 'test_*' collection pattern rejected it — the assertion never ran. This is the same bug class as cycle-1 B6. Renamed to test_decode_internal_mcp_auth_context_rejects_non_object_payload with a docstring naming the bug class. Other testCASE-without-underscore names exist elsewhere (test_resource_service.py:2007/2024/2033, test_a2a_service.py:942, test_toolops_altk_service.py:59) but predate PR #4341 and are out of scope. Worth filing a separate cleanup issue. Verified: 8443 tests pass across services/, utils/test_gateway_access.py, test_internal_a2a_endpoints.py, test_main.py, test_main_extended.py, test_admin.py (+17 from this commit; +1 of those is the previously-broken testdecode_* now actually running). Signed-off-by: Jonathan Springer <jps@s390x.com> * test(security): adapt mcp_rbac e2e admin visibility test to PR #4341 HTTP collapse test_admin_sees_all_servers asserted that an admin-bypass JWT (is_admin=true, teams=null) could see ALL servers including private — pre-PR-#4341 'admin sees everything' semantics. After cycle-2 S3-b documented in CHANGELOG, get_scoped_resource_access_context deliberately collapses HTTP admin requests to (None, None) so HTTP cannot be a stealthy escalation surface. The service layer then applies the anonymous-bypass rule and denies private rows even when the requesting admin is the owner — the (email, None) DB-admin own-private carve-out only fires from internal callers (Rust runtime hop) where the email is preserved through the call chain. Renamed to test_admin_sees_public_and_team_via_http and updated assertions: explicit in-set checks for public and team, plus an explicit not-in for private. The error message on the not-in assertion names PR #4341 and the collapse mechanism so a future regression that re-introduced the leak via HTTP would surface a self-explanatory failure. Other tests in TestServerVisibilityViaAPI (test_team_member_sees_public_and_team, test_outsider_sees_only_public, test_team_admin_sees_public_and_team) already followed the new policy by asserting only what each role can see — this test was the lone outlier. Signed-off-by: Jonathan Springer <jps@s390x.com> --------- Signed-off-by: Bogdan-Marius-Catanus <bogdan-marius.catanus@ibm.com> Signed-off-by: Jonathan Springer <jps@s390x.com> Co-authored-by: Bogdan-Marius-Catanus <bogdan-marius.catanus@ibm.com> Co-authored-by: Jonathan Springer <jps@s390x.com>
1 parent feda268 commit 2f7ab34

38 files changed

Lines changed: 2825 additions & 986 deletions

.secrets.baseline

Lines changed: 50 additions & 50 deletions
Large diffs are not rendered by default.

CHANGELOG.md

Lines changed: 43 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -39,6 +39,49 @@
3939

4040
`CONTENT_PATTERN_DETECTION_ENABLED=true` and `CONTENT_VALIDATE_PROMPT_TEMPLATES=true` ship as defaults (in contrast to `CONTENT_STRICT_MIME_VALIDATION=false` which had a soft-launch default for US-2). Existing deployments containing any of the default blocked patterns in stored resources or prompts (e.g. Jinja2 `{{ config }}` access, shell metacharacters, `UNION SELECT`) will start returning 400s on subsequent update calls. Set either flag to `false` temporarily to audit and clean existing content before re-enabling.
4141

42+
#### **🔒 Admin bypass no longer reveals other users' private resources** ([#4323](https://github.com/IBM/mcp-context-forge/issues/4323), [#4341](https://github.com/IBM/mcp-context-forge/pull/4341))
43+
44+
**Action Required for integrators relying on admin-bypass reads of other users' private resources.**
45+
46+
Admin bypass (`is_admin=true` with `teams: null` in the JWT, or dev-mode basic-auth admin) now grants access only to **public** and **team** resources via the public HTTP routes. Another user's private resources (visibility=`private`) are only accessible to their owner — admin bypass can no longer read, update, delete, list, or enumerate another user's private tools, prompts, resources, servers, gateways, or A2A agents.
47+
48+
The service layer additionally implements an own-private carve-out for the DB-resolved admin shape `(email, None)`: a session that resolves to admin in the database AND has not been narrowed by a token scope can still access its own private rows. The verified path that exercises this carve-out today is the trusted internal A2A endpoint (`mcpgateway/main.py::_get_internal_a2a_scope_context`), which forwards the admin email through to the service layer. Other internal/in-process callers will hit the same carve-out *only* if they preserve the email on the `(email, None)` shape; OAuth token refresh, for example, performs its own owner check at `token_storage_service._refresh_access_token` and does not exercise the hybrid branch. The carve-out is **not** reachable from the public HTTP routes: `mcpgateway.auth_context.get_scoped_resource_access_context` collapses HTTP admin requests to `(None, None)`, so a normal browser-driven admin gets the same anonymous-bypass treatment as everyone else and is denied their own private rows on `GET /tools/{my_private_tool}`. Use `team`-scoped tokens or own-the-resource workflows if you need a HTTP admin to see their own private rows directly.
49+
50+
**Enforcement applied at the service layer for:**
51+
52+
- `ToolService.get_tool`, `list_tools`
53+
- `PromptService.get_prompt`, `get_prompt_details`, `list_prompts`
54+
- `ResourceService.get_resource_by_id`, `read_resource`, `list_resources`
55+
- `ServerService.get_server`, `list_servers`
56+
- `GatewayService.get_gateway`, `list_gateways`
57+
- `A2AAgentService.get_agent`, `get_agent_by_name`, `get_agent_card`, `cancel_task`, `get_task`
58+
- `BaseService._apply_access_control` and `BaseService._apply_visibility_scope` (list endpoints inheriting from `BaseService`, plus completion / tag enumeration)
59+
60+
**Behavior for denied access:**
61+
62+
- Direct-ID reads return `404 Not Found` (not 403) to avoid disclosing the existence of private resources.
63+
- A structured log event (`*_access_denied`, e.g. `tool_access_denied`) is emitted for forensics.
64+
65+
**What's unchanged:**
66+
67+
- Public-resource access for admin bypass — unchanged.
68+
- Team-resource access for admin bypass — unchanged.
69+
- Resource owners can still access their own private resources via owner-email matching at the service layer.
70+
- DB-resolved admin sessions can still see their own private rows via internal / non-HTTP call paths that preserve the admin email on a `(email, None)` shape — the trusted internal A2A endpoint is the verified example today. HTTP admin requests are intentionally collapsed to `(None, None)` and do not fire the own-private carve-out — by design, to avoid HTTP being a stealthy escalation surface.
71+
- Scoped tokens (`teams: [...]`) continue to use their scoped team list; admin-bypass detection still requires both `is_admin=true` **and** `teams: null`.
72+
73+
**Migration guidance for integrators:**
74+
75+
- Audit tokens/scripts that currently rely on an admin listing or reading another user's private data. Transfer resource ownership or switch them to `team`-scoped visibility if the cross-user access is intentional.
76+
- If an admin genuinely needs cross-user visibility for an operational scenario, prefer a properly scoped token (`teams: ["<target_team>"]`) over relying on bypass.
77+
- Callers of `server_service.get_server`, `gateway_service.get_gateway`, `prompt_service.get_prompt_details`, `resource_service.get_resource_by_id`, and `a2a_service.get_agent_by_name`/`get_agent_card` now accept new optional `user_email` / `token_teams` parameters. Omitting them evaluates as admin-bypass (public + team access, other-users' private denied). Call sites in `mcpgateway/main.py` and `mcpgateway/admin.py` have been updated to forward the caller's scope via `get_scoped_resource_access_context` (now in `mcpgateway/auth_context.py`).
78+
79+
**Related security invariants (see `AGENTS.md`):**
80+
81+
- `public` is platform-public scope, not internet-anonymous.
82+
- Token-team interpretation continues to flow through `normalize_token_teams()` / `resolve_session_teams()` in `mcpgateway/auth.py`.
83+
- Non-JWT admin (basic-auth / dev-mode) retains unrestricted access to public and team resources, but is now also denied direct reads of other users' private resources.
84+
4285
## [1.0.0-RC3] - 2026-04-14 - Auth Hardening, Plugin Multi-Tenancy, Rust Runtime & Multi-Arch
4386

4487
### Overview

docs/docs/architecture/multitenancy.md

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -289,7 +289,8 @@ Applies to Tools, Servers, Resources, Prompts, and A2A Agents. All resources are
289289
- Who sees it: Only the resource owner (owner_email), and only when their token is not a public-only token (token_teams must be non-empty).
290290
- Team members cannot see or use it unless they are the owner.
291291
- Public-only tokens (token_teams=[]) cannot access private resources even as the owner.
292-
- Mutations: Owner and Platform Admin can update/delete; team owners may be allowed by policy (see Enhancements).
292+
- Platform Admin bypass (token teams=null, is_admin=true) does NOT grant visibility or mutation access to another user's private resources (see [#4341](https://github.com/IBM/mcp-context-forge/pull/4341)). Since direct-ID reads return 404 under bypass, update/delete of another user's private resource also fails because the read-before-write gate hides the resource.
293+
- Mutations: Only the owner. If cross-user administrative access is required, prefer changing visibility to `team` or using a properly scoped token; admin bypass alone is insufficient.
293294

294295
- Team:
295296

docs/docs/manage/rbac.md

Lines changed: 5 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -317,16 +317,19 @@ Resources in ContextForge have three visibility levels:
317317
|------------|-------------|-------------|
318318
| `public` | Accessible to all authenticated users | Everyone with valid token |
319319
| `team` | Accessible to team members only | Team members + admins (with bypass) |
320-
| `private` | Accessible to owner only | Resource owner + admins (with bypass) |
320+
| `private` | Accessible to owner only | Resource owner only — **never** admin bypass |
321321

322322
### Access Matrix by Token Type
323323

324324
| Token Type | Public Resources | Team Resources | Private Resources |
325325
|------------|-----------------|----------------|-------------------|
326-
| Admin Bypass (`teams=null`, `is_admin=true`) || ✅ (all teams) | ✅ (all) |
326+
| Admin Bypass (`teams=null`, `is_admin=true`) || ✅ (all teams) | ❌ (owner-only, see note) |
327327
| Team-Scoped (`teams=["t1"]`) || ✅ (own team) | ✅ (own only) |
328328
| Public-Only (`teams=[]`) ||||
329329

330+
!!! warning "Admin Bypass Does Not Include Other Users' Private Resources"
331+
Since [#4341](https://github.com/IBM/mcp-context-forge/pull/4341), admin bypass **cannot** read, list, update, or delete another user's private resources. Private resources (visibility=`private`) are strictly owner-scoped. If cross-user access is intentional, prefer `team` visibility or a scoped token over relying on bypass. See `docs/architecture/multitenancy.md` for the canonical multi-tenancy model.
332+
330333
!!! warning "Public-Only Token Limitations"
331334
**Public-only tokens (`teams=[]`) cannot access private resources, even if the resource is owned by the token's user.**
332335

mcpgateway/admin.py

Lines changed: 40 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -66,6 +66,7 @@
6666

6767
# Authentication and password-related imports
6868
from mcpgateway.auth import get_current_user, get_user_team_roles
69+
from mcpgateway.auth_context import get_scoped_resource_access_context
6970
from mcpgateway.cache.a2a_stats_cache import a2a_stats_cache
7071
from mcpgateway.cache.global_config_cache import global_config_cache
7172
from mcpgateway.common.models import LogLevel
@@ -2796,12 +2797,13 @@ async def admin_servers_partial_html(
27962797

27972798
@admin_router.get("/servers/{server_id}", response_model=ServerRead)
27982799
@require_permission("servers.read", allow_admin_bypass=False)
2799-
async def admin_get_server(server_id: str, db: Session = Depends(get_db), user=Depends(get_current_user_with_permissions)) -> Dict[str, Any]:
2800+
async def admin_get_server(server_id: str, request: Request, db: Session = Depends(get_db), user=Depends(get_current_user_with_permissions)) -> Dict[str, Any]:
28002801
"""
28012802
Retrieve server details for the admin UI.
28022803

28032804
Args:
28042805
server_id (str): The ID of the server to retrieve.
2806+
request (Request): Incoming FastAPI request (for visibility scope resolution).
28052807
db (Session): The database session dependency.
28062808
user (str): The authenticated user dependency.
28072809

@@ -2820,7 +2822,8 @@ async def admin_get_server(server_id: str, db: Session = Depends(get_db), user=D
28202822
"""
28212823
try:
28222824
LOGGER.debug(f"User {get_user_email(user)} requested details for server ID {server_id}")
2823-
server = await server_service.get_server(db, server_id)
2825+
auth_user_email, auth_token_teams = get_scoped_resource_access_context(request, user)
2826+
server = await server_service.get_server(db, server_id, user_email=auth_user_email, token_teams=auth_token_teams)
28242827
return server.masked().model_dump(by_alias=True)
28252828
except ServerNotFoundError as e:
28262829
raise HTTPException(status_code=404, detail=str(e))
@@ -11312,7 +11315,7 @@ async def _safe_entity_search(search_callable, empty_key: str, **kwargs: Any) ->
1131211315

1131311316
@admin_router.get("/tools/{tool_id}", response_model=ToolRead)
1131411317
@require_permission("tools.read", allow_admin_bypass=False)
11315-
async def admin_get_tool(tool_id: str, db: Session = Depends(get_db), user=Depends(get_current_user_with_permissions)) -> Dict[str, Any]:
11318+
async def admin_get_tool(tool_id: str, request: Request, db: Session = Depends(get_db), user=Depends(get_current_user_with_permissions)) -> Dict[str, Any]:
1131611319
"""
1131711320
Retrieve specific tool details for the admin UI.
1131811321

@@ -11322,6 +11325,7 @@ async def admin_get_tool(tool_id: str, db: Session = Depends(get_db), user=Depen
1132211325

1132311326
Args:
1132411327
tool_id (str): The ID of the tool to retrieve.
11328+
request (Request): Incoming FastAPI request (for visibility scope resolution).
1132511329
db (Session): Database session dependency.
1132611330
user (str): Authenticated user dependency.
1132711331

@@ -11339,11 +11343,19 @@ async def admin_get_tool(tool_id: str, db: Session = Depends(get_db), user=Depen
1133911343
'admin_get_tool'
1134011344
"""
1134111345
LOGGER.debug(f"User {get_user_email(user)} requested details for tool ID {tool_id}")
11346+
auth_user_email, auth_token_teams = get_scoped_resource_access_context(request, user)
1134211347
_user_email = get_user_email(user)
1134311348
_is_admin = bool(user.get("is_admin", False) if isinstance(user, dict) else getattr(user, "is_admin", False))
1134411349
_team_roles = _get_user_team_roles(db, _user_email) if not _is_admin else {}
1134511350
try:
11346-
tool = await tool_service.get_tool(db, tool_id, requesting_user_email=_user_email, requesting_user_is_admin=_is_admin, requesting_user_team_roles=_team_roles)
11351+
tool = await tool_service.get_tool(
11352+
db,
11353+
tool_id,
11354+
requesting_user_email=auth_user_email,
11355+
requesting_user_is_admin=_is_admin,
11356+
requesting_user_team_roles=_team_roles,
11357+
token_teams=auth_token_teams,
11358+
)
1134711359
return tool.model_dump(by_alias=True)
1134811360
except ToolNotFoundError as e:
1134911361
raise HTTPException(status_code=404, detail=str(e))
@@ -11945,11 +11957,12 @@ async def admin_set_tool_state(
1194511957

1194611958
@admin_router.get("/gateways/{gateway_id}", response_model=GatewayRead)
1194711959
@require_permission("gateways.read", allow_admin_bypass=False)
11948-
async def admin_get_gateway(gateway_id: str, db: Session = Depends(get_db), user=Depends(get_current_user_with_permissions)) -> Dict[str, Any]:
11960+
async def admin_get_gateway(gateway_id: str, request: Request, db: Session = Depends(get_db), user=Depends(get_current_user_with_permissions)) -> Dict[str, Any]:
1194911961
"""Get gateway details for the admin UI.
1195011962

1195111963
Args:
1195211964
gateway_id: Gateway ID.
11965+
request: Incoming FastAPI request (for visibility scope resolution).
1195311966
db: Database session.
1195411967
user: Authenticated user.
1195511968

@@ -11968,7 +11981,8 @@ async def admin_get_gateway(gateway_id: str, db: Session = Depends(get_db), user
1196811981
"""
1196911982
LOGGER.debug(f"User {get_user_email(user)} requested details for gateway ID {gateway_id}")
1197011983
try:
11971-
gateway = await gateway_service.get_gateway(db, gateway_id)
11984+
auth_user_email, auth_token_teams = get_scoped_resource_access_context(request, user)
11985+
gateway = await gateway_service.get_gateway(db, gateway_id, user_email=auth_user_email, token_teams=auth_token_teams)
1197211986
return gateway.model_dump(by_alias=True)
1197311987
except GatewayNotFoundError as e:
1197411988
raise HTTPException(status_code=404, detail=str(e))
@@ -12544,11 +12558,12 @@ async def admin_test_resource(resource_uri: str, db: Session = Depends(get_db),
1254412558

1254512559
@admin_router.get("/resources/{resource_id}")
1254612560
@require_permission("resources.read", allow_admin_bypass=False)
12547-
async def admin_get_resource(resource_id: str, db: Session = Depends(get_db), user=Depends(get_current_user_with_permissions)) -> Dict[str, Any]:
12561+
async def admin_get_resource(resource_id: str, request: Request, db: Session = Depends(get_db), user=Depends(get_current_user_with_permissions)) -> Dict[str, Any]:
1254812562
"""Get resource details for the admin UI.
1254912563

1255012564
Args:
1255112565
resource_id: Resource ID.
12566+
request: Incoming FastAPI request (for visibility scope resolution).
1255212567
db: Database session.
1255312568
user: Authenticated user.
1255412569

@@ -12567,9 +12582,15 @@ async def admin_get_resource(resource_id: str, db: Session = Depends(get_db), us
1256712582
"""
1256812583
LOGGER.debug(f"User {get_user_email(user)} requested details for resource ID {resource_id}")
1256912584
try:
12570-
resource = await resource_service.get_resource_by_id(db, resource_id, include_inactive=True)
12571-
# content = await resource_service.read_resource(db, resource_id=resource_id)
12572-
return {"resource": resource.model_dump(by_alias=True)} # , "content": None}
12585+
auth_user_email, auth_token_teams = get_scoped_resource_access_context(request, user)
12586+
resource = await resource_service.get_resource_by_id(
12587+
db,
12588+
resource_id,
12589+
include_inactive=True,
12590+
user_email=auth_user_email,
12591+
token_teams=auth_token_teams,
12592+
)
12593+
return {"resource": resource.model_dump(by_alias=True)}
1257312594
except ResourceNotFoundError as e:
1257412595
raise HTTPException(status_code=404, detail=str(e))
1257512596
except Exception as e:
@@ -12942,11 +12963,12 @@ async def admin_set_resource_state(
1294212963

1294312964
@admin_router.get("/prompts/{prompt_id}")
1294412965
@require_permission("prompts.read", allow_admin_bypass=False)
12945-
async def admin_get_prompt(prompt_id: str, db: Session = Depends(get_db), user=Depends(get_current_user_with_permissions)) -> Dict[str, Any]:
12966+
async def admin_get_prompt(prompt_id: str, request: Request, db: Session = Depends(get_db), user=Depends(get_current_user_with_permissions)) -> Dict[str, Any]:
1294612967
"""Get prompt details for the admin UI.
1294712968

1294812969
Args:
1294912970
prompt_id: Prompt ID.
12971+
request: Incoming FastAPI request (for visibility scope resolution).
1295012972
db: Database session.
1295112973
user: Authenticated user.
1295212974

@@ -12965,7 +12987,13 @@ async def admin_get_prompt(prompt_id: str, db: Session = Depends(get_db), user=D
1296512987
"""
1296612988
LOGGER.info(f"User {get_user_email(user)} requested details for prompt ID {prompt_id}")
1296712989
try:
12968-
prompt_details = await prompt_service.get_prompt_details(db, prompt_id)
12990+
auth_user_email, auth_token_teams = get_scoped_resource_access_context(request, user)
12991+
prompt_details = await prompt_service.get_prompt_details(
12992+
db,
12993+
prompt_id,
12994+
user_email=auth_user_email,
12995+
token_teams=auth_token_teams,
12996+
)
1296912997
prompt = PromptRead.model_validate(prompt_details)
1297012998
return prompt.model_dump(by_alias=True)
1297112999
except PromptNotFoundError as e:

mcpgateway/auth.py

Lines changed: 55 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -4,10 +4,61 @@
44
SPDX-License-Identifier: Apache-2.0
55
Authors: Mihai Criveti
66
7-
Shared authentication utilities.
8-
9-
This module provides common authentication functions that can be shared
10-
across different parts of the application without creating circular imports.
7+
Authentication primitives: JWT, sessions, API tokens, and team membership.
8+
9+
Purpose (for future implementers)
10+
---------------------------------
11+
``mcpgateway`` separates authentication concerns into two modules:
12+
13+
- ``mcpgateway.auth`` (this module) - the **token / session / team model
14+
layer**. Helpers here operate on stored artifacts: JWT payloads, session
15+
records, API tokens, revocation records, and team-membership rows. They
16+
return DB-shaped or dict results and never take a FastAPI ``Request``.
17+
Because they are pure in that sense, they can be reused from any context
18+
(tests, background tasks, transport hops, RBAC middleware).
19+
20+
- ``mcpgateway.auth_context`` - the **per-request resolution layer**. Helpers
21+
there take a FastAPI ``Request`` plus the ``user`` produced by the auth
22+
dependency and compute what the caller is allowed to see on this request
23+
(Layer 1 visibility context). See ``auth_context.py`` for its purpose block
24+
and the ``AGENTS.md`` "Authentication & RBAC Overview" section for the
25+
two-layer policy model.
26+
27+
Rule of thumb
28+
-------------
29+
- Input is a ``Request``? -> belongs in ``auth_context.py``.
30+
- Input is a JWT payload, user email, token hash, or team ID? -> belongs here.
31+
32+
Public surface
33+
--------------
34+
The names below form this module's stable public API. ``main.py``, routers,
35+
transports, middleware, and tests all consume them.
36+
37+
User and session resolution
38+
get_current_user(...) - FastAPI dependency
39+
get_user_team_roles(db, email) -> dict[team_id, role]
40+
41+
Token claim normalization (canonical per AGENTS.md)
42+
normalize_token_teams(payload) -> list[str] | None
43+
resolve_session_teams(...) -> list[str] | None
44+
45+
Private-but-cross-module surface
46+
--------------------------------
47+
A few leading-underscore helpers are intentionally imported by other modules
48+
for a specific reason: they are the **synchronous variants** of async DB
49+
lookups, wrapped in ``asyncio.to_thread`` by callers in FastAPI hot paths.
50+
The underscore is a convention marker that says "prefer the async wrapper;
51+
if you must go sync, use this one" - not a "don't import" signal. The
52+
callers (``main.py``, ``transports/streamablehttp_transport.py``,
53+
``utils/verify_credentials.py``) all follow the same ``asyncio.to_thread``
54+
pattern.
55+
56+
_check_token_revoked_sync(jti) -> bool
57+
_lookup_api_token_sync(token_hash) -> dict | None
58+
59+
If you are tempted to import any other underscore-prefixed name from this
60+
module, stop and ask whether the caller should really go through a public
61+
wrapper or whether the helper genuinely deserves promotion to the public API.
1162
"""
1263

1364
# Standard

0 commit comments

Comments
 (0)