Return granular_scopes when listing service account tokens
What does this MR do and why?
The two routes that list a service account's personal access tokens present each token with API::Entities::PersonalAccessToken and no options:
GET /groups/:id/service_accounts/:user_id/personal_access_tokensGET /projects/:id/service_accounts/:user_id/personal_access_tokens
That entity exposes granular_scopes only when the token is granular and with_granular_scopes is passed. So a fine-grained token of a service account is listed with "granular": true and "scopes": ["granular"], and nothing in the list says what the token can access, while GET /personal_access_tokens and, since !260929 (merged), the rotate routes of these same two API classes present those scopes.
This MR presents each page of both list routes with the options granular_scopes_options_for returns for it, as GET /personal_access_tokens does. Both API classes reach the helper through API::Helpers::PersonalAccessTokensHelpers, which they already include, and which includes API::Helpers::Authz::GranularScopesHelpers since that module was extracted on master. The helper preloads the scopes of the granular tokens on the page and their namespaces, and resolves project_id for a scope on a project. Given a page with no granular token, it returns no options and runs no query, so a token that is not granular is listed exactly as before.
The field is additive and appears only for tokens with granular scopes. Nothing changes in who may call the two routes, or in which tokens they list. Writing the N+1 check turned up a query on users per granular token that has nothing to do with granular_scopes and is there on master too: the first one reaches the database and the query cache answers the rest, and the check counts both. So the routes now also preload the users of the tokens they list (commit 3), as GET /personal_access_tokens and GET /users/:user_id/impersonation_tokens do. That costs a page with no granular token one more database query, on users, than master runs, and a page with granular tokens runs as many database queries as it does without the preload.
The commits:
- Return granular_scopes when listing service account tokens: the two routes, and a shared example both request specs include, beside the one for the rotate routes: a granular token scoped to a project is listed with that scope and the project's
project_id, next to a token that is not granular and is still listed withoutgranular_scopes. It carries theChangelog: fixedtrailer and describes the change, so it is the right message if the MR is squashed. - Pin how service account token lists present legacy tokens: the shared example gains an example for a page with no granular token: no
granular_scopes, and no query on the granular scopes tables. It passes with the route files ofmastertoo, which is what makes it characterization. - Preload the users of listed service account tokens: a granular token is
activeonly while thegranular_personal_access_tokensflag is enabled for its user, so presenting a page loaded the service account once per granular token on it, from the database the first time and from the query cache after that. Both routes nowpreload_users, asGET /personal_access_tokensdoes, which costs a page with no granular token one query onusersthatmasterdoes not run. The shared example gains an N+1 check: a page with one more granular token, holding two scopes on two projects, runs no more queries than a page with one granular token holding one scope. Without the preload it fails on the users query alone; withoutgranular_scopes_options_forit also fails on the scopes, their namespaces and the projects. - Document granular_scopes in the service account token lists:
doc/api/service_accounts.md, a history item and the note the rotate sections carry, in the two list sections. The history items say 19.6, since this MR opens after the 19.5 cutoff of 2026-10-12.
I picked this up because my gitlab-mcp-server lists a service account's tokens through these two routes and publishes granular_scopes for each token, which the lists never sent.
References
Closes #633317 (closed)
- !260929 (merged) made the same change for the rotate routes of these two API classes. Its description noted the list routes as left out, and offered an issue for them, which is the one this MR closes.
- !261218 (merged) moved the rotate examples into a shared example, as
eduardosanzasked on !260929 (merged). The list examples follow the same shape, in a shared example of their own beside it.
Screenshots or screen recordings
Not a UI change. What each list route answers for a fine-grained token of a service account, from a throwaway request spec (not part of this MR) in which the service account creates the token for itself through POST /user/personal_access_tokens and an administrator then lists its tokens, run with the route files of master and of this branch:
| Route | Before | After |
|---|---|---|
GET /groups/:id/service_accounts/:user_id/personal_access_tokens |
"scopes": ["granular"], "granular": true, no granular_scopes |
the same, and "granular_scopes": [{"access": "all_memberships", "permissions": ["read_job"], "project_id": null, "group_id": null}] |
GET /projects/:id/service_accounts/:user_id/personal_access_tokens |
"scopes": ["granular"], "granular": true, no granular_scopes |
the same, and "granular_scopes": [{"access": "all_memberships", "permissions": ["read_job"], "project_id": null, "group_id": null}] |
POST /user/personal_access_tokens answered 201 with those scopes for both service accounts, so this is a token a service account can create for itself today.
Checks I ran
All on top of master at 0b084acff6c, in the test environment, with bundle exec rspec:
- Commit 1's specs with the two route files of
master: the new example fails on both routes (2 examples, 2 failures), withexpected a collection that can be converted to an array with #to_ary or #to_a, but got nilfor the token'sgranular_scopes. - Commit 2's example with the two route files of
master: 2 examples, 0 failures, which is what makes it characterization. - Commit 3's specs with the route files of commit 2: the N+1 example fails on both routes, on the users query alone (group route: a maximum of 29 queries, got 30; project route: 28, got 29).
- The branch with route files that keep the preload of the users but present with
with_granular_scopes: trueand nogranular_scopes_options_for: the N+1 example fails on both routes (a maximum of 28 queries, got 33, on the group route; 27, got 32, on the project route), on the queries of the granular scopes, of their namespaces and of the projects. - Each commit, filtered to the two list describes: commit 1, 10 examples, 0 failures; commit 2, 12 examples, 0 failures; commit 3, 14 examples, 0 failures. Two of them are pending on every commit, skips inside
authorizing granular token permissionsthat are pending onmastertoo. - The branch:
spec/requests/api/group_service_accounts_spec.rbandspec/requests/api/project_service_accounts_spec.rbwhole, 177 examples, 0 failures, 18 pending (the same two skips, in every route that includes that shared example);ee/spec/requests/api/group_service_accounts_spec.rbandee/spec/requests/api/project_service_accounts_spec.rbwhole, 220 examples, 0 failures. - A throwaway spec (not part of this MR) counting the database queries of one list request, for a page with two tokens that are not granular and then with two granular tokens added: 17 and 18 on
master, 17 and 22 with the route files of commit 2, 18 and 22 on this branch, the same on both routes. bin/rake gitlab:openapi:v3:check_docs, asscripts/static-analysisruns it, on the branch: the OpenAPI v3 document is up to date, since neither route changes its description, parameters or success entity.- RuboCop on the Ruby files of each commit: no offenses. Both commit message linters (
scripts/lint/commit_linter.rband the Danger one) on each commit: no problems.git diff --check: clean. doc/api/service_accounts.mdwith the image of the docs lint jobs:markdownlint-cli2,scripts/lint-doc.shandlychee --offline, no errors; Vale reports nothing on the changed lines, and elsewhere on the page the findingsmasterhas.
How to set up and validate locally
-
Create a group service account (
POST /groups/:id/service_accounts) and a personal access token for it with theapiscope (POST /groups/:id/service_accounts/:user_id/personal_access_tokens). -
Authenticated as the service account with that token, create a fine-grained token for it:
curl --request POST \ --header "PRIVATE-TOKEN: <service_account_token>" \ --header "Content-Type: application/json" \ --data '{"name": "fine-grained", "granular_scopes": [{"access": "all_memberships", "permissions": ["read_job"]}]}' \ --url "http://gdk.test:3000/api/v4/user/personal_access_tokens" -
As the group Owner or an administrator, list the service account's tokens:
curl --request GET \ --header "PRIVATE-TOKEN: <your_access_token>" \ --url "http://gdk.test:3000/api/v4/groups/<group_id>/service_accounts/<service_account_id>/personal_access_tokens" -
On
masterthe fine-grained token is listed with"granular": trueand nogranular_scopes. On this branch it carriesgranular_scopes. The project route (/projects/<project_id>/service_accounts/...) answers the same way for a project service account.
The specs:
bundle exec rspec spec/requests/api/group_service_accounts_spec.rb spec/requests/api/project_service_accounts_spec.rb -e 'service account token list presenting granular scopes'MR acceptance checklist
Evaluate this MR against the MR acceptance checklist. It helps you analyze changes to reduce risks in quality, performance, reliability, security, and maintainability.