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:
scopesbecomes optional, and the route requires exactly one ofscopesandgranular_scopeswithexactly_one_of, asPOST /user/personal_access_tokensdoes.- With
granular_scopes, the route answers 404 whengranular_personal_access_tokensis disabled for the user the token is for, builds the scopes for that user withbuild_granular_scopes, and creates the token withcreate_granular_token(..., target_user:), as the impersonation route does.build_granular_scopesgoes through theAuthz::GranularScopes::Builderthat !259286 (merged) shared, with aReadBoundaryRulefor that user, so withselected_membershipsthe user must be able to use each listed group and project as a boundary, or the route answers 404.create_granular_tokenrunsPrivilegeEscalationCheckagainst 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 itsgranular_scopes, as the impersonation routes do since !260277 (merged) andPOST /user/personal_access_tokensdoes since !260276 (merged). For a token created withscopesthe 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 itsreason, then 422. The escalation check returnsreason: :forbidden, so a refused escalation answers 403, as on the impersonation route. Every other refusal answers 422 as before, because neitherPersonalAccessTokens::CreateServicenorAuthn::PersonalAccessTokens::CreateGranularServicesets 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_scopestoPOST /user/personal_access_tokensand toPOST /users/:user_id/impersonation_tokens. This route now follows them. - !259286 (merged) shared
Authz::GranularScopes::Builder, whichbuild_granular_scopesuses. - !259764 (merged) added
granular_scopes_options_for, and !260277 (merged) and !260276 (merged) used it on the impersonation token routes, onPOST /user/personal_access_tokensand on two rotate routes. This branch is rebased onmasterwith both merged. The description of !260276 (merged) lists the undocumentedgranular_scopesparameter 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 callscreate_granular_tokenemits one today, because the check runs beforePersonalAccessTokens::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 ofPOST /users/:user_id/personal_access_tokensmakesscopesoptional, addsuse :granular_scope_paramsandexactly_one_of :scopes, :granular_scopes; the handler looks the user up once, branches onparams[:granular_scopes]as the impersonation route does, presents the token withgranular_scopes_options_for, and renders an error withresponse.http_status || response.reason || :unprocessable_entity. The route'sdetailsays to usegranular_scopesinstead ofscopesto 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 bybin/rake gitlab:openapi:v3:generate; the request body of this route gainsgranular_scopes,scopesis no longer required, and the route's description carries the newdetail.
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 ofuser,instance,all_membershipsandpersonal_projects: the token is created for the user, is granular, is not an impersonation token, holds the one scope, and the response carries it ingranular_scopes. personal_projectsbinds the scope to the user's namespace, not the administrator's.selected_membershipswith a project and a group the user is a member of: the token holds one scope for each, and the response returnsproject_idandgroup_idfor each.selected_membershipswith 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
CreateGranularServicebefore 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 astarget_user, andcreation_source: api; the user, authenticated as themselves, finds the new token inGET /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_scopesarray: 422 from the service. granular_personal_access_tokensenabled for the administrator only: 404.- A user that does not exist: 404.
- A fine-grained calling token with
create_personal_access_tokenon 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 nogranular_scopeskey, 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.rbasmasterhas it: 19 failures. Fifteen requests that sendgranular_scopesalone are answered400withscopes is missing; the one that sends both parameters creates a legacy token and ignoresgranular_scopes; the N+1 example finds nogranular_scopesin the response; the service example seesCreateGranularServicenever called; and the missing-parameters example readsname is missing, scopes is missing. The two new examples that pass onmasterare the guards for what must not change: a token created withscopesruns 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
-
The
granular_personal_access_tokensfeature 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) -
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" -
On
masterthe request is refused with400andscopes is missing. On this branch it answers201, with"granular": trueand the scope ingranular_scopes, with the project's ID inproject_id. -
Authenticated as that user,
GET /personal_access_tokenslists the new token, which it does not do for an impersonation token. -
A request with
scopesreturns 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_scopesparameter was undocumented, and the OpenAPI v3 document regenerated. -
Changelog: addedtrailer 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.