Return granular_scopes from GET /personal_access_tokens/self
What does this MR do and why?
GET /personal_access_tokens/self presents the authenticating token with API::Entities::PersonalAccessToken and no options, and that entity exposes granular_scopes only when with_granular_scopes is passed. The list, get-by-ID and rotate routes in lib/api/personal_access_tokens.rb pass it, together with the project_ids_by_namespace_id map that PersonalAccessTokenGranularScope reads for project_id. So a fine-grained token that asks about itself is told that it is granular (granular: true, scopes: ["granular"]) and not what it was granted, which is the one thing a client holding such a token needs to know.
The API documentation already says otherwise: it accepts the keyword self for id, and says the response has the same attributes as the list, including granular_scopes.
This MR makes the self route do what the get-by-ID route does: it preloads the token's granular scopes with their namespaces and presents them with with_granular_scopes: true and the project ID map. A legacy token skips both, so its response is unchanged and it does not pay for the queries that would load scopes it does not have.
Following the review, the preload and the options now come from one helper, granular_scopes_options_for in lib/api/helpers/personal_access_tokens_helpers.rb, which the self route and the list, get-by-ID and rotate routes all call instead of each spelling them out. It preloads the scopes first and then the namespaces of the scopes the tokens hold, so it also serves a token that already holds its scopes without their namespaces: that is the token Authn::PersonalAccessTokens::CreateGranularService returns, which the create routes of #632314 (closed) and #632315 (closed) will hand it. No response of the other three routes changes, and neither does their query count, except that rotating a legacy token by ID no longer runs a preload that could only find nothing.
The field is additive and appears only for fine-grained tokens. It discloses nothing new: the self route asks a fine-grained token for Personal Access Token: Read at the user boundary, which is the same permission GET /personal_access_tokens/:id asks of it for its own ID, so the same token could already read the same grant with a second request.
This answers the third open question of #629849 (whether granular_scopes is missing from the self-inform response) and nothing else. Whether self-introspection should need read_personal_access_token at all, and the behavior matrix the issue asks for, are for the group to decide, and this MR touches neither, so it is related to the issue rather than closing it.
For review I would suggest eduardosanz from the Authentication approvers, who merged !243754 (merged) (the MR that added granular_scopes to the other three routes), and idurham for the docs line.
References
Related to #629849
- !243754 (merged) added
granular_scopesto the list, get-by-ID and rotate responses. - Retrieve a personal access token, the documentation this brings the self route in line with.
- #632314 (closed) and #632315 (closed), opened during this review for the token routes that still do not return
granular_scopes, will use the helper.
What changed, file by file
lib/api/helpers/personal_access_tokens_helpers.rb:granular_scopes_options_for(tokens)keeps the granular tokens among those it is given, preloadsgranular_scopeson them and thennamespaceon the scopes they hold, withActiveRecord::Associations::Preloaderboth times, and returnswith_granular_scopes: truewithproject_ids_by_namespace_id_forthose tokens. Given no granular token, it returns no options and runs no query. The comment above it says why the preload takes two steps.lib/api/personal_access_tokens/self_information.rb: theget 'self'route presents the token with**granular_scopes_options_for([access_token]). The class already includesAPI::Helpers::PersonalAccessTokensHelpers. A legacy token is presented exactly as before.lib/api/personal_access_tokens.rb: the list, get-by-ID and rotate routes pass**granular_scopes_options_for(...)in place of the options each of them spelled out, and the rotate route drops the preload it ran for the token it returns, which the helper now runs.spec/lib/api/helpers/personal_access_tokens_helpers_spec.rb(new) andspec/requests/api/personal_access_tokens/self_information_spec.rb: the cases under "Tests" below.doc/api/personal_access_tokens.md: a history item under "Self-inform", in the form suggested in review. The page already promised the attribute, so this records the version in which the self keyword started returning it: 19.5, the milestonemasteris on (VERSIONis19.5.0-pre).
The Changelog: fixed trailer is on the first commit only; the other two change no response.
Nothing changes in the OpenAPI documents: the route's success model is the same entity, whose documentation already lists granular_scopes.
Tests
Five new examples under GET /personal_access_tokens/self in spec/requests/api/personal_access_tokens/self_information_spec.rb:
- A legacy token, two examples: the response has no
granular_scopeskey, and the request runs no query that touchesgranular_scopes. - A fine-grained token holding Personal Access Token: Read at the user boundary (the one permission the route needs): the response carries that scope, with
accessuserandproject_idandgroup_idbothnull. This example fails onmaster, where the key is absent. - The same token with a project scope as well: the project scope carries the project's
project_id. - N+1: adding a second project scope to the same token adds no query, which fails without the preload.
The tokens are built with read_personal_access_token rather than an unrelated permission such as read_job, because the route itself requires it: a token without it is refused with 403 insufficient_granular_scope before the endpoint runs.
Five examples for the helper in the new spec/lib/api/helpers/personal_access_tokens_helpers_spec.rb:
- No granular token among those given: no options, and no query.
- A legacy token and a granular one, two examples: the options carry the project ID map for the granular token's project scope, and only the granular token has its scopes preloaded, with their namespaces.
- Scopes already loaded through
PersonalAccessToken.preload_granular_scopes, as the list route loads them: nothing is loaded again. - A token that holds two scopes without their namespaces, the state
CreateGranularServiceleaves a token in: the namespaces are loaded in one query, and the scopes are not read again.
I ran the helper spec and the whole personal access token API locally on this branch (Ruby 3.3.11, PostgreSQL 17, Redis 7.2, Gitaly built): spec/lib/api/helpers/personal_access_tokens_helpers_spec.rb, spec/requests/api/personal_access_tokens_spec.rb and spec/requests/api/personal_access_tokens/, which cover the helper and the four routes that call it: 353 examples, 0 failures, 16 pending. The pending ones are the granular-token shared examples excusing themselves ("namespace has no top-level group", "only meaningful on Project/Group boundaries"), not anything of this change.
To check that each new example tests what it says, I also ran the helper spec and self_information_spec.rb against four broken versions:
| Version | Result |
|---|---|
The self route presenting the token without the options, as on master |
The two self route examples that read granular_scopes fail; the key is absent |
| The helper returning the options without either preload | The self route N+1 example fails (19 queries against a control of 18), and so does the helper example for scopes held without their namespaces, which finds no batched namespaces query |
| The helper preloading and returning the options for every token, legacy ones included | The three legacy examples fail: the helper's two (options returned, a query run, the legacy token's scopes loaded) and the self route's (a SELECT from granular_scopes) |
The helper preloading { granular_scopes: :namespace } in one step |
The helper example for scopes held without their namespaces fails: the scopes are read again through the join rows |
rubocop reports no offenses on the five Ruby files. markdownlint-cli2 0.22.1 with this repository's configuration reports no issues on the page, and Vale 3.21.0 with this repository's styles reports the same three suggestions as on master and nothing on the new lines. gitlab:openapi:v3:check_docs reports the document up to date.
Where this comes from
I maintain gitlab-mcp-server, an MCP server that exposes the GitLab API to AI assistants. To serve a fine-grained token only the actions its grant reaches, it reads the grant when a session starts and again on each revalidation. Because the self response omits the scopes, it reads the token's ID from GET /personal_access_tokens/self and then asks GET /personal_access_tokens/:id for the grant, one request more each time a session starts. 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 A token's own description omits its granular scopes.
Screenshots or screen recordings
Not applicable, this is an API response with no UI surface.
How to set up and validate locally
-
Create a fine-grained personal access token with Personal Access Token: Read at the user boundary and, to see
project_idresolved, a second scope on a project its user belongs to. -
Ask the API about the token with the token itself:
curl --request GET \ --header "PRIVATE-TOKEN: <your_fine_grained_token>" \ --url "http://127.0.0.1:3000/api/v4/personal_access_tokens/self" -
On
masterthe response has"granular": trueand nogranular_scopes. On this branch it hasgranular_scopes, the same arrayGET /personal_access_tokens/<id>returns for the same token. -
A legacy token returns the same response on both.
The specs:
bundle exec rspec spec/requests/api/personal_access_tokens/self_information_spec.rb spec/lib/api/helpers/personal_access_tokens_helpers_spec.rb
bundle exec rspec spec/requests/api/personal_access_tokens_spec.rbMR acceptance checklist
- I have evaluated the MR acceptance checklist for this MR.
- Tests added for a fine-grained token, a fine-grained token with a project scope, the N+1 case and a legacy token on the self route, and for the helper given no granular token, a mix, preloaded scopes and scopes held without their namespaces.
- Documentation updated: a history item under "Self-inform" in
doc/api/personal_access_tokens.md. -
Changelog: fixedtrailer on the first commit only. - Specs run locally: 353 examples, 0 failures, 16 pending for the helper and the personal access token API. The rest of the suite is for the pipeline to speak for.
- No new query for a legacy token, and no N+1 for a granular one, both pinned by a spec.