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
- Create a fine-grained PAT with all memberships access and, among others,
Merge request: Read,Work item: Read,Code: ReadandProject: Read. - 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 } } }
} } }- 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.workItemTypeBoth 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
endA 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.rbThese 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]
endIt 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.
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_authorizesparent_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: :projectBoundary 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.
Related
- #624129 — same defect class,
AccessLevelandProjectStatistics. 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.txtentry. - #617089 —
repository_diff_stats_apirollout. Independent; enabling it would give REST aDiffStats-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.