Skip to content

Conversation

@crivetimihai
Copy link
Member

@crivetimihai crivetimihai commented Jan 9, 2026

Previously, tools/list, resources/list, and prompts/list operations returned unfiltered results regardless of the authenticated token's team scope. This allowed public access tokens to see private resources they shouldn't have access to.

Token Scoping Security Model

Unified Token Teams Semantics

All enforcement layers (middleware, REST endpoints, RPC, /mcp transport) now follow consistent rules:

┌─────────────────────────────────────────────────────────────────────────────┐
│                        Token Teams Claim Handling                           │
├─────────────────────────────────────────────────────────────────────────────┤
│  JWT Claim State          │  Admin User           │  Non-Admin User         │
├───────────────────────────┼───────────────────────┼─────────────────────────┤
│  No "teams" key           │  UNRESTRICTED         │  PUBLIC-ONLY (secure)   │
│  teams: null              │  UNRESTRICTED         │  PUBLIC-ONLY (secure)   │
│  teams: []                │  PUBLIC-ONLY          │  PUBLIC-ONLY            │
│  teams: ["team-id-1"]     │  Team + Public        │  Team + Public          │
└───────────────────────────┴───────────────────────┴─────────────────────────┘

Security Design Principles

  1. Principle of Least Privilege: Non-admin tokens without explicit team scope default to public-only access
  2. Scoped Automation Tokens: Admin tokens with teams: [] are intentionally restricted to public resources (for CI/CD, monitoring)
  3. Backward Compatible Admin Access: Admin session tokens (from UI login) omit the teams claim entirely, granting unrestricted access

Token Scoping Flow

                                 ┌──────────────────┐
                                 │   JWT Token      │
                                 │   Received       │
                                 └────────┬─────────┘
                                          │
                                          ▼
                              ┌───────────────────────┐
                              │  Extract "teams"      │
                              │  claim from JWT       │
                              └───────────┬───────────┘
                                          │
                          ┌───────────────┴───────────────┐
                          │                               │
                          ▼                               ▼
               ┌─────────────────────┐       ┌─────────────────────┐
               │ "teams" key exists  │       │ "teams" key missing │
               │ with non-null value │       │ OR teams: null      │
               └──────────┬──────────┘       └──────────┬──────────┘
                          │                             │
                          ▼                             ▼
               ┌─────────────────────┐       ┌─────────────────────┐
               │ Use explicit scope  │       │ Check is_admin flag │
               │ (may be empty [])   │       └──────────┬──────────┘
               └──────────┬──────────┘                  │
                          │                 ┌───────────┴───────────┐
                          │                 │                       │
                          │                 ▼                       ▼
                          │      ┌──────────────────┐   ┌──────────────────┐
                          │      │ Admin: token_    │   │ Non-Admin:       │
                          │      │ teams = None     │   │ token_teams = [] │
                          │      │ (UNRESTRICTED)   │   │ (PUBLIC-ONLY)    │
                          │      └────────┬─────────┘   └────────┬─────────┘
                          │               │                      │
                          └───────────────┴──────────────────────┘
                                          │
                                          ▼
                              ┌───────────────────────┐
                              │   Apply visibility    │
                              │   filter to query     │
                              └───────────────────────┘

Enforcement Points Updated

  • Token Scoping Middleware (token_scoping.py): Request-level access control
  • REST Endpoints (main.py): /tools, /resources, /prompts, /servers/{id}/*
  • RPC Handler (main.py): tools/list, resources/list, prompts/list, legacy list_tools
  • Streamable HTTP Transport (streamablehttp_transport.py): MCP protocol filtering
  • Service Layer (tool_service.py, resource_service.py, prompt_service.py): Query filtering

MCP List Visibility Fix

Changes:

  • Add token_teams parameter to service list functions (tool_service, resource_service, prompt_service) to filter by token scope instead of DB team membership lookup
  • Add helper functions in main.py to extract and normalize token teams from JWT payloads (_normalize_token_teams, _get_token_teams_from_request, _get_rpc_filter_context)
  • Wire /rpc and /servers/{id}/tools|resources|prompts endpoints to use token-based filtering
  • Fix SSE token forwarding to read auth from cookies (for admin UI) and preserve is_admin status in fallback tokens
  • Update streamable HTTP auth to check both top-level and nested is_admin in JWT payloads
  • Admin session tokens now omit teams claim to enable unrestricted access bypass
  • Non-admin tokens without teams default to public-only (secure default)

Token Details Modal & Scope Display

Added enhanced token visibility in the Admin UI at /admin/#tokens:

Token List Enhancements:

  • Team badge showing team name (if team-scoped)
  • IP restrictions count badge (e.g., "2 IP restrictions")
  • New "Details" button for full token information

Token Details Modal:

  • Basic Info: ID (with copy button), name, description, creator, team, dates, status
  • Scope & Restrictions: server ID, permissions list, IP restrictions, time restrictions, usage limits
  • Tags section
  • Revocation details (for revoked tokens)

Input Validation

Added comprehensive validation for token scope fields:

Frontend Validation (admin.js):

  • isValidIpOrCidr() - Validates IPv4/IPv6 addresses and CIDR notation using regex
  • isValidPermission() - Validates resource.action format or wildcard *
  • Immediate feedback before API call with clear error messages

Backend Validation (schemas.py):

  • TokenScopeRequest.validate_ip_restrictions() - Uses Python's ipaddress module for robust validation
  • TokenScopeRequest.validate_permissions() - Regex pattern matching for resource.action format

Validation Examples:

  • Valid IPs: 192.168.1.1, 10.0.0.0/8, ::1, 2001:db8::/32
  • Invalid IPs: sfas, 192.168.1.256, 192.168.1.0/33
  • Valid permissions: tools.read, resources.write, *
  • Invalid permissions: read, tools., 123.read

Security Improvements

  • Replaced inline onclick handlers with event delegation pattern to prevent XSS
  • Added extractApiError() helper to properly parse Pydantic validation error arrays
  • Added parseErrorResponse() helper to handle non-JSON error responses (500 errors)
  • One-time guard pattern for event handlers to prevent handler stacking on re-renders
  • Consistent token scoping across all enforcement layers
  • Secure default: non-admin tokens without teams = public-only

Closes #1915

Previously, tools/list, resources/list, and prompts/list operations
returned unfiltered results regardless of the authenticated token's
team scope. This allowed public access tokens to see private resources
they shouldn't have access to.

Changes:
- Add token_teams parameter to service list functions (tool_service,
  resource_service, prompt_service) to filter by token scope instead
  of DB team membership lookup
- Add helper functions in main.py to extract and normalize token teams
  from JWT payloads (_normalize_token_teams, _get_token_teams_from_request,
  _get_rpc_filter_context)
- Wire /rpc and /servers/{id}/tools|resources|prompts endpoints to use
  token-based filtering
- Fix SSE token forwarding to read auth from cookies (for admin UI) and
  preserve is_admin status in fallback tokens
- Update streamable HTTP auth to check both top-level and nested is_admin
  in JWT payloads
- Update token_scoping middleware to allow empty-team tokens access to
  owned resources (own + public policy)
- Update admin UI message from "public-only access" to "your own resources
  and public resources only"

The empty-team token policy is now "own + public" - users with tokens
that have no teams can see their own resources plus public resources,
not just public resources.

Closes #1915

Signed-off-by: Mihai Criveti <[email protected]>
@crivetimihai
Copy link
Member Author

Test plan

1. Reproduce Original Issue (Before Fix)

  • Create a user with team memberships (e.g., team_a, team_b)
  • Create tools/resources/prompts with different visibilities:
    • Public tool owned by another user
    • Private tool owned by another user in team_c
    • Team-scoped tool in team_a
    • Private tool owned by test user
  • Generate a "public access token" (empty teams array) for the user
  • Call tools/list via /rpc with the public token
  • Expected (before fix): All tools visible regardless of token scope
  • Expected (after fix): Only public tools + user's own tools visible

2. Token Team Scope Filtering

2.1 Tools List (tools/list)

  • Token with teams: ["team_a"] sees: team_a tools + public tools + owned tools
  • Token with teams: ["team_a", "team_b"] sees: team_a + team_b + public + owned
  • Token with teams: [] (empty) sees: public tools + owned tools only
  • Token with teams: null or missing teams key triggers DB lookup (full membership)

2.2 Resources List (resources/list)

  • Token with teams: ["team_a"] sees: team_a resources + public + owned
  • Token with teams: [] sees: public resources + owned resources only

2.3 Prompts List (prompts/list)

  • Token with teams: ["team_a"] sees: team_a prompts + public + owned
  • Token with teams: [] sees: public prompts + owned prompts only

3. Admin Bypass

  • Admin user with is_admin: true in token sees ALL resources (no filtering)
  • Admin user with empty teams still sees all resources
  • Verify both top-level is_admin and nested user.is_admin are recognized

4. Empty-Team Token ("Own + Public" Policy)

4.1 List Operations

  • Empty-team token lists public resources
  • Empty-team token lists user's own private resources
  • Empty-team token does NOT list other users' private resources
  • Empty-team token does NOT list team-scoped resources (unless owned)

4.2 Access by ID (Token Scoping Middleware)

  • Empty-team token can GET /tools/{id} for owned private tool
  • Empty-team token can GET /tools/{id} for public tool
  • Empty-team token CANNOT GET /tools/{id} for other user's private tool (403)
  • Empty-team token CANNOT GET /tools/{id} for team-scoped tool not owned (403)
  • Same checks for /resources/{id}, /prompts/{id}, /servers/{id}

5. SSE Endpoints (Cookie Auth)

5.1 Admin UI Session

  • Login to admin UI (sets jwt_token or access_token cookie)
  • Connect to /servers/{id}/sse endpoint
  • Verify SSE connection works with cookie auth (no Authorization header)
  • Verify admin users retain full access via SSE

5.2 Token Forwarding

  • SSE endpoint extracts token from Authorization header (Bearer)
  • SSE endpoint falls back to jwt_token cookie
  • SSE endpoint falls back to access_token cookie
  • Fallback token preserves is_admin status
  • Fallback token preserves token_teams scope

6. Streamable HTTP (/mcp Endpoints)

  • /servers/{id}/mcp respects token team scope for tools/list
  • /servers/{id}/mcp respects token team scope for resources/list
  • /servers/{id}/mcp respects token team scope for prompts/list
  • Admin users see all resources via /mcp endpoints
  • Verify is_admin is read from both top-level and user.is_admin

7. Server-Specific Endpoints

  • GET /servers/{id}/tools filters by token scope
  • GET /servers/{id}/resources filters by token scope
  • GET /servers/{id}/prompts filters by token scope
  • Include both active and inactive resources when include_inactive=true

8. Edge Cases

8.1 Token Team Format Normalization

  • Teams as string array: ["team_a", "team_b"] works
  • Teams as dict array: [{"id": "team_a", "name": "Team A"}] normalizes to IDs
  • Mixed format: [{"id": "t1"}, "t2"] normalizes correctly
  • Dict without id key is skipped
  • Empty string ID is skipped

8.2 Non-JWT Authentication

  • Plugin auth (no JWT payload) triggers DB team lookup
  • Basic auth triggers DB team lookup
  • Proxy auth with X-Forwarded-User works correctly

8.3 Visibility Values

  • visibility: "public" accessible by all tokens
  • visibility: "team" accessible by team members + owners
  • visibility: "private" accessible by team members + owners
  • visibility: "user" accessible by owner only
  • Unknown visibility defaults to denied

9. UI Verification

  • Admin UI "Token Scope" section shows correct message
  • Message reads "your own resources and public resources only" for empty-team tokens
  • Token generation UI reflects the scope limitation

10. Automated Tests

# Helper function tests
pytest tests/unit/mcpgateway/test_main.py::TestNormalizeTokenTeams -v
pytest tests/unit/mcpgateway/test_main.py::TestGetTokenTeamsFromRequest -v
pytest tests/unit/mcpgateway/test_main.py::TestGetRpcFilterContext -v

# Service token_teams filtering tests
pytest tests/unit/mcpgateway/services/test_tool_service.py::TestToolServiceTokenTeamsFiltering -v

# Streamable HTTP auth tests
pytest tests/unit/mcpgateway/transports/test_streamablehttp_transport.py -k "token_teams or is_admin" -v

# Full test suite
pytest tests/unit/mcpgateway/test_main.py tests/unit/mcpgateway/test_main_extended.py -v

11. Regression Checks

  • Existing team-scoped tokens continue to work as before
  • Full-access tokens (all teams) see all appropriate resources
  • No performance regression on list operations
  • WebSocket endpoints unaffected
  • Tool execution (tools/call) unaffected by list filtering changes

Signed-off-by: Mihai Criveti <[email protected]>
Signed-off-by: Mihai Criveti <[email protected]>
@crivetimihai crivetimihai marked this pull request as ready for review January 10, 2026 00:16
Security improvements to token team scoping:

- Unify teams claim semantics: missing key or null = unrestricted for admin,
  public-only for non-admin (secure default)
- Admin bypass now requires token_teams is None (not just is_admin flag)
- Admin with teams: [] explicitly scoped to public-only resources
- Fix inconsistency where /rpc treated missing teams differently than REST
- Update session token generation to omit teams for admin users (unrestricted)
- Fix all enforcement points: middleware, main.py, services, transports

Token scoping rules:
- Admin, no teams key: unrestricted (sees all)
- Admin, teams: null: unrestricted (same as missing)
- Admin, teams: []: public-only (scoped automation token)
- Non-admin, no teams: public-only (secure default)
- Non-admin, teams: []: public-only

Also:
- Update UI text to reflect public-only policy for unscoped tokens
- Add tests for teams: null handling
- Fix docstrings to document correct semantics

Signed-off-by: Mihai Criveti <[email protected]>
Add comprehensive documentation for token-based access control:

- Token teams claim semantics (missing key, null, empty, explicit teams)
- Different behavior for admin vs non-admin users
- ASCII flow diagrams for token scoping decision tree
- Visibility levels (public, team, private) explanation
- Enforcement points across all access layers
- Token types and use cases (session, API, automation)
- CLI examples for generating scoped tokens
- Best practices for token lifecycle and team organization
- Troubleshooting guide for common access issues

Also add summary table and cross-reference in securing.md.

Signed-off-by: Mihai Criveti <[email protected]>
@crivetimihai crivetimihai merged commit 9cb3042 into main Jan 10, 2026
52 checks passed
@crivetimihai crivetimihai deleted the 1915-mcp-visibility branch January 10, 2026 02:54
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.

[Bug]: SSE and /mcp list paths ignore visibility filters for MCP resources

2 participants