fix(mcp): resolve databases through project roles by vsai12 · Pull Request #21551 · bytebase/bytebase · GitHub
Skip to content

fix(mcp): resolve databases through project roles - #21551

Open
vsai12 wants to merge 2 commits into
mainfrom
fix/mcp-resolver-project-roles
Open

vsai12 wants to merge 2 commits into
mainfrom
fix/mcp-resolver-project-roles

Conversation

@vsai12

@vsai12 vsai12 commented Oct 3, 2026 •

Copy link
Copy Markdown
Contributor

Ships in the same release as #21552, which removes the MCP query skill and get_skill and adds server instructions.

query_database, get_schema and propose_database_change find a database by name. To do that, they list databases at the workspace, and that listing needs bb.databases.list at the workspace level. Project roles don't grant it, and neither does Workspace member. So a user whose access comes only from a project role, such as a project developer or a SQL Editor user, got PERMISSION_DENIED from all three tools, even for a database in their own project.

After this change:

  • No project named. The resolver lists at the workspace first, as before. If the caller's own permissions refuse that listing, it lists through each project that SearchProjects returns for the caller. A database in a project the caller has no role in stays DATABASE_NOT_FOUND, the same answer as for a name that matches nothing. A caller who can read no project at all gets PERMISSION_DENIED for every name, because it can list no databases.
  • A project named. The resolver lists under that project directly, for every caller. If the caller holds no role there, the answer is PERMISSION_DENIED naming the project. If the project doesn't exist, the answer is DATABASE_NOT_FOUND. That split is the server's own: ListDatabases answers 404 for a missing project before it checks permission, and call_api already shows the same caller the same 403 and 404.

The server checks bb.projects.get in the project for each project listing, and the console's project Databases page makes the same call. So the tools resolve exactly the databases the caller can list through the API, and nothing more. A caller with workspace-level bb.databases.list (workspace admin, DBA) still makes one listing call. A refusal by the MCP access policy comes back as before, with no fallback.

This ships with that PR, which deletes the query skill. Until this fix, a project-only user could query through MCP only with call_api and a full database name.

Why the project loop comes back

#19823 replaced a SearchProjects loop with the single workspace call, at ecmadao's request: the loop had been working around a "workspace mismatch" 403 that the workspace's full name fixes. The workspace call also needs workspace-level bb.databases.list, so project-only users lost access; Codex's last P1 on #19823 named exactly this. Here the loop runs only as the fallback after the caller's permissions refuse the workspace call. Workspace-level callers keep the single call.

A cleaner shape would remove the loop: let ListDatabases accept the AIP-159 wildcard projects/- and return the databases in the projects the caller can read, as SearchIssues already does with getProjectIDsSearchFilter. Then every resolve is one call. That changes the documented parent forms of ListDatabasesRequest in proto/, so it isn't in this PR.

What a resolve costs

  • A named project: one ListDatabases call.
  • No project named, workspace-level caller: one call.
  • No project named, project-only caller: one refused ListDatabases call at the workspace, one SearchProjects call per 1,000 projects in the workspace, and one ListDatabases call per project the caller can read.

The refused workspace call is a permission refusal like any other. ListDatabases isn't an audited method, so no audit row is stored. When the stdout audit stream is on, the refusal is streamed as a WARNING line.

SearchProjects filters each page after reading it, so a page can be empty and still have a next one; the resolver follows the page token. A project that becomes unreadable or is deleted between the search and its listing contributes nothing.

Not in this change

  • A caller with a custom workspace role that holds bb.databases.list without bb.projects.get used to resolve a database in a named project through the workspace listing. It now gets PERMISSION_DENIED for that project, as ListDatabases gives it for that parent; it can still leave project out. No predefined role has that shape. On feat(mcp): simplify resolveDatabase with workspace-scoped ListDatabases #19823 this split was judged a pathological custom role.
  • A custom workspace role that holds bb.instances.get without bb.databases.list can list databases under instances/{id}. The resolver doesn't try instance parents. No predefined role has that shape.
  • A custom workspace role that holds bb.projects.get without bb.databases.list gets every project from SearchProjects, so its unnamed resolve makes one listing call per project in the workspace. No predefined role has that shape either.

Tests

  • TestMCPResolvesDatabasesThroughProjectRoles (backend/tests) uses a live server, real MCP sessions and real IAM. It creates a SQL Editor user and a project developer, each with a role in one project only, and a second database in another project.
    • On main, the SQL Editor user's first query_database fails: PERMISSION_DENIED: you don't have permission to list databases in this workspace.
    • On this branch, the SQL Editor user queries their database and gets DATABASE_NOT_FOUND for the other project's database. Naming their own project returns the rows; naming the other project returns PERMISSION_DENIED; naming a missing project returns DATABASE_NOT_FOUND.
    • The developer, who holds no bb.sql.select, reads the schema and gets DATABASE_NOT_FOUND for the other project's database. Under a Read-write policy the developer proposes a change, and the issue lands in their project. The workspace admin resolves the other project's database, as the control.
  • Unit tests in tool_resolve_test.go use a mock that answers the way the server answers a project-only caller:
    • TestResolve_ProjectRoleListsThroughReadableProjects: lists through the searched projects, including a search page that is empty but has a next page.
    • TestResolve_ProjectRoleCombinesProjectsAndKeepsTheFilter: two readable projects give one candidate list, and an instance filter, workspace or project instance, still narrows the fallback.
    • TestResolve_ProjectRoleSkipsAProjectRefusedAfterTheSearch: a project refused after the search is skipped, not fatal.
    • TestResolve_ProjectRoleWithNamedProject: a named project is listed with one call. Its cases are readable, refused (PERMISSION_DENIED naming the project) and missing (DATABASE_NOT_FOUND).
    • TestResolve_WorkspaceCallerListsOnce: a workspace-level caller makes exactly one call. It passes on main and on this branch.
    • Mutations checked: failing on a refused searched project, dropping the filter on the fallback, and keeping only the last project's results each fail a test.
  • TestQueryDatabase_WorkspacePermissionDenied now expects the project-role advice. The old advice, "grant bb.databases.list", was the wrong ask for most of these users.
  • TestMCPReadOnlyRoleDowngradeBitesTheNextRequest and TestResolve_PolicyDenialGetsNoRoleAdvice: comments no longer describe the old listing or the old advice. No behavior change.

Receipts

Claim Receipt
The tools listed only at the workspace backend/api/mcp/tool_resolve.go:113-120 on main
All three tools share that resolver resolveTarget at tool_query.go:90, tool_schema.go:243, tool_change.go:197
The workspace parent needs bb.databases.list; a project parent needs bb.projects.get in the project, after a 404 for a missing project backend/api/v1/database_service.go:307-333
The console's project Databases page lists under the project frontend/src/routes/project/ProjectDatabasesPage.tsx:826-828
Workspace member holds neither backend/store/predefined_roles.go:319-344
Workspace bindings count for project checks backend/component/iam/manager.go:52-97
SearchProjects filters after paging backend/api/v1/project_service.go:169-198
SearchIssues serves projects/- backend/api/v1/issue_service.go:368-377; helper plan_service.go:136-166
A refused unaudited call stores no row and streams when stdout is on backend/api/v1/audit.go:82-83
#19823 history ecmadao's review on #19823 ("use the workspace fullname workspaces/{id} instead of loop the projects"); Codex's last P1 there, "Resolve databases with project-scoped ListDatabases"

🤖 Generated with Claude Code

vsai12 and others added 2 commits October 2, 2026 17:39
query_database, get_schema and propose_database_change find a database by
listing at the workspace, which needs workspace-wide bb.databases.list.
Project roles don't grant it, so a user whose access comes only from project
roles got PERMISSION_DENIED from all three tools.

A caller refused at the workspace is now listed through the projects it can
read: the project it named, or each project SearchProjects returns. A caller
with workspace-wide access still lists once, as before.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…arch

A named project is now listed under that project for every caller, so a
project-only caller who names one makes one call instead of a refused
workspace call first. The server's answer for that project comes through:
PERMISSION_DENIED naming the project if the caller holds no role there,
DATABASE_NOT_FOUND if it does not exist.

Without a named project, a project the caller loses access to between the
search and its listing now contributes nothing, as a project deleted in
that window already did, instead of failing the whole resolve.

Tests pin the search path across two projects, the filter on that path, a
project refused after the search, the named-project answers against a live
server, and a project developer proposing a change.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

@ecmadao ecmadao Oct 14, 2026 •

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.

Avoid the per-project database-list fallback

For a caller without workspace-wide bb.databases.list, this path first receives a denied workspace request, then paginates SearchProjects and makes a sequential ListDatabases request for every readable project. That can exceed the resolver's 30-second deadline in a workspace with many projects, and even a caller with no project access must scan the project list before receiving PERMISSION_DENIED.

Could we use a different API shape here? In particular, consider extending ListDatabases to accept parent = "projects/-" and return only databases from projects where the caller holds bb.projects.get. The resolver could then fall back from the denied workspace request to one wildcard request, rather than enumerating projects and issuing one database-list request per project.

vsai12 added a commit that referenced this pull request Oct 23, 2026
…ct-role lookups to #21551

The recheck found the in-result advice firing for writes and EXPLAIN, which
no grant lifts: a grant's statement must be read-only, and a granted target
still needs bb.sql.explain. The advice now needs bb.sql.select or
bb.sql.info; other refusals pass through alone.

The resolver fallback duplicated open #21551, whose reviewer asked for
ListDatabases to accept projects/- instead of a per-project loop, so it is
removed here. The account line now opens any existing server instructions
instead of replacing them, for #21552. The service-account row runs in a
workspace that admits all users, so only the principal type keeps it out.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

This branch has not been deployed

No deployments
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.

2 participants