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.
-
As an enterprise user of the group for whom the
granular_personal_access_tokensfeature flag is enabled, create a fine-grained personal access token, in the UI or through the API, authenticated with a token that has theapiscope: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. -
As an Owner of the group, list the tokens of its enterprise users and service accounts with
GET /groups/:id/manage/personal_access_tokens. -
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.