Resolve group work items for fine-grained access tokens

What does this MR do and why?

WorkItem declares its read_work_item granular scope on the project boundary only. A work item that belongs directly to a group, such as an epic, has no project, so BoundaryExtractor finds no boundary for it, Authz::Tokens::AuthorizeGranularScopesService answers 404 Not Found, and the type is redacted. A fine-grained personal access token granted Work Item: Read on the group therefore reads workItem(id:) of an epic as null, and group.workItems(types: [EPIC]) as nodes: [], with no error in either case. That is #630483. A legacy token reads both. The read_work_item assignable already lists the group boundary, and WorkItemUpdate already declares the project and the group boundary, so on master a token also granted Work Item: Update on the group can rename an epic it cannot read: the mutation commits and answers workItem: null.

This MR adds the group boundary to the type:

authorize_granular_token permissions: :read_work_item,
  boundaries: [
    { boundary: :project, boundary_type: :project },
    { boundary: :namespace, boundary_type: :group }
  ]

I named the project and namespace associations rather than resource_parent, which WorkItemUpdate, NoteType and BoardType use, because of the boundary preloader. BoundaryExtractors::Preloader#boundary_association_for builds a preload only for a real association whose class is the boundary's record class, so today boundary: :project is preloaded as { project: :project_namespace } for every connection of work items, while resource_parent is a method and is not preloaded at all. I measured both forms on group.workItems(includeDescendants: true): with resource_parent in both directives, the list loads the project namespace of each distinct project in a query of its own (SELECT ... FROM "namespaces" WHERE "namespaces"."type" = 'Project' AND "namespaces"."id" = ? LIMIT 1), and it does so for legacy tokens too, because the preloader runs for every personal access token. With the form in this MR the query count is the same as on master whatever the number of projects:

  • A project work item resolves its project as before, preloaded with its project namespace. Its namespace is a ProjectNamespace, which the group directive skips, since the extractor compares the resolved record's class with the declared boundary_type.
  • A group work item has no project, so the project directive yields nothing, and its namespace is the Group.
  • The work item connections whose resolvers prepend WorkItems::LookAheadPreloads (workItems on a project, a group, a namespace and the current user, workItemsByReference, and the widgets' children, ancestors and linkedItems) include namespace unconditionally (#unconditional_includes), so the group directive adds no query to them. The boundary preloader does not preload namespace itself (the association's class is Namespace, not Group), which is why those resolvers are what keeps it batched.
  • Where a work item is authorized on its own rather than through one of those connections, a request made with a personal access token now also reads the work item's namespace, which costs at most one query per distinct namespace if nothing has loaded it yet (the query cache answers the repeats). On the two such paths I measured, the query count is the same on this branch as on master: 35 for workItem(id:) of a project work item, with a legacy or a fine-grained token, and 51, 87 and 96 for MergeRequest.linkedWorkItems with a legacy token, for two closing issues in two projects, five in five and eight in five.

Two things stay as they are. A work item in a personal namespace (Namespaces::UserNamespace) still resolves to no boundary, because neither directive matches it; whether such work items should be reachable with a fine-grained token is a separate question I have not touched. The WorkItem.createNoteEmail field keeps its own project-only directive.

References

Closes #630483

  • #631631 tracks the GraphQL types that declare no fine-grained permission yet. WorkItem is not on it because it is declared, on the project boundary only. A token still needs Namespace from that issue to reach a group's work items through namespace(fullPath:); through group(fullPath:), which is the path #630483 uses, this MR is enough.
What changed, file by file
  • app/graphql/types/work_item_type.rb: the read_work_item directive declares the project boundary through project and the group boundary through namespace, with a comment on why the associations are named rather than resource_parent.
  • doc/auth/tokens/fine_grained_access_tokens_graphql.md: regenerated with bundle exec rake gitlab:permissions:graphql:compile_docs, which adds | Read | Group | Type | WorkItem | under Work Item.
  • ee/spec/requests/api/graphql/work_item_spec.rb: the granular token shared examples for workItem(id:) of a group work item, granted on the group.
  • ee/spec/requests/api/graphql/group/work_items_spec.rb: a block for a fine-grained token on a private group, with #630483's query, a list of the group's and its projects' work items, a token without Work Item: Read, and N+1 guards.

The specs are EE specs because a group-level work item needs the epics license; without it the work item is not readable at all (the existing "without group level work item license" example).

Tests

In ee/spec/requests/api/graphql/work_item_spec.rb, under "when work item is created at the group level", it_behaves_like 'authorizing granular token permissions for GraphQL', :read_work_item with the group as the boundary object and scalar fields only, so that the example is about the boundary of the work item itself. That brings the five shared examples: a legacy token is served, and refused once the top-level group requires fine-grained tokens; a fine-grained token granted on the group is served, and refused without the scope or with the granular_personal_access_tokens feature flag disabled.

In ee/spec/requests/api/graphql/group/work_items_spec.rb, under "with a fine-grained personal access token" (a private group, a reporter, an epic assigned to them and an issue in a project of the group, a token granted Group: Read and Work Item: Read on the group):

  • #630483's query, workItems(types: [EPIC], assigneeUsernames: [...], state: opened, first: 50), returns the epic.
  • includeDescendants: true returns the epic and the project's issue.
  • A token without Work Item: Read gets an empty list.
  • N+1, once with the fine-grained token and once with a legacy token: adding two projects with an issue each and a second epic adds no query to the list.

The N+1 guards cover the list of work items, not the hierarchy widget. A fine-grained token can read an epic's children for the first time with this MR, and that list grows by four queries for each project its children span (one of them uncached, the other three answered by the query cache): from 50 to 62 queries between one and four projects. It grows the same way on master with a legacy token and on this branch with either kind of token, and the resource_parent form reaches 65, so it is not about boundaries and this MR does not guard it.

To check that each example tests what it says, I ran the 10 examples this MR adds against two other versions of the declaration (the runs also took in the 3 examples already in the group-level context, which pass on all three versions):

WorkItem declares Result
The project boundary only, as on master 5 of the 10 fail: #630483's query and the descendants list miss the epic, the fine-grained N+1 example sees 3 work items instead of 5, the single work item is null for the fine-grained token, and the shared example for a group that enforces fine-grained tokens fails too, because a group work item with no boundary takes no part in that enforcement
resource_parent for both boundaries 2 of the 10 fail, the two N+1 examples: 34 queries against a control of 32, one namespaces query for each project added

Run locally on this branch (Ruby 3.3.11, PostgreSQL 17, Redis 7.2, Gitaly built):

  • ee/spec/requests/api/graphql/group/work_items_spec.rb, spec/graphql/types/work_item_type_spec.rb and spec/requests/api/graphql/subscriptions/work_item_updated_spec.rb, whole: 82 examples, 0 failures.
  • The granular token examples (-e granular) of the CE and EE work item request specs and of the project work items, issue, work item export and work item update request specs, plus the group-level context of the EE work item spec: 59 examples, 0 failures, 4 pending. The pending ones are the shared examples excusing themselves ("namespace has no top-level group") in the issue and export specs, not anything of this change.

bundle exec rake gitlab:permissions:validate reports every check valid and both documentation pages up to date. rubocop reports no offenses on the three Ruby files. markdownlint-cli2 0.23.2 with this repository's configuration reports no issues on the regenerated page, and Vale 3.21.0 with this repository's styles reports the same alerts as on master and nothing on the new line.

Where this comes from

I maintain gitlab-mcp-server, an MCP server that exposes the GitLab API to AI assistants. It works out, for each of its actions, what a fine-grained token has to be granted, from the route and type declarations GitLab ships, and withholds the actions no grant can reach. Every epic action ends at a WorkItem that belongs to a group, so for a fine-grained token those actions are withheld, and a probe against a running instance showed workItemUpdate renaming an epic and answering workItem: null. The project keeps a record of everything it finds in its dependencies and in sibling projects in upstream-bugs.md; this one is the entry WorkItem declares the project boundary only, so a group's work item is null to a fine-grained token.

Screenshots or screen recordings

Not applicable, this changes a GraphQL authorization declaration with no UI surface.

How to set up and validate locally

  1. In a group with the epics license, create an epic and note the group's full path.

  2. Create a fine-grained personal access token for a member of the group with Group: Read and Work Item: Read on that group.

  3. Ask for the group's epics with the token:

    curl --request POST \
      --header "Authorization: Bearer <your_fine_grained_token>" \
      --header "Content-Type: application/json" \
      --data '{"query": "{ group(fullPath: \"<group-path>\") { workItems(types: [EPIC], first: 50) { nodes { iid title } } } }"}' \
      --url "http://127.0.0.1:3000/api/graphql"
  4. On master the response is "nodes": []. On this branch it lists the epic, as a legacy token with read_api does on both.

The specs:

bundle exec rspec ee/spec/requests/api/graphql/group/work_items_spec.rb
bundle exec rspec ee/spec/requests/api/graphql/work_item_spec.rb -e 'when work item is created at the group level'

MR acceptance checklist

  • I have evaluated the MR acceptance checklist for this MR.
  • Tests added for a group work item read with a fine-grained token, #630483's query, a token without the scope, and N+1 guards with a fine-grained and a legacy token.
  • Documentation regenerated with gitlab:permissions:graphql:compile_docs, and gitlab:permissions:validate passes.
  • Changelog: fixed trailer on the commit.
  • No new query on a group's list of work items for either kind of token, pinned by a spec that fails with the resource_parent form.

Merge request reports

Loading
Loading