Add REST support for creating granular PATs
Overview
This MR is part of a short series bringing GitLab's REST API up to parity with GraphQL for granular personal access tokens (gPATs). Related issue: #589500 (closed)
- !243252 (merged) (1 of 2) — extracts a privilege-escalation check GraphQL already had into shared code, so REST can reuse it.
- This MR (2 of 2) — teaches REST to actually create granular tokens, for all 5 access types. This is the user-facing feature.
- Follow-up (not started yet) — expose
granular_scopeswhen listing/reading/rotating tokens via REST. Right now REST only shows agranular: trueflag, not the actual scopes.
What does this MR do?
Adds a new optional parameter to the existing POST /user/personal_access_tokens REST endpoint — no new endpoint is added. Today that endpoint only knows how to create "legacy" tokens (a token with a flat list of scopes like k8s_proxy/self_rotate). This MR teaches it to also create the newer "granular" style of token (gPAT), which was already possible via GraphQL but had no REST equivalent.
A granular token's permissions are defined by what it can do (a list of permission names, e.g. "read issues") and how broadly that applies (an access type: your own account, instance-wide, all groups/projects you belong to, just your personal projects, or specific projects/groups you pick by ID). This MR covers all five access types.
Concretely:
- New
granular_scopesparam, mutually exclusive with the existingscopesparam (exactly_one_of) — existing callers usingscopesare completely unaffected. - When
granular_scopesis given: checks thegranular_personal_access_tokensfeature flag, builds the granular scope records, runs the privilege-escalation check from !243252 (merged), then creates the token via the same service GraphQL already uses (Authn::PersonalAccessTokens::CreateGranularService). - For
selected_memberships, each scope entry can includeproject_ids/group_ids. These are resolved to the actual projects/groups, membership is checked (404if the caller isn't a member, same as GraphQL), and one granular scope is built per resolved project/group. IDs that don't resolve to a real project/group are silently skipped, matching GraphQL's behavior; if that leaves zero scopes, the request fails with "At least one granular scope must be provided". - Regenerated
doc/api/openapi/openapi_v3.yamlfor the new params.
Known, deliberate duplication: the logic that turns a granular_scopes input into actual scope records is re-implemented here in plain ActiveRecord, separate from the equivalent logic in the GraphQL mutation (which uses GraphQL-specific batch loaders). The two are currently kept in sync by hand.
Why are we doing this?
Closes most of the functional gap in issue #589500 (closed) — bringing REST up to parity with GraphQL for granular token creation. This MR depends on !243252 (merged) and is stacked on top of it; please review/merge that one first.
How to test
Automated:
bin/rspec spec/requests/api/users_spec.rb -e "POST /user/personal_access_tokens"
Manually, against a GDK instance with Feature.enable(:granular_personal_access_tokens):
export TOKEN="<a personal access token with api scope>"
# Simplest case — no resource needed
curl --request POST --header "PRIVATE-TOKEN: $TOKEN" -H "Content-Type: application/json" \
"http://gdk.test:3000/api/v4/user/personal_access_tokens" \
--data '{"name":"my-token","granular_scopes":[{"access":"instance","permissions":["read_job"]}]}'
# expect 201, response includes "granular": true and a "token"
# selected_memberships — needs real project/group IDs you're a member of
curl --request POST --header "PRIVATE-TOKEN: $TOKEN" -H "Content-Type: application/json" \
"http://gdk.test:3000/api/v4/user/personal_access_tokens" \
--data '{"name":"my-token-2","granular_scopes":[{"access":"selected_memberships","permissions":["read_job"],"project_ids":[1],"group_ids":[2]}]}'
# expect 201
# Both scopes and granular_scopes -> 400 "mutually exclusive"
curl --request POST --header "PRIVATE-TOKEN: $TOKEN" -H "Content-Type: application/json" \
"http://gdk.test:3000/api/v4/user/personal_access_tokens" \
--data '{"name":"x","scopes":["k8s_proxy"],"granular_scopes":[{"access":"user","permissions":["read_job"]}]}'The REST response doesn't show granular_scopes yet (that's the follow-up MR), so to confirm scopes were actually attached correctly, check via gdk rails console:
PersonalAccessToken.find_by(name: "my-token").granular_scopes.map { |s| [s.access, s.permissions] }Privilege escalation (create a limited granular token, then try to use it to mint a broader one — should be rejected with 422):
LIMITED=$(curl -s -X POST -H "PRIVATE-TOKEN: $TOKEN" -H "Content-Type: application/json" \
"http://gdk.test:3000/api/v4/user/personal_access_tokens" \
--data '{"name":"limited","granular_scopes":[{"access":"user","permissions":["create_personal_access_token"]}]}' \
| python3 -c "import json,sys;print(json.load(sys.stdin)['token'])")
curl -X POST -H "PRIVATE-TOKEN: $LIMITED" -H "Content-Type: application/json" \
"http://gdk.test:3000/api/v4/user/personal_access_tokens" \
--data '{"name":"escalated","granular_scopes":[{"access":"instance","permissions":["read_job"]}]}'
# expect 422: "A granular token can only create tokens with equal or lesser permissions."