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):
- 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.
- 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)
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 anX-Workspace-Idheader 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_workspaceor referencekey_info.workspace_idat all. It only checks the requested workspace againstdatabase.get_user_workspace_ids(key_info.user_id)— the user's full owned-workspace set:Consequences (verified from source, not yet reproduced end-to-end):
auth.py:171-177); MCP allows it silently.workspace_idargument is supplied, MCP falls back to all of the user's workspaces (_get_workspace_idselse-branch), whereas REST's_resolve_workspacefor a workspace-scoped key always narrows to exactlykey_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_idscallsrc.services.auth._resolve_workspace(or a shared extraction of its scoping rule) instead of re-deriving workspace access fromget_user_workspace_idsalone, 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)