Return granular_scopes from the impersonation token API
What does this MR do and why?
The three impersonation token routes in lib/api/users.rb that return a token (GET /users/:user_id/impersonation_tokens, GET /users/:user_id/impersonation_tokens/:impersonation_token_id and POST /users/:user_id/impersonation_tokens) present it with API::Entities::ImpersonationToken or ImpersonationTokenWithToken and no options. Both inherit from API::Entities::PersonalAccessToken, which exposes granular_scopes only when with_granular_scopes is passed. So an administrator who creates an impersonation token with granular_scopes, which the create route accepts, cannot read those scopes back from the impersonation token routes: not from the list, not from the token's own route, and not even from the response to the request that created it. The only way to read them today is the personal access token API, through GET /personal_access_tokens/:id or GET /personal_access_tokens?user_id=<user_id>, which present an impersonation token like any other token of the user.
This MR builds on granular_scopes_options_for, the helper !259764 (merged) added to lib/api/helpers/personal_access_tokens_helpers.rb, which GET /personal_access_tokens, GET /personal_access_tokens/:id, POST /personal_access_tokens/:id/rotate and GET /personal_access_tokens/self already use, and passes its options on all three routes. The helper keeps the granular tokens it is given, preloads their scopes and then the namespaces of those scopes, and returns with_granular_scopes: true with the project_ids_by_namespace_id map that PersonalAccessTokenGranularScope reads for project_id. Given no granular token, it returns no options and runs no query, so a token created with scopes is presented exactly as before.
The create route presents the token Authn::PersonalAccessTokens::CreateGranularService returns, which already holds its scopes without their namespaces, because the service reads them back to track the creation. The helper loads the namespaces of the scopes the token holds in one query, so presenting the new token costs the same whatever the number of its scopes.
On the list route the helper receives the whole page, so the scopes of every granular token on the page are preloaded in one batch. The issue suggests adding .preload_granular_scopes to the relation, as GET /personal_access_tokens does. I did not, because that scope queries personal_access_token_granular_scopes for every page, including a page with no granular token on it, and the acceptance criteria ask that legacy responses run no granular_scopes query. The helper's batch preload avoids the N+1 all the same, and the specs below pin both.
The field is additive and appears only for tokens created with granular scopes. The three routes stay administrator-only, and nothing changes in who may call them.
For review I would suggest eduardosanz, who opened the issue during the review of !259764 (merged).
References
Closes #632315 (closed)
- #605650, opened from the review of !243990 (merged), asks for the same
granular_scopesin the response of the create route, which this MR returns. - !259764 (merged) added
granular_scopes_options_forand thegranular_scopesresponse ofGET /personal_access_tokens/self. - !243754 (merged) added
granular_scopesto the list, get-by-ID and rotate responses of the personal access token API. - #632314 (closed) is the same gap on the personal access token create and rotate routes, which I am addressing in a separate MR.
- #630541 proposes accepting
granular_scopesonPOST /users/:user_id/personal_access_tokens, and documenting it indoc/api/user_tokens.mdtogether with the same request attribute of the impersonation token create route, which is not documented yet.
What changed, file by file
lib/api/users.rb: the list, get and create routes for impersonation tokens present with**granular_scopes_options_for(...), given the paginated page on the list route and the one token on the other two. The create route passes them on thegranular_scopesbranch, the only one that creates a granular token; thescopesbranch is unchanged.spec/requests/api/users_spec.rb: the cases under "Tests" below.doc/api/user_tokens.md: under "List all impersonation tokens for a user", "Retrieve an impersonation token for a user" and "Create an impersonation token", a history item in the form suggested on !259764 (merged), and a note after the example response that points to the description of the attribute in List all personal access tokens. The history items say 19.6:VERSIONonmasteris still19.5.0-pre, but this MR still needs its reviews and I do not expect it to merge before the 19.5 cutoff. If it does, I will change them to 19.5.
The page still does not document granular_scopes as a request attribute of the create route, and still marks scopes and expires_at as required there, although the route declares both optional and scopes and granular_scopes mutually exclusive. That is the request side, which #630541 already proposes to document on this page, so I left it out of this MR. I can add it here instead if you prefer.
Nothing changes in the OpenAPI documents: the success models are the same entities, whose documentation already lists granular_scopes, and gitlab:openapi:v3:check_docs reports the document up to date. gitlab:openapi:v2:check_docs reports the v2 document outdated, but it does so on master too, and the document it generates is byte for byte the same with and without this MR.
Tests
New and extended examples in spec/requests/api/users_spec.rb:
- List, with no granular token: no entry has a
granular_scopeskey, and the request runs no query that touchesgranular_scopes. - List, with a granular token beside the legacy ones, holding a user scope and a project scope: the granular entry carries both scopes, the project one with that project's
project_id, and the legacy entry has nogranular_scopeskey. - List, N+1: adding a second granular impersonation token with a project scope adds no query.
- Get, for a legacy token: no
granular_scopeskey and no query that touchesgranular_scopes. - Get, for a granular token with a user scope and a project scope: both scopes, the project one with its
project_id. - Get, N+1: adding a second project scope to the same token adds no query.
- Create, with
scopes: nogranular_scopeskey and no query that touchesgranular_scopes. - Create, with
granular_scopes: the existing example for each of the four access types (user,instance,all_memberships,personal_projects) now also checks that the response carries the scope, and a new one forselected_membershipswith a project and a group checksproject_idandgroup_id. - Create, N+1: a new token with two project scopes is presented with no more queries than one with one.
Creating a token writes a row for each scope, so a request that creates a token with two scopes runs more queries than one with one for reasons that have nothing to do with the response. The create N+1 example therefore creates its tokens through CreateGranularService first, and stubs the service in the request to return them, so that the requests it compares differ only in the number of scopes they present.
Each N+1 example also sets the administrator's last_activity_on to today first, as the work item request specs do. Users::ActivityService writes it behind a one-minute Redis lease that outlives the transaction each example rolls back, so otherwise the write can land on the measured request and not on the control.
I ran the specs locally on this branch (Ruby 3.3.11, PostgreSQL 17, Redis 7.2, Gitaly built):
spec/requests/api/users_spec.rbfiltered toimpersonation_tokens, every example under the four impersonation token routes: 90 examples, 0 failures, 8 pending. The pending ones are the granular-token shared examples excusing themselves ("namespace has no top-level group", "only meaningful on Project/Group boundaries"), two on each route, not anything of this change.spec/lib/api/helpers/personal_access_tokens_helpers_spec.rb, the helper's own spec: 5 examples, 0 failures.spec/requests/api/users_spec.rb, the one spec file this MR changes, in full, before I rebased the branch onto the currentmaster(whose new commits change none of the files this MR touches or relies on): 1271 examples, 1 failure, 144 pending. The failure isPOST /users/:id/emails creates unverified email, which fails the same way with this MR's files as onmaster: the confirmation mail's layout inlines a stylesheet that Sprockets compiles in a local test run, and my setup cannot compile it (LoadError: cannot load such file -- sass). CI turnsconfig.assets.compileoff and reads the precompiled asset instead.
To check that each new example tests what it says, I also ran them against five broken versions:
| Version | Result |
|---|---|
The three routes presenting without the options, as on master |
The eight examples that read granular_scopes fail (list, get, the four access types of create, selected_memberships, and the create N+1 example, which reads the project_id of each scope it presents); the key is absent |
| The helper passing the options without its preload | The three N+1 examples fail: 31 queries against a control of 29 on the list, 29 against 28 on the get, 32 against 31 on the create; the helper's own spec from !259764 (merged) fails as well |
| The helper preloading and passing the options for every token, legacy ones included | The legacy examples of the list and the get fail on a SELECT from personal_access_token_granular_scopes; two examples of the helper's own spec fail as well |
The helper preloading { granular_scopes: :namespace } in one step, the order its comment warns against |
The create N+1 example fails, 32 queries against 31, the extra one a namespaces lookup for the second scope; the helper's own spec fails as well |
The list relation with .preload_granular_scopes, as GET /personal_access_tokens has it |
The legacy list example fails, on a SELECT from personal_access_token_granular_scopes; the granular and N+1 examples pass |
rubocop reports no offenses on the two Ruby files, and scripts/lint/commit_linter.rb none on the commit message. In the image the docs lint jobs use (markdownlint-cli2 0.23.2, Vale 3.21.0), markdownlint-cli2 reports no issues on the page, and Vale reports the same two suggestions as on master, nothing at the warning level the job reports, and nothing on the lines this MR adds.
Where this comes from
I maintain gitlab-mcp-server, an MCP server that exposes the GitLab API to AI assistants, including the impersonation token routes for administrators. #632315 (closed) and #632314 (closed) were opened during the review of !259764 (merged), for the other token routes that do not return granular_scopes. The project keeps a record of what it finds in its dependencies in upstream-bugs.md; the entry No token struct carries the granular fields notes that gitlab-org/api/client-go!3063, which adds the granular fields to the Go client's token types, leaves granular_scopes off the impersonation token type because these routes do not send it.
Screenshots or screen recordings
Not applicable, this is an API response with no UI surface.
How to set up and validate locally
-
As an administrator (with the
admin_modescope on the token if Admin Mode is enabled), create an impersonation token for a user with a granular scope on a project the user is a member of:curl --request POST \ --header "PRIVATE-TOKEN: <your_admin_token>" \ --header "Content-Type: application/json" \ --data '{"name": "granular-impersonation", "granular_scopes": [{"access": "selected_memberships", "permissions": ["read_job"], "project_ids": [<project_id>]}]}' \ --url "http://127.0.0.1:3000/api/v4/users/<user_id>/impersonation_tokens" -
On
masterthe response has"granular": trueand nogranular_scopes. On this branch it hasgranular_scopes, with the project's ID inproject_id. -
Ask for the token through
GET /users/<user_id>/impersonation_tokens/<id>and in the list atGET /users/<user_id>/impersonation_tokens: the samegranular_scopeson this branch, none onmaster. -
A token created with
scopesreturns the same response on both.
The specs:
bundle exec rspec spec/requests/api/users_spec.rb -e 'impersonation_tokens'
bundle exec rspec spec/lib/api/helpers/personal_access_tokens_helpers_spec.rbMR acceptance checklist
- I have evaluated the MR acceptance checklist for this MR.
- Tests added for granular tokens on the list, get and create routes,
project_idandgroup_idon a project and a group scope, the N+1 case on each route, and a legacy token on each route. - Documentation updated: a history item and a note under each of the three sections in
doc/api/user_tokens.md. -
Changelog: fixedtrailer on the commit. - Specs run locally: 90 examples, 0 failures, 8 pending for
-e 'impersonation_tokens'inspec/requests/api/users_spec.rb, and 5 examples, 0 failures for the helper's spec. The changed spec file in full, before the last rebase ontomaster: 1271 examples, 144 pending, and 1 failure thatmasterhas too in my setup (an asset it cannot compile withoutsass). The rest of the suite is for the pipeline to speak for. - No new query for a legacy token on any of the three routes, and no N+1 for granular ones, both pinned by a spec.