Accept granular_scopes when an admin creates a personal access token for a user

What does this MR do and why?

POST /users/:user_id/personal_access_tokens takes only legacy scopes, and requires them, so an administrator cannot create a fine-grained personal access token for another user through the REST API. The other two token create routes already take granular_scopes: POST /user/personal_access_tokens since !243428 (merged) and POST /users/:user_id/impersonation_tokens since !243990 (merged). The workaround #630541 names, an impersonation token with granular_scopes, does not show up on the user's own access tokens page. On an instance that enforces fine-grained tokens the route cannot create any token for a human user: PersonalAccessTokens::CreateService refuses a legacy token there (granular_tokens_enforced?), and the route has no other kind to offer.

This MR implements the proposal in #630541:

  • scopes becomes optional, and the route requires exactly one of scopes and granular_scopes with exactly_one_of, as POST /user/personal_access_tokens does.
  • With granular_scopes, the route answers 404 when granular_personal_access_tokens is disabled for the user the token is for, builds the scopes for that user with build_granular_scopes, and creates the token with create_granular_token(..., target_user:), as the impersonation route does. build_granular_scopes goes through the Authz::GranularScopes::Builder that !259286 (merged) shared, with a ReadBoundaryRule for that user, so with selected_memberships the user must be able to use each listed group and project as a boundary, or the route answers 404. create_granular_token runs PrivilegeEscalationCheck against the calling token, so a fine-grained caller cannot grant more than it holds.
  • The new token is presented with **granular_scopes_options_for([personal_access_token]), the helper !259764 (merged) added, so a fine-grained token comes back with its granular_scopes, as the impersonation routes do since !260277 (merged) and POST /user/personal_access_tokens does since !260276 (merged). For a token created with scopes the helper returns no options and runs no query, so that response is what it was.
  • The user is looked up once per request, and the result is passed to every step that needs it.
  • An error answers with the service response's http_status, then its reason, then 422. The escalation check returns reason: :forbidden, so a refused escalation answers 403, as on the impersonation route. Every other refusal answers 422 as before, because neither PersonalAccessTokens::CreateService nor Authn::PersonalAccessTokens::CreateGranularService sets either field.

The change adds no feature flag of its own: the new path sits behind the existing granular_personal_access_tokens flag, checked for the user the token is for, as on the impersonation route.

A request that passes only scopes is served as before. A request that passes neither parameter is told scopes, granular_scopes are missing, exactly one parameter must be provided instead of scopes is missing. A request that passes both is now refused with 400 and scopes, granular_scopes are mutually exclusive; on master the route does not declare granular_scopes, so it drops them and creates a legacy token from scopes, which is the one request that worked before and is now refused.

doc/api/user_tokens.md documents granular_scopes for this route, with an example request and a note after the example response that points to List all personal access tokens for the attribute, as the impersonation sections do. The issue also asks to document the parameter on "Create an impersonation token", which takes it since 19.2 and does not list it, and I did the same for "Create a personal access token", where it is undocumented as well. Each of those two sections gets a history item naming the MR that added the parameter. doc/api/openapi/openapi_v3.yaml is regenerated.

The impersonation table still marks expires_at as required, which the description of !260277 (merged) pointed out, while the route declares it optional. That row is not about granular_scopes, so I left it as it is here.

The history item for this route says 19.5, the milestone of this MR, as the items of !260276 (merged) and !260277 (merged) on the same page do since !260930 (merged).

References

Closes #630541

  • !243428 (merged) and !243990 (merged) added granular_scopes to POST /user/personal_access_tokens and to POST /users/:user_id/impersonation_tokens. This route now follows them.
  • !259286 (merged) shared Authz::GranularScopes::Builder, which build_granular_scopes uses.
  • !259764 (merged) added granular_scopes_options_for, and !260277 (merged) and !260276 (merged) used it on the impersonation token routes, on POST /user/personal_access_tokens and on two rotate routes. This branch is rebased on master with both merged. The description of !260276 (merged) lists the undocumented granular_scopes parameter of "Create a personal access token" as a gap it left out, and this MR documents it.
  • !245585 (closed), closed by its author on 2026-08-24, attempted this route before. This MR addresses the review points it was left with: it looks the user up once, links the permissions to the fine-grained permissions reference, and adds no history item that is not about granular_scopes. That MR also emitted an audit event when the escalation check refuses a grant, which I left out: no route that calls create_granular_token emits one today, because the check runs before PersonalAccessTokens::CreateService, which is what EE audits, and the review there suggested that such an event belongs in a layer shared with the GraphQL mutation.
What changed, file by file
  • lib/api/users.rb: the params block of POST /users/:user_id/personal_access_tokens makes scopes optional, adds use :granular_scope_params and exactly_one_of :scopes, :granular_scopes; the handler looks the user up once, branches on params[:granular_scopes] as the impersonation route does, presents the token with granular_scopes_options_for, and renders an error with response.http_status || response.reason || :unprocessable_entity. The route's detail says to use granular_scopes instead of scopes to create a fine-grained personal access token, as Isaac Durham suggested in review.
  • spec/requests/api/users_spec.rb: the examples under "Tests" below, and the expected message of the existing "returns error if required attributes are missing" example.
  • doc/api/user_tokens.md: the three create sections, as described above.
  • doc/api/openapi/openapi_v3.yaml: regenerated by bin/rake gitlab:openapi:v3:generate; the request body of this route gains granular_scopes, scopes is no longer required, and the route's description carries the new detail.
Tests

New and changed examples in spec/requests/api/users_spec.rb, under POST /users/:user_id/personal_access_tokens:

  • With granular_scopes, for each of user, instance, all_memberships and personal_projects: the token is created for the user, is granular, is not an impersonation token, holds the one scope, and the response carries it in granular_scopes.
  • personal_projects binds the scope to the user's namespace, not the administrator's.
  • selected_memberships with a project and a group the user is a member of: the token holds one scope for each, and the response returns project_id and group_id for each.
  • selected_memberships with a private group the user cannot read: 404, and no token is created.
  • N+1: a token with two project scopes is presented with no more queries than one with one. The tokens are created through CreateGranularService before the requests, and each request stubs the service to return its token, so the two requests differ only in the number of scopes they present.
  • The service receives the administrator as current_user, the user as target_user, and creation_source: api; the user, authenticated as themselves, finds the new token in GET /personal_access_tokens; the user is looked up once.
  • Both parameters: 400 with Grape's message. Neither: 400 with Grape's message. An empty granular_scopes array: 422 from the service.
  • granular_personal_access_tokens enabled for the administrator only: 404.
  • A user that does not exist: 404.
  • A fine-grained calling token with create_personal_access_token on the instance: 403 when the new token asks for a permission the caller does not hold, and created when it asks for one it holds.
  • An instance that enforces fine-grained tokens: a legacy token for the user is refused with 422 and the service's message, and a fine-grained one is created.
  • With scopes: the response has no granular_scopes key, and the request runs no query that touches the granular scope tables.

I ran the specs locally (Ruby 3.3.11, PostgreSQL 17, Redis 7.2) on this branch on top of master at a20857c3, and the first of these runs again after rebasing it onto master at 42a3c077, with the same result:

  • spec/requests/api/users_spec.rb -e 'POST /users/:user_id/personal_access_tokens': 37 examples, 0 failures, 2 pending.
  • The same examples with lib/api/users.rb as master has it: 19 failures. Fifteen requests that send granular_scopes alone are answered 400 with scopes is missing; the one that sends both parameters creates a legacy token and ignores granular_scopes; the N+1 example finds no granular_scopes in the response; the service example sees CreateGranularService never called; and the missing-parameters example reads name is missing, scopes is missing. The two new examples that pass on master are the guards for what must not change: a token created with scopes runs no granular scope query, and an instance that enforces fine-grained tokens still refuses a legacy one.

The pending ones are the granular-token shared examples excusing themselves on an instance boundary ("namespace has no top-level group", "only meaningful on Project/Group boundaries"), as they do with the route as master has it.

To check that each new example tests what it says, I also ran them against broken versions of the route, in two runs that each carried several of the changes below, and ran the lookup example once more, on its own, against the first run's version; every failure is the one the change in its row produces:

Version Result
The user looked up through target_user at each use "looks the user up once" fails: User.find_by called twice for the user (in that run the flag check read the administrator, which left two uses that read the user)
The feature flag checked for the administrator instead of the user the flag example fails: 201 instead of 404
The error rendered without response.reason the escalation example fails: 422 instead of 403
The scopes built for the administrator instead of the user personal_projects binds the administrator's namespace, and the private group the user cannot read is accepted (201, a token created)
The token presented without granular_scopes_options_for every example that reads granular_scopes fails, the four access types, selected_memberships and the N+1 example among them
mutually_exclusive in place of exactly_one_of the missing-parameters example fails: name is missing only

Presenting every token with with_granular_scopes: true does not fail the two examples with scopes, because the entity exposes granular_scopes only for a granular token; they pin that a token created with scopes comes back and is loaded as before.

rubocop reports no offenses on lib/api/users.rb and spec/requests/api/users_spec.rb, and scripts/lint/commit_linter.rb none on the two commits I wrote. It skips the suggestion commit by its subject, and the gitlab-dangerfiles linter it wraps reports nothing on that one when run on it directly. markdownlint-cli2 0.23.2 and lychee --offline --include-fragments in the docs-lint image report nothing on doc/api/user_tokens.md, and Vale 3.21.0 reports the same two suggestions on the page as master has it: the page's reading level (8.45 here, 9.23 on master) and an "as well as" on a line this MR does not change. Of the OpenAPI tasks scripts/static-analysis runs, gitlab:openapi:v3:check_docs reports the regenerated v3 document up to date and gitlab:openapi:v3:validate_tag_docs reports the API tag content valid.

Where this comes from

I maintain gitlab-mcp-server, an MCP server that exposes the GitLab API to AI assistants, including an action that creates a personal access token for a user through this route. The project keeps a record of what it finds in its dependencies in upstream-bugs.md; the entry The admin token route takes no granular scopes is this gap.

Screenshots or screen recordings

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

How to set up and validate locally

  1. The granular_personal_access_tokens feature flag is enabled by default. If it was disabled on your instance, enable it in the Rails console, for the user the token will be for or for everyone:

    Feature.enable(:granular_personal_access_tokens)
  2. As an administrator, create a fine-grained token for another user, on a project that user is a member of:

    curl --request POST \
      --header "PRIVATE-TOKEN: <admin_access_token>" \
      --header "Content-Type: application/json" \
      --data '{"name": "granular-for-user", "granular_scopes": [{"access": "selected_memberships", "permissions": ["read_job"], "project_ids": [<project_id>]}]}' \
      --url "http://127.0.0.1:3000/api/v4/users/<user_id>/personal_access_tokens"
  3. On master the request is refused with 400 and scopes is missing. On this branch it answers 201, with "granular": true and the scope in granular_scopes, with the project's ID in project_id.

  4. Authenticated as that user, GET /personal_access_tokens lists the new token, which it does not do for an impersonation token.

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

The specs:

bundle exec rspec spec/requests/api/users_spec.rb -e 'POST /users/:user_id/personal_access_tokens'

MR acceptance checklist

  • I have evaluated the MR acceptance checklist for this MR.
  • Tests added for every access type, the user each scope is built for, the boundary refusal, the feature flag, the escalation check, the enforcement setting, the N+1 case, and a legacy request.
  • Documentation updated for this route and for the two create routes whose granular_scopes parameter was undocumented, and the OpenAPI v3 document regenerated.
  • Changelog: added trailer on the first commit, as the changelog guide asks of a merge request with several commits.
  • No new query for a request with scopes, and no N+1 for a fine-grained token, both pinned by a spec.
Edited by José M. Requena Plens

Merge request reports

Loading
Loading