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_tokens
  • GET /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:

  1. 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 without granular_scopes. It carries the Changelog: fixed trailer and describes the change, so it is the right message if the MR is squashed.
  2. 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 of master too, which is what makes it characterization.
  3. Preload the users of listed service account tokens: a granular token is active only while the granular_personal_access_tokens flag 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 now preload_users, as GET /personal_access_tokens does, which costs a page with no granular token one query on users that master does 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; without granular_scopes_options_for it also fails on the scopes, their namespaces and the projects.
  4. 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 eduardosanz asked 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), with expected a collection that can be converted to an array with #to_ary or #to_a, but got nil for the token's granular_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: true and no granular_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 permissions that are pending on master too.
  • The branch: spec/requests/api/group_service_accounts_spec.rb and spec/requests/api/project_service_accounts_spec.rb whole, 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.rb and ee/spec/requests/api/project_service_accounts_spec.rb whole, 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, as scripts/static-analysis runs 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.rb and the Danger one) on each commit: no problems. git diff --check: clean.
  • doc/api/service_accounts.md with the image of the docs lint jobs: markdownlint-cli2, scripts/lint-doc.sh and lychee --offline, no errors; Vale reports nothing on the changed lines, and elsewhere on the page the findings master has.

How to set up and validate locally

  1. Create a group service account (POST /groups/:id/service_accounts) and a personal access token for it with the api scope (POST /groups/:id/service_accounts/:user_id/personal_access_tokens).

  2. 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"
  3. 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"
  4. On master the fine-grained token is listed with "granular": true and no granular_scopes. On this branch it carries granular_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.

Merge request reports

Loading
Loading