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
  1. 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 without granular_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 the Changelog: fixed and EE: true trailers and describes the change, so it is the right message if the MR is squashed.
  2. 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 of master too, which is what makes it characterization.
  3. Document granular_scopes in the credentials inventory token list: the section of doc/api/groups.md gets 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 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, the second because the page carries no granular_scopes at 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: true and no granular_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_scopes added to the finder chain: commit 2's example fails on a query on personal_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.rb whole: 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, as scripts/static-analysis runs 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.rb and the Danger one) on each commit: no problems. git diff --check: clean.
  • doc/api/groups.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

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.

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

Merge request reports

Loading
Loading