Fine-grained PATs get null from the DiffStats, DiffStatsSummary, LinkedWorkItemType and WorkItemType GraphQL types, while REST returns the same data to the same token

Everyone can contribute. Help move this issue forward while earning points, leveling up and collecting rewards.

Summary

Four GraphQL types are unreachable for fine-grained personal access tokens regardless of which permissions are granted. The token sees the parent objects perfectly well — same merge requests, same work items, same counts as a legacy token — and is then refused the leaf type hanging off them. REST returns the same underlying data to the same token, so this reads as an oversight rather than a deliberate restriction.

GraphQL type Ruby class authorization_todo.txt
DiffStats Types::DiffStatsType line 520
DiffStatsSummary Types::DiffStatsSummaryType line 521
LinkedWorkItemType Types::WorkItems::LinkedItemType line 646
WorkItemType Types::WorkItems::TypeType line 1044

There is no permission a user can select that fixes any of them.

This is the same defect class as #624129 (AccessLevel, ProjectStatistics), filed under the #593878 umbrella. Filing separately because these four are a different sub-case: three of them carry # rubocop:disable Graphql/AuthorizeTypes in source with a comment saying a parent or resolver authorizes them, so they were deliberately exempted from type-level authorization and the granular check has no way to know that. See Cause.

Steps to reproduce

  1. Create a fine-grained PAT with all memberships access and, among others, Merge request: Read, Work item: Read, Code: Read and Project: Read.
  2. Query, as that token:
{ project(fullPath: "gitlab-org/gitlab-rebased") {
    mergeRequest(iid: "61") {
      diffStatsSummary { additions deletions fileCount }
      diffStats { path additions deletions }
    }
    workItems(first: 10) { nodes { id workItemType { name } } }
} }
{ project(fullPath: "gitlab-org/gitlab") {
    issue(iid: "487062") {
      linkedWorkItems { nodes { linkType workItem { id } } }
} } }
  1. Repeat with a legacy PAT scoped to read_api.

Current behaviour

Measured on GitLab.com (19.4.0-pre), 31 August 2026, against a public project:

legacy read_api fine-grained
merge requests returned 40 40
with non-null diffStatsSummary 40 0
with non-null diffStats 40 0
sum of diffStatsSummary.additions 8,709 0
work items returned 10 10
non-null work item nodes 10 0
linkedWorkItems nodes returned 5 5
non-null linkedWorkItems nodes 5 0

Object visibility is identical. The fine-grained token resolves the same 40 merge requests and the same 10 work items with the same ids and titles; only the leaf type is denied. Narrowing the token from all memberships to a single selected project changes nothing.

Two distinct failure modes, and neither is diagnosable by a client:

Silent. MergeRequest.diffStatsSummary is nullable, so it returns null with a 200 and no errors entry at all. LinkedWorkItemType is the element type of a nullable list, so linkedWorkItems.nodes returns [null, null, null, null, null] — five entries, all null, no error. Selecting only the scalar linkType, with no workItem sub-selection, is enough to produce this: the denied type is the node itself.

An untyped error. Where the schema makes the type non-null, denial surfaces as GraphQL non-null propagation:

Cannot return null for non-nullable element of type 'DiffStats' for MergeRequest.diffStats
Cannot return null for non-nullable field WorkItem.workItemType

Both arrive with HTTP 200 and no extensions, so they are indistinguishable from a genuine server-side null. A client cannot tell "your token may not read this" from "this failed", which makes the condition impossible to handle or to report to a user accurately.

REST returns the same facts to the same fine-grained token:

Call Result
GET /projects/:id/merge_requests/:iid/diffs 200, the full diff text of every file
GET /projects/:id/repository/commits/:sha 200, stats: {additions: 178, deletions: 719, total: 897}
GET /projects/:id/merge_requests/:iid 200

So the same token is handed every byte of the diff over REST and refused the two integers summarising it over GraphQL.

For completeness: GET /projects/:id/repository/diff_stats is the endpoint shaped exactly like DiffStats, but it is behind repository_diff_stats_api (config/feature_flags/beta/, default_enabled: false, rollout #617089) and measured 404 on GitLab.com for both token classes. It is not part of the asymmetry above and not what this issue asks for.

Expected behaviour

A fine-grained token granted Merge request: Read should be able to read a merge request's diff statistics, and one granted Work item: Read should be able to read a work item's type and its links. At minimum GraphQL and REST should agree for one token.

Cause

lib/gitlab/graphql/authz/granular_scope_authorization.rb#L50-56:

# if no gPAT directives are defined, granular tokens deny access while
# legacy tokens fall through to their existing authorization
if directives.empty?
  return error(default_error_message) if token.granular?
  return success
end

A type carrying no authorize_granular_token directive is denied outright for a granular token. None of the four declares one. Per the granular-access GraphQL guide an unauthorized query returns null with a 200, which is why the two nullable cases are silent.

The four split into two sub-cases, and the first is the interesting one.

Three of them are Graphql/AuthorizeTypes-exempt by design — the source says so:

# rubocop: disable Graphql/AuthorizeTypes
# Types that use DiffStatsType should have their own authorization
class DiffStatsType < BaseObject          # app/graphql/types/diff_stats_type.rb
class DiffStatsSummaryType < BaseObject   # app/graphql/types/diff_stats_summary_type.rb

# rubocop:disable Graphql/AuthorizeTypes -- authorized by resolver
class LinkedItemType < BaseObject         # app/graphql/types/work_items/linked_item_type.rb

These are leaf types that intentionally delegate authorization to a parent. Under role-based authorization that works. Under granular authorization the delegation is invisible: the check looks for a directive on the type itself, finds none, and denies — so the very annotation that marks a type as correctly authorized elsewhere is what makes it unreachable. Any type in this category is denied to granular tokens today unless someone has explicitly recorded the skip.

WorkItemType is the ordinary case and is simply missing its directive (app/graphql/types/work_items/type_type.rb):

class TypeType < BaseObject
  graphql_name 'WorkItemType'

  authorize :read_work_item_type

  def self.authorization_scopes
    super + [:ai_workflows]
  end

It is already authorization-aware — an ability, an extra authorization scope, and scopes: [:api, :read_api, :ai_workflows] on id and name — and only the granular directive is absent.

⚠️ Naming collision worth flagging for whoever picks this up. Types::WorkItemType, whose GraphQL name is WorkItem, carries authorize_granular_token permissions: :read_work_item, boundary: :project, boundary_type: :project on master and works correctly — measured, it resolves for a granular token. The type in this issue is Types::WorkItems::TypeType, whose GraphQL name is WorkItemType. Two different types with each other's names, and only the second is affected.

Possible fix

Same shape as #603527, #606143 and #604498 (closed): add the directive, add the shared-example test, drop the authorization_todo.txt line, and regenerate with bundle exec rake gitlab:permissions:graphql:compile_docs.

The mechanism for the first sub-case already exists and is documented in doc/development/permissions/granular_access/graphql_implementation_guide.md, with VulnerabilityIdentifierType as the worked example:

authorize_granular_token skip_reason: :parent_authorizes

parent_authorizes is one of the four values in lib/tasks/gitlab/permissions/graphql/skip_reasons.rb, and GranularScopeAuthorization#skip_authorization? short-circuits on it. So for DiffStats, DiffStatsSummary and LinkedWorkItemType this is a one-line declaration each that records what the rubocop comment already asserts — no new permission, no new boundary.

For WorkItemType, the ordinary form:

authorize_granular_token permissions: :read_work_item_type, boundary: :project, boundary_type: :project

Boundary is the Authorization group's call — the type is a global catalogue row rather than a project-scoped resource, so skip_reason: may fit it better too.

A note on scope. config/authz/graphql/authorization_todo.txt has 1,074 entries but is a superset of what can actually be denied: membership is a name-suffix filter over every object type, and runtime denial reaches only Types::BaseObject descendants. type:PageInfo is on the list and is demonstrably not denied — a granular token resolves hasNextPage, endCursor, startCursor and hasPreviousPage identically to a legacy one. The four types above were each checked individually against a live token; I am not claiming anything about the rest of the file.

  • #624129 — same defect class, AccessLevel and ProjectStatistics. Two more types, same cause, same REST asymmetry.
  • #593878 — the umbrella ("no 'ghost APIs' that are exempt from AuthZ").
  • #603527 — worked template for removing a authorization_todo.txt entry.
  • #617089 — repository_diff_stats_api rollout. Independent; enabling it would give REST a DiffStats-shaped route but would not fix GraphQL.

Impact

gitlab-org/gitlab-rebased generates a personal year-in-review from a user's GitLab activity and recommends a fine-grained read-only token for it. diffStats is the only source of line counts anywhere in the GraphQL schema — DiffStats and DiffStatsSummary are the only two types carrying them — so four of its charts cannot be drawn for the token class it recommends, and it currently ships a visible "unavailable" state instead. WorkItemType and LinkedWorkItemType block its epic queries and its issue-relationship graph. Every one of those facts is readable by the same token over REST.

Edited by 🤖 GitLab Bot 🤖