Return granular_scopes when rotating a service account token

What does this MR do and why?

The two routes below rotate a service account's personal access token, which can be a fine-grained one, and present the new token with API::Entities::PersonalAccessTokenWithToken and no options. That entity inherits from API::Entities::PersonalAccessToken, which exposes granular_scopes only when the token is granular and with_granular_scopes is passed:

  • POST /groups/:id/service_accounts/:user_id/personal_access_tokens/:token_id/rotate
  • POST /projects/:id/service_accounts/:user_id/personal_access_tokens/:token_id/rotate

Both rotate through PersonalAccessTokens::RotateService, which copies the granular scopes of the old token to the new one. So a caller who rotates a service account's fine-grained token is told that the new token is granular (granular: true, scopes: ["granular"]) and not which scopes it holds, while POST /personal_access_tokens/:id/rotate already returns them for the same rotation.

This MR passes the options from granular_scopes_options_for on both routes, as !260276 (merged) did on the user create, self-rotate and enterprise group rotate routes. Both API classes already include API::Helpers::PersonalAccessTokensHelpers. Given a token that is not granular, the helper returns no options and runs no query, so a legacy token is presented 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 a service account can hold: the service account routes that create a token still accept only scopes.

For review I would suggest eduardosanz, who opened the issue.

References

Closes #632896 (closed)

  • !260276 (merged), merged on 2026-10-08 for #632314 (closed), is the same fix for POST /user/personal_access_tokens, POST /personal_access_tokens/self/rotate and the GitLab.com route that rotates an enterprise user's token, and !260277 (merged), merged the same day for #632315 (closed), is the same fix for the impersonation token routes.
  • !259764 (merged) added granular_scopes_options_for.
  • #573355 discusses fine-grained tokens for service accounts, and how a service account comes to hold one today.
  • The routes that list a service account's tokens (GET /groups/:id/service_accounts/:user_id/personal_access_tokens and GET /projects/:id/service_accounts/:user_id/personal_access_tokens, in the same two files) present each token with API::Entities::PersonalAccessToken and no options, so a fine-grained token they list does not show its scopes either. #632896 (closed) covers rotation only, so I left them out of this MR, and can open an issue for them or add them here if you prefer.
What changed, file by file
  • lib/api/group_service_accounts.rb and lib/api/project_service_accounts.rb: the rotate route presents the new token with **granular_scopes_options_for([new_token]).
  • spec/requests/api/group_service_accounts_spec.rb and spec/requests/api/project_service_accounts_spec.rb: the cases under "Tests" below, in the existing rotate block of each file.
  • doc/api/service_accounts.md ("Rotate a personal access token for a group service account" and "Rotate a personal access token for a project service account"): a history item in the form !260276 (merged) used, and after each example response the note idurham suggested on !260276 (merged) for the enterprise rotate route, which points to the description of the attribute in List all personal access tokens. The history items say 19.5, as eduardosanz suggested on this MR for a merge before the 19.5 cutoff on 2026-10-12, and as the lines of !260276 (merged) and !260277 (merged) say since !260930 (merged).

Nothing changes in the OpenAPI documents: the success model of both routes is the same entity, 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 with both routes as on master too, and the document it generates is byte for byte the same with and without this MR.

Tests

New examples, in the rotate block of each spec file, which signs in as an administrator as the existing examples there do:

  • A legacy token: no granular_scopes key, and any query that touches granular scopes is the one RotateService runs on every rotation (SELECT 1 AS one FROM "granular_scopes", asking whether the old token has scopes to copy). The example allows that query without requiring it, so a change to the service alone does not fail it.
  • A granular token with a project scope: the new token carries the scope, with that project's project_id. On the group route the project belongs to the group, and on the project route it is the service account's own project.
  • N+1: a new token with two project scopes is presented with no more queries than one with one.

Rotating a token writes a row for each scope it copies, so a request that rotates a token with two scopes runs more queries than one with one for reasons that have nothing to do with the response. As in !260276 (merged), each N+1 example therefore rotates its tokens through the service 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. It also sets the administrator's last_activity_on to today and the last_used_at of the token the requests share to now, because Users::ActivityService and PersonalAccessTokens::LastUsedService write those 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 (Ruby 3.3.11, PostgreSQL 17, Redis 7.2, Gitaly built), on this branch as it is now:

  • The rotate examples of both spec files, with the command under "How to set up and validate locally": 16 examples, 0 failures, 2 pending.
  • The two spec files this MR changes and their EE counterparts (ee/spec/requests/api/group_service_accounts_spec.rb and ee/spec/requests/api/project_service_accounts_spec.rb, which cover the same routes for group and project Owners), in full: 391 examples, 0 failures, 18 pending.
  • spec/lib/api/helpers/personal_access_tokens_helpers_spec.rb, the helper's own spec: 5 examples, 0 failures.

The pending ones are the granular-token shared examples excusing themselves ("namespace has no top-level group", "public-access bypass only applies to public resources"), not anything of this change.

To check that each new example tests what it says, I also ran the rotate examples of both files against three broken versions:

Version Result
Both routes presenting without the options, as on master The four examples that read granular_scopes fail (the project scope example and the N+1 example on each route); the key is absent
The helper passing the options without its preload The two N+1 examples fail: 25 queries against a control of 24 on the group route, 23 against 22 on the project route
The helper preloading and passing the options for every token, legacy ones included The two legacy examples fail on a SELECT from personal_access_token_granular_scopes

rubocop reports no offenses on the four Ruby files, and scripts/lint/commit_linter.rb and the Danger commit linter of gitlab-dangerfiles 4.12.0 none on the commit message. markdownlint-cli2 0.23.2 and Vale 3.21.0, in the image the docs-lint markdown job uses, report nothing on the page.

Where this comes from

I maintain gitlab-mcp-server, an MCP server that exposes the GitLab API to AI assistants, including the group and project service account token routes. #632896 (closed) was opened as the follow-up of !260276 (merged), for the two routes that MR left out. 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 is where the server reads granular_scopes from a token response the Go client does not decode.

Screenshots or screen recordings

Not applicable, this is an API response with no UI surface.

How to set up and validate locally

  1. Create a group service account (POST /groups/:id/service_accounts) and add it as a member of a project in the group, so that it can hold a scope on that project.

  2. Give the service account a fine-grained token. The service account routes that create a token accept only scopes, so as an administrator create a fine-grained impersonation token for it:

    curl --request POST \
      --header "PRIVATE-TOKEN: <admin_token>" \
      --header "Content-Type: application/json" \
      --data '{"name": "sa-granular", "granular_scopes": [{"access": "selected_memberships", "permissions": ["read_job"], "project_ids": [<project_id>]}]}' \
      --url "http://127.0.0.1:3000/api/v4/users/<service_account_id>/impersonation_tokens"
  3. Rotate it through the service account route:

    curl --request POST \
      --header "PRIVATE-TOKEN: <your_access_token>" \
      --url "http://127.0.0.1:3000/api/v4/groups/<group_id>/service_accounts/<service_account_id>/personal_access_tokens/<token_id>/rotate"
  4. On master the response has "granular": true and no granular_scopes. On this branch it has granular_scopes, with the project's ID in project_id. The project route (/projects/<project_id>/service_accounts/...) answers the same way for a project service account.

  5. A token with scopes returns the same response on both.

The specs:

bundle exec rspec spec/requests/api/group_service_accounts_spec.rb spec/requests/api/project_service_accounts_spec.rb -e 'personal_access_tokens/:token_id/rotate'

MR acceptance checklist

  • I have evaluated the MR acceptance checklist for this MR.
  • Tests added for a granular token on both routes, project_id on a project 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 two sections.
  • Changelog: fixed trailer on the commit.
  • Specs run locally: 16 examples, 0 failures, 2 pending for the rotate examples of both routes; 391 examples, 0 failures, 18 pending for the CE and EE spec files of both APIs in full; 5 examples, 0 failures for the helper. The rest of the suite is for the pipeline to speak for.
  • No new query for a legacy token on either route, and no N+1 for granular ones, both pinned by a spec.
Edited by José M. Requena Plens

Merge request reports

Loading
Loading