Include granular_scopes in group credentials inventory personal access token list API responses

Summary

The route of the group credentials inventory API that lists the personal access tokens of a group's enterprise users and service accounts presents each token with API::Entities::PersonalAccessToken and no options:

  • GET /groups/:id/manage/personal_access_tokens

That entity exposes granular_scopes only when the token is granular and options[:with_granular_scopes] is set. So a fine-grained token is listed with granular: true and scopes: ["granular"], and nothing says what the token can access.

GET /personal_access_tokens already passes the options from granular_scopes_options_for, and so does the rotate route of this same API class (POST /groups/:id/manage/personal_access_tokens/:id/rotate, since !260276 (merged)). The credentials inventory page of the group shows the permissions and access of each fine-grained token (ee/app/views/shared/credentials_inventory/personal_access_tokens/_scopes.html.haml:1-20), so the API is where an Owner cannot read them. !261673 (merged) (#633317 (closed)) fixed the same gap in the two service account token lists.

Steps to reproduce

The route answers on GitLab.com only, where the group credentials inventory is available.

  1. As an enterprise user of the group for whom the granular_personal_access_tokens feature flag is enabled, create a fine-grained personal access token, in the UI or through the API, authenticated with a token that has the api scope:

    curl --request POST \
      --header "PRIVATE-TOKEN: <enterprise_user_token>" \
      --header "Content-Type: application/json" \
      --data '{"name": "fine-grained", "granular_scopes": [{"access": "all_memberships", "permissions": ["read_job"]}]}' \
      --url "https://gitlab.com/api/v4/user/personal_access_tokens"

    The response of this route includes granular_scopes.

  2. As an Owner of the group, list the tokens of its enterprise users and service accounts with GET /groups/:id/manage/personal_access_tokens.

  3. Compare with the credentials inventory page of the group, which lists the same token with its permissions and access.

I found this by reading the code on master and checked the response with a request spec, shown under "Relevant logs" below. I have not run these steps on GitLab.com, where I do not own a group with enterprise users.

Example Project

Not applicable: any group whose enterprise users or service accounts hold a fine-grained token shows it.

What is the current bug behavior?

Each fine-grained token in the list has granular: true and scopes: ["granular"], but no granular_scopes. The same token is presented with its granular_scopes by GET /personal_access_tokens and by the rotate route of the same class.

What is the expected correct behavior?

Each fine-grained token in the list has granular_scopes, with project_id and group_id resolved as on the other routes. Tokens that are not fine-grained are presented as they are today, and the route runs no extra query for them.

Relevant logs and/or screenshots

From a throwaway request spec on master: an enterprise user of the group holds a fine-grained token scoped to a project (ID 18 in that run), and an Owner of the group lists the tokens. The route answers for that token:

{"id":300,"name":"fine-grained","revoked":false,"created_at":"2026-10-17T10:37:15.789Z","description":"Token description","scopes":["granular"],"user_id":206,"last_used_at":null,"last_used_ips":[],"active":true,"granular":true,"expires_at":"2026-11-16"}

With the route presenting the page with the options granular_scopes_options_for returns, the same token carries its scopes (the project's ID was 19 in that run):

{"id":304,"name":"fine-grained","revoked":false,"created_at":"2026-10-17T10:38:46.404Z","description":"Token description","scopes":["granular"],"user_id":210,"last_used_at":null,"last_used_ips":[],"granular_scopes":[{"access":"selected_memberships","permissions":["read_job"],"project_id":19,"group_id":null}],"active":true,"granular":true,"expires_at":"2026-11-16"}

Output of checks

Found on master at 509b0502. I have not reproduced it on GitLab.com.

Possible fixes

The list route ends with present paginate(tokens, skip_default_order: true), with: Entities::PersonalAccessToken and passes no options (ee/lib/api/manage/groups.rb:87).

The entity exposes granular_scopes only with options[:with_granular_scopes] (lib/api/entities/personal_access_token.rb:25-27).

The fix is to present the page with **granular_scopes_options_for(page), as GET /personal_access_tokens does (lib/api/personal_access_tokens.rb:42-45) and the rotate route of the same class does (ee/lib/api/manage/groups.rb:141-142):

paginated_tokens = paginate(tokens, skip_default_order: true)

present paginated_tokens, with: Entities::PersonalAccessToken,
  **granular_scopes_options_for(paginated_tokens)

The class already includes API::Helpers::PersonalAccessTokensHelpers (ee/lib/api/manage/groups.rb:17), which brings in the helper (lib/api/helpers/authz/granular_scopes_helpers.rb:56-69). It preloads the scopes and namespaces of the granular tokens on the page. For a page with no granular token it returns no options and runs no query.

The request spec of the route would cover a granular token listed with its granular_scopes (with project_id for a project scope), a legacy token listed without the key and with no query on the granular scopes tables, and a query count that does not grow with the number of granular tokens. The section of doc/api/groups.md would get a history item and the note the service account token lists carry.

I'd like to work on this.

Edited by 🤖 GitLab Bot 🤖