Add granular scope support to impersonation create endpoint

This MR adds the ability to specify gPAT scopes on the create impersonation REST endpoint

See #589500 (comment 3513079087)

The expected behavior for this endpoint was described in this comment: #589500 (comment 3513220319)

How to test

Run the following script after updating these values to match your environment:

HOST="http://localhost:3000"
RUBY="mise exec ruby@3.3.11 -- bundle exec rails runner"
#!/usr/bin/env bash
# Manual end-to-end test for granular scopes on POST /users/:user_id/impersonation_tokens
# Usage: bash tmp/test_impersonation_gpat.sh
# Requires: GDK running on localhost:3000, current branch checked out
set -euo pipefail

HOST="http://localhost:3000"
RUBY="mise exec ruby@3.3.11 -- bundle exec rails runner"

echo "==> Setting up fixtures (admin token, impersonated user, group/project, feature flag)..."
SETUP=$($RUBY - <<'RUBY' 2>/dev/null
org   = Organizations::Organization.find_by(path: 'default') || Organizations::Organization.first
admin = User.find_by(username: 'root')

target = User.find_by(username: 'gpat_impersonated') ||
  Users::CreateService.new(admin, username: 'gpat_impersonated', email: 'gpat_impersonated@example.com',
    name: 'GPAT Impersonated', password: 'Password123!!', organization_id: org.id,
    skip_confirmation: true).execute.payload[:user]
target.reload

Feature.enable(:granular_personal_access_tokens)

grp = Group.find_by(path: 'gpat-imp-grp')
grp ||= (Groups::CreateService.new(admin, name: 'gpat-imp-grp', path: 'gpat-imp-grp', organization_id: org.id).execute; Group.find_by(path: 'gpat-imp-grp'))
grp.add_developer(target)

other = Group.find_by(path: 'gpat-imp-othergrp')
other ||= (Groups::CreateService.new(admin, name: 'gpat-imp-othergrp', path: 'gpat-imp-othergrp', organization_id: org.id).execute; Group.find_by(path: 'gpat-imp-othergrp'))

proj = Project.find_by(path: 'gpat-imp-proj', namespace_id: target.namespace_id)
proj ||= (Projects::CreateService.new(target, name: 'gpat-imp-proj', path: 'gpat-imp-proj', namespace_id: target.namespace_id, organization_id: org.id).execute; Project.find_by(path: 'gpat-imp-proj', namespace_id: target.namespace_id))

admin_pat  = admin.personal_access_tokens.create!(name: "gpat-admin-#{Time.now.to_i}",  scopes: [:api], organization_id: org.id, expires_at: 30.days.from_now)
target_pat = target.personal_access_tokens.create!(name: "gpat-target-#{Time.now.to_i}", scopes: [:api], organization_id: org.id, expires_at: 30.days.from_now)

puts "ADMIN_TOKEN=#{admin_pat.token}"
puts "TARGET_TOKEN=#{target_pat.token}"
puts "USER_ID=#{target.id}"
puts "GROUP_ID=#{grp.id}"
puts "PROJECT_ID=#{proj.id}"
puts "NONMEMBER_GROUP_ID=#{other.id}"
RUBY
)
eval "$(echo "$SETUP" | grep -E '^[A-Z_]+=')"
BASE="$HOST/api/v4/users/$USER_ID/impersonation_tokens"
echo "    USER_ID=$USER_ID GROUP_ID=$GROUP_ID PROJECT_ID=$PROJECT_ID NONMEMBER_GROUP_ID=$NONMEMBER_GROUP_ID"

run(){ local label="$1"; shift; echo "----- $label -----"; curl -s -o /tmp/gpat_body -w "HTTP %{http_code}\n" "$@"; head -c 400 /tmp/gpat_body; echo; echo; }

run "1) instance access (expect 201, granular+impersonation true)" \
  -X POST -H "PRIVATE-TOKEN: $ADMIN_TOKEN" -H "Content-Type: application/json" "$BASE" \
  --data '{"name":"imp-instance","granular_scopes":[{"access":"instance","permissions":["read_job"]}]}'

run "2) personal_projects (expect 201)" \
  -X POST -H "PRIVATE-TOKEN: $ADMIN_TOKEN" -H "Content-Type: application/json" "$BASE" \
  --data '{"name":"imp-pp","granular_scopes":[{"access":"personal_projects","permissions":["read_job"]}]}'

run "3) selected_memberships member group+project (expect 201)" \
  -X POST -H "PRIVATE-TOKEN: $ADMIN_TOKEN" -H "Content-Type: application/json" "$BASE" \
  --data "{\"name\":\"imp-sm\",\"granular_scopes\":[{\"access\":\"selected_memberships\",\"permissions\":[\"read_job\"],\"project_ids\":[$PROJECT_ID],\"group_ids\":[$GROUP_ID]}]}"

run "4) selected_memberships NON-member group (expect 404)" \
  -X POST -H "PRIVATE-TOKEN: $ADMIN_TOKEN" -H "Content-Type: application/json" "$BASE" \
  --data "{\"name\":\"imp-sm-bad\",\"granular_scopes\":[{\"access\":\"selected_memberships\",\"permissions\":[\"read_job\"],\"group_ids\":[$NONMEMBER_GROUP_ID]}]}"

run "5) both scopes + granular_scopes (expect 400 mutually exclusive)" \
  -X POST -H "PRIVATE-TOKEN: $ADMIN_TOKEN" -H "Content-Type: application/json" "$BASE" \
  --data '{"name":"imp-both","scopes":["api"],"granular_scopes":[{"access":"user","permissions":["read_job"]}]}'

run "6) non-admin caller (expect 403)" \
  -X POST -H "PRIVATE-TOKEN: $TARGET_TOKEN" -H "Content-Type: application/json" "$BASE" \
  --data '{"name":"imp-forbidden","granular_scopes":[{"access":"user","permissions":["read_job"]}]}'

run "7) bogus access value (expect 400 does not have a valid value)" \
  -X POST -H "PRIVATE-TOKEN: $ADMIN_TOKEN" -H "Content-Type: application/json" "$BASE" \
  --data '{"name":"imp-bogus","granular_scopes":[{"access":"bogus","permissions":["read_job"]}]}'

echo "----- dedicated impersonation list (admin): created tokens present, granular+impersonation true -----"
curl -s "$BASE" -H "PRIVATE-TOKEN: $ADMIN_TOKEN" \
  | python3 -c "import json,sys; d=json.load(sys.stdin); print([(t['id'],t['name'],t['impersonation'],t['granular']) for t in d if t['name'].startswith('imp-')])"
echo
echo "----- impersonated user's OWN regular PAT table: expect NO impersonation tokens -----"
curl -s "$HOST/api/v4/personal_access_tokens" -H "PRIVATE-TOKEN: $TARGET_TOKEN" \
  | python3 -c "import json,sys; d=json.load(sys.stdin); print('impersonation ids present:', [t['id'] for t in d if t.get('impersonation')])"
echo

echo "==> Cleanup: revoke all minted/created tokens..."
$RUBY - <<'RUBY' 2>/dev/null
Feature.enable(:granular_personal_access_tokens)
admin  = User.find_by(username: 'root')
target = User.find_by(username: 'gpat_impersonated')
admin.personal_access_tokens.where("name LIKE 'gpat-admin-%'").each(&:revoke!)
target.personal_access_tokens.where("name LIKE 'gpat-target-%'").each(&:revoke!)
target.personal_access_tokens.with_impersonation.where("name LIKE 'imp-%'").each(&:revoke!)
puts "cleanup done; ff_enabled=#{Feature.enabled?(:granular_personal_access_tokens)}"
RUBY
rm -f /tmp/gpat_body
echo "==> Done."
Edited by Matthew MacRae-Bovell

Merge request reports

Loading
Loading