Return granular_scopes in the credentials inventory token list
What does this MR do and why?
The group credentials inventory route 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 with_granular_scopes is passed. So a fine-grained token is listed with "granular": true and "scopes": ["granular"], and nothing in the list says what the token can access, while GET /personal_access_tokens, the rotate route of this same API class (since !260276 (merged)) and the credentials inventory page of the group present those scopes.
This MR presents each page of the route with the options granular_scopes_options_for returns for it, as GET /personal_access_tokens does and as !261673 (merged) did for the two service account token lists. The class reaches the helper through API::Helpers::PersonalAccessTokensHelpers, which it already includes. 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 route, or in which tokens it lists. The route already preloads the users of the tokens it lists, so unlike !261673 (merged) it needs no other preload.
GET /personal_access_tokens also puts .preload_granular_scopes on its finder chain. I left that out on purpose: the helper already preloads the scopes of the granular tokens on the page, and the chain preload would add a query on personal_access_token_granular_scopes to every page, a page with no granular token included. With it, the second commit's example fails on exactly that query.
The commits
- Return granular_scopes in the credentials inventory token list: the route, and two examples in its request spec. 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. An N+1 check adds a granular token holding two scopes on two projects to a page that holds one granular token with one scope, and the page runs no more queries. It carries theChangelog: fixedandEE: truetrailers and describes the change, so it is the right message if the MR is squashed. - Pin how the credentials inventory lists legacy tokens: 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 file ofmastertoo, which is what makes it characterization. - Document granular_scopes in the credentials inventory token list: the section of
doc/api/groups.mdgets a history item and the note the service account token lists carry. The history item says 19.6.
I picked this up because my gitlab-mcp-server lists a group's credentials inventory through this route, and the token list it returns cannot say what a fine-grained token can access while the route does not send it. That project keeps a record of everything it finds and contributes on its dependencies and on sibling projects in upstream-bugs.md.
References
Closes #634390 (closed)
- !261673 (merged) made the same change for the two service account token lists, with the same examples in a shared example.
- !260276 (merged) made it for the rotate route of this class,
POST /groups/:id/manage/personal_access_tokens/:id/rotate.
Screenshots or screen recordings
Not a UI change. What the route answers for a fine-grained token of an enterprise user, scoped to one project, from a throwaway request spec (not part of this MR) run with the route file of master and of this branch:
| Route file | Answer for the fine-grained token |
|---|---|
master |
"scopes": ["granular"], "granular": true, no granular_scopes |
| This branch | the same, and "granular_scopes": [{"access": "selected_memberships", "permissions": ["read_job"], "project_id": 19, "group_id": null}], where 19 is the project's ID |
A token that is not granular is answered the same way on both.
Checks I ran
All on top of master at 509b0502d12, in the test environment, with bundle exec rspec unless stated otherwise:
- Commit 1's examples with the route file of
master: both fail (2 examples, 2 failures). The first fails withexpected a collection that can be converted to an array with #to_ary or #to_a, but got nilfor the token'sgranular_scopes, the second because the page carries nogranular_scopesat all. - Commit 2's example with the route file of
master: 1 example, 0 failures, which is what makes it characterization. - The branch with a route that presents with
with_granular_scopes: trueand nogranular_scopes_options_for: the N+1 example fails (a maximum of 31 queries, got 36), on the queries of the granular scopes, of their namespaces and of the projects. - The branch with
.preload_granular_scopesadded to the finder chain: commit 2's example fails on a query onpersonal_access_token_granular_scopes; every other example of the route passes. - Commit 1, the describe of the route: 29 examples, 0 failures. The branch,
ee/spec/requests/api/manage/groups_spec.rbwhole: 159 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: 20 and 20 on
master, 20 and 24 on this branch, and 21 and 24 with the chain preload as well. The four queries the branch adds are the helper's: the join rows and the scopes, their namespaces, and the IDs of the projects. bin/rake gitlab:openapi:v3:check_docs, asscripts/static-analysisruns it: the OpenAPI v3 document is up to date, since the route does not change its description, parameters or success entity.- RuboCop on the Ruby files: 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/groups.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
The route answers only when the instance runs as GitLab.com, so start GDK with GITLAB_SIMULATE_SAAS=1 and use a top-level group on Ultimate. A group service account is the quickest token owner: the route lists the tokens of the group's service accounts as well as those of its enterprise users.
-
As the group Owner, 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, list the group's tokens:
curl --request GET \ --header "PRIVATE-TOKEN: <your_access_token>" \ --url "http://gdk.test:3000/api/v4/groups/<group_id>/manage/personal_access_tokens" -
On
masterthe fine-grained token is listed with"granular": trueand nogranular_scopes. On this branch it carriesgranular_scopes.
The specs:
bundle exec rspec ee/spec/requests/api/manage/groups_spec.rb -e 'GET /groups/:id/manage/personal_access_tokens'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.