Skip to content

[Security]: MCP tool workspace resolution does not enforce workspace-scoped API key binding #138

Description

@inherent-prime

Found while

Documenting docs/architecture/components/pa-auth-and-tenancy.md (Task 8 of the Component Design & LLD doc set). Not fixed here — this is a docs-only task; filing per the repo's Defect Prevention / Surface Friction rule (unfiled defect noticed in code read but not touched).

What's wrong

services/inh-public-api-svc/src/services/auth.py::_resolve_workspace (auth.py:145-227) is the REST tenancy-resolution rule: a workspace-scoped API key (APIKeyInfo.workspace_id is not None, models/api_key.py:14) is bound to exactly that workspace. Any request for a different workspace is rejected with 403, even if the user happens to own that other workspace too (auth.py:160-177, comment explains this explicitly: "Honouring an X-Workspace-Id header that differs from the key's binding would collapse the key's scope to 'any workspace the user owns', defeating the point of issuing a scoped key").

The MCP surface has its own, separate workspace-resolution helper, _get_workspace_ids (services/inh-public-api-svc/src/mcp_server/server.py:238-260), which does not call _resolve_workspace or reference key_info.workspace_id at all. It only checks the requested workspace against database.get_user_workspace_ids(key_info.user_id) — the user's full owned-workspace set:

async def _get_workspace_ids(
    key_info: APIKeyInfo, requested_workspace_id: str | None
) -> tuple[list[str], str | None]:
    database = await get_database()
    if requested_workspace_id:
        user_workspaces = await database.get_user_workspace_ids(key_info.user_id)
        if requested_workspace_id not in user_workspaces:
            return [], f"Error: You don't have access to workspace '{requested_workspace_id}'"
        return [requested_workspace_id], None
    else:
        user_workspaces = await database.get_user_workspace_ids(key_info.user_id)
        if not user_workspaces:
            return [], "No workspaces found. Upload documents to create a workspace."
        return user_workspaces, None

Consequences (verified from source, not yet reproduced end-to-end):

  1. A workspace-scoped key whose owner has multiple workspaces can be used, via an MCP tool call, to query a workspace other than the one the key is scoped to — as long as the caller's user account owns that other workspace. REST would reject this with 403 (auth.py:171-177); MCP allows it silently.
  2. When no workspace_id argument is supplied, MCP falls back to all of the user's workspaces (_get_workspace_ids else-branch), whereas REST's _resolve_workspace for a workspace-scoped key always narrows to exactly key_info.workspace_id (auth.py:178) — never expanding scope.

This is a dual-surface parity gap of the kind called out in the repo's Defect Prevention retrospective (#98/#99/#100/#112): the same capability (workspace resolution for a scoped key) is implemented twice, and the implementations disagree on authorization, not just on error formatting.

Suggested fix direction

Have MCP's call_tool/_get_workspace_ids call src.services.auth._resolve_workspace (or a shared extraction of its scoping rule) instead of re-deriving workspace access from get_user_workspace_ids alone, so a workspace-scoped key is bound identically on both surfaces.

Where to look

  • services/inh-public-api-svc/src/services/auth.py:145-227 (_resolve_workspace, REST's authoritative rule)
  • services/inh-public-api-svc/src/mcp_server/server.py:188-260 (call_tool, _get_workspace_ids, MCP's separate implementation)
  • services/inh-public-api-svc/src/models/api_key.py:14 (APIKeyInfo.workspace_id — the binding both surfaces are supposed to honor)

Metadata

Metadata

Assignees

No one assigned

    Labels

    bugSomething isn't working

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions