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_scopes when listing/reading/rotating tokens via REST. Right now REST only shows a granular: true flag, 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_scopes param, mutually exclusive with the existing scopes param (exactly_one_of) — existing callers using scopes are completely unaffected.
  • When granular_scopes is given: checks the granular_personal_access_tokens feature 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 include project_ids/group_ids. These are resolved to the actual projects/groups, membership is checked (404 if 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.yaml for 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."
Edited by Ayush Billore

Merge request reports

Loading
Loading