diff --git a/config/feature_flags/development/pat_creation_api_for_admin.yml b/config/feature_flags/development/pat_creation_api_for_admin.yml new file mode 100644 index 0000000000000000000000000000000000000000..246f7623cc93d016f7facbf824af08ae3b7c33a6 --- /dev/null +++ b/config/feature_flags/development/pat_creation_api_for_admin.yml @@ -0,0 +1,7 @@ +--- +name: pat_creation_api_for_admin +introduced_by_url: https://gitlab.com/gitlab-org/gitlab/-/merge_requests/45152 +rollout_issue_url: https://gitlab.com/gitlab-org/gitlab/-/issues/267553 +type: development +group: group::access +default_enabled: false diff --git a/doc/api/personal_access_tokens.md b/doc/api/personal_access_tokens.md index 5bd804b80428d3c0c31716224d4e1151e8c0442c..8a838a0f1796a39b070f4df856c795bf5110adf4 100644 --- a/doc/api/personal_access_tokens.md +++ b/doc/api/personal_access_tokens.md @@ -93,3 +93,7 @@ curl --request DELETE --header "PRIVATE-TOKEN: <your_access_token>" "https://git - `204: No Content` if successfully revoked. - `400 Bad Request` if not revoked successfully. + +## Create a personal access token (admin only) + +See the [Users API documentation](users.md#create-a-personal-access-token-admin-only) for information on creating a personal access token. diff --git a/doc/api/users.md b/doc/api/users.md index 31e8bb67bd3c54a5ca9cec1558456412acc60bf8..e1fa97765df828bf1fc5bf58898290701dca453d 100644 --- a/doc/api/users.md +++ b/doc/api/users.md @@ -1441,7 +1441,54 @@ Parameters: | `user_id` | integer | yes | The ID of the user | | `impersonation_token_id` | integer | yes | The ID of the impersonation token | -### Get user activities (admin only) +## Create a personal access token (admin only) + +> - [Introduced](https://gitlab.com/gitlab-org/gitlab/-/issues/17176) in GitLab 13.6. +> - It's [deployed behind a feature flag](../user/feature_flags.md), disabled by default. +> - To use it in GitLab self-managed instances, ask a GitLab administrator to [enable it](#enable-or-disable-an-administrators-ability-to-use-the-api-to-create-personal-access-tokens). **(CORE)** + +CAUTION: **Warning:** +This feature might not be available to you. Check the **version history** note above for details. + +> Requires admin permissions. +> Token values are returned once. Make sure you save it - you won't be able to access it again. + +It creates a new personal access token. + +```plaintext +POST /users/:user_id/personal_access_tokens +``` + +| Attribute | Type | Required | Description | +| ------------ | ------- | -------- | ------------------------------------------------------------------------------------------------------------------------ | +| `user_id` | integer | yes | The ID of the user | +| `name` | string | yes | The name of the personal access token | +| `expires_at` | date | no | The expiration date of the personal access token in ISO format (`YYYY-MM-DD`) | +| `scopes` | array | yes | The array of scopes of the personal access token (`api`, `read_user`, `read_api`, `read_repository`, `write_repository`) | + +```shell +curl --request POST --header "PRIVATE-TOKEN: <your_access_token>" --data "name=mytoken" --data "expires_at=2017-04-04" --data "scopes[]=api" "https://gitlab.example.com/api/v4/users/42/personal_access_tokens" +``` + +Example response: + +```json +{ + "id": 3, + "name": "mytoken", + "revoked": false, + "created_at": "2020-10-14T11:58:53.526Z", + "scopes": [ + "api" + ], + "user_id": 42, + "active": true, + "expires_at": "2020-12-31", + "token": "ggbfKkC4n-Lujy8jwCR2" +} +``` + +## Get user activities (admin only) NOTE: **Note:** This API endpoint is only available on 8.15 (EE) and 9.1 (CE) and above. @@ -1546,3 +1593,22 @@ Example response: }, ] ``` + +## Enable or disable an administrator's ability to use the API to create personal access tokens **(CORE)** + +An administrator's ability to create personal access tokens through the API is +deployed behind a feature flag that is **disabled by default**. +[GitLab administrators with access to the GitLab Rails console](../administration/feature_flags.md) +can enable it. + +To enable it: + +```ruby +Feature.enable(:pat_creation_api_for_admin) +``` + +To disable it: + +```ruby +Feature.disable(:pat_creation_api_for_admin) +``` diff --git a/lib/api/users.rb b/lib/api/users.rb index 54228373512da43ffa803db2db72cb407206235f..501ed629c7e6c20f30eba7f6ebd0b9bef455e186 100644 --- a/lib/api/users.rb +++ b/lib/api/users.rb @@ -65,9 +65,9 @@ module API params :sort_params do optional :order_by, type: String, values: %w[id name username created_at updated_at], - default: 'id', desc: 'Return users ordered by a field' + default: 'id', desc: 'Return users ordered by a field' optional :sort, type: String, values: %w[asc desc], default: 'desc', - desc: 'Return users sorted in ascending and descending order' + desc: 'Return users sorted in ascending and descending order' end end @@ -706,6 +706,40 @@ module API end end end + + resource :personal_access_tokens do + helpers do + def target_user + find_user_by_id(params) + end + end + + before { authenticated_as_admin! } + + desc 'Create a personal access token. Available only for admins.' do + detail 'This feature was introduced in GitLab 13.6' + success Entities::PersonalAccessTokenWithToken + end + params do + requires :name, type: String, desc: 'The name of the personal access token' + requires :scopes, type: Array[String], coerce_with: ::API::Validations::Types::CommaSeparatedToArray.coerce, values: ::Gitlab::Auth.all_available_scopes.map(&:to_s), + desc: 'The array of scopes of the personal access token' + optional :expires_at, type: Date, desc: 'The expiration date in the format YEAR-MONTH-DAY of the personal access token' + end + post feature_category: :authentication_and_authorization do + not_found! unless Feature.enabled?(:pat_creation_api_for_admin) + + response = ::PersonalAccessTokens::CreateService.new( + current_user: current_user, target_user: target_user, params: declared_params(include_missing: false) + ).execute + + if response.success? + present response.payload[:personal_access_token], with: Entities::PersonalAccessTokenWithToken + else + render_api_error!(response.message, response.http_status || :unprocessable_entity) + end + end + end end end diff --git a/spec/requests/api/users_spec.rb b/spec/requests/api/users_spec.rb index 7330c89fe77575433a66e41817cc45179532fb25..98840d6238acb2ccccf8e68f6de2ea8654c02772 100644 --- a/spec/requests/api/users_spec.rb +++ b/spec/requests/api/users_spec.rb @@ -161,7 +161,7 @@ RSpec.describe API::Users, :do_not_mock_admin_mode do end context 'accesses the profile of another admin' do - let(:admin_2) {create(:admin, note: '2010-10-10 | 2FA added | admin requested | www.gitlab.com')} + let(:admin_2) { create(:admin, note: '2010-10-10 | 2FA added | admin requested | www.gitlab.com') } it 'contains the note of the user' do get api("/user?private_token=#{admin_personal_access_token}&sudo=#{admin_2.id}") @@ -772,11 +772,11 @@ RSpec.describe API::Users, :do_not_mock_admin_mode do it "does not create user with invalid email" do post api('/users', admin), - params: { - email: 'invalid email', - password: 'password', - name: 'test' - } + params: { + email: 'invalid email', + password: 'password', + name: 'test' + } expect(response).to have_gitlab_http_status(:bad_request) end @@ -811,14 +811,14 @@ RSpec.describe API::Users, :do_not_mock_admin_mode do it 'returns 400 error if user does not validate' do post api('/users', admin), - params: { - password: 'pass', - email: 'test@example.com', - username: 'test!', - name: 'test', - bio: 'g' * 256, - projects_limit: -1 - } + params: { + password: 'pass', + email: 'test@example.com', + username: 'test!', + name: 'test', + bio: 'g' * 256, + projects_limit: -1 + } expect(response).to have_gitlab_http_status(:bad_request) expect(json_response['message']['password']) .to eq(['is too short (minimum is 8 characters)']) @@ -838,23 +838,23 @@ RSpec.describe API::Users, :do_not_mock_admin_mode do context 'with existing user' do before do post api('/users', admin), - params: { - email: 'test@example.com', - password: 'password', - username: 'test', - name: 'foo' - } + params: { + email: 'test@example.com', + password: 'password', + username: 'test', + name: 'foo' + } end it 'returns 409 conflict error if user with same email exists' do expect do post api('/users', admin), - params: { - name: 'foo', - email: 'test@example.com', - password: 'password', - username: 'foo' - } + params: { + name: 'foo', + email: 'test@example.com', + password: 'password', + username: 'foo' + } end.to change { User.count }.by(0) expect(response).to have_gitlab_http_status(:conflict) expect(json_response['message']).to eq('Email has already been taken') @@ -863,12 +863,12 @@ RSpec.describe API::Users, :do_not_mock_admin_mode do it 'returns 409 conflict error if same username exists' do expect do post api('/users', admin), - params: { - name: 'foo', - email: 'foo@example.com', - password: 'password', - username: 'test' - } + params: { + name: 'foo', + email: 'foo@example.com', + password: 'password', + username: 'test' + } end.to change { User.count }.by(0) expect(response).to have_gitlab_http_status(:conflict) expect(json_response['message']).to eq('Username has already been taken') @@ -877,12 +877,12 @@ RSpec.describe API::Users, :do_not_mock_admin_mode do it 'returns 409 conflict error if same username exists (case insensitive)' do expect do post api('/users', admin), - params: { - name: 'foo', - email: 'foo@example.com', - password: 'password', - username: 'TEST' - } + params: { + name: 'foo', + email: 'foo@example.com', + password: 'password', + username: 'TEST' + } end.to change { User.count }.by(0) expect(response).to have_gitlab_http_status(:conflict) expect(json_response['message']).to eq('Username has already been taken') @@ -1185,14 +1185,14 @@ RSpec.describe API::Users, :do_not_mock_admin_mode do it 'returns 400 error if user does not validate' do put api("/users/#{user.id}", admin), - params: { - password: 'pass', - email: 'test@example.com', - username: 'test!', - name: 'test', - bio: 'g' * 256, - projects_limit: -1 - } + params: { + password: 'pass', + email: 'test@example.com', + username: 'test!', + name: 'test', + bio: 'g' * 256, + projects_limit: -1 + } expect(response).to have_gitlab_http_status(:bad_request) expect(json_response['message']['password']) .to eq(['is too short (minimum is 8 characters)']) @@ -1714,14 +1714,14 @@ RSpec.describe API::Users, :do_not_mock_admin_mode do context "hard delete disabled" do it "does not delete user" do - perform_enqueued_jobs { delete api("/users/#{user.id}", admin)} + perform_enqueued_jobs { delete api("/users/#{user.id}", admin) } expect(response).to have_gitlab_http_status(:conflict) end end context "hard delete enabled" do it "delete user and group", :sidekiq_might_not_need_inline do - perform_enqueued_jobs { delete api("/users/#{user.id}?hard_delete=true", admin)} + perform_enqueued_jobs { delete api("/users/#{user.id}?hard_delete=true", admin) } expect(response).to have_gitlab_http_status(:no_content) expect(Group.exists?(group.id)).to be_falsy end @@ -1993,7 +1993,7 @@ RSpec.describe API::Users, :do_not_mock_admin_mode do delete api("/user/keys/#{key.id}", user) expect(response).to have_gitlab_http_status(:no_content) - end.to change { user.keys.count}.by(-1) + end.to change { user.keys.count }.by(-1) end it_behaves_like '412 response' do @@ -2124,7 +2124,7 @@ RSpec.describe API::Users, :do_not_mock_admin_mode do post api("/user/gpg_keys/#{gpg_key.id}/revoke", user) expect(response).to have_gitlab_http_status(:accepted) - end.to change { user.gpg_keys.count}.by(-1) + end.to change { user.gpg_keys.count }.by(-1) end it 'returns 404 if key ID not found' do @@ -2157,7 +2157,7 @@ RSpec.describe API::Users, :do_not_mock_admin_mode do delete api("/user/gpg_keys/#{gpg_key.id}", user) expect(response).to have_gitlab_http_status(:no_content) - end.to change { user.gpg_keys.count}.by(-1) + end.to change { user.gpg_keys.count }.by(-1) end it 'returns 404 if key ID not found' do @@ -2279,7 +2279,7 @@ RSpec.describe API::Users, :do_not_mock_admin_mode do delete api("/user/emails/#{email.id}", user) expect(response).to have_gitlab_http_status(:no_content) - end.to change { user.emails.count}.by(-1) + end.to change { user.emails.count }.by(-1) end it_behaves_like '412 response' do @@ -2756,6 +2756,124 @@ RSpec.describe API::Users, :do_not_mock_admin_mode do end end + describe 'POST /users/:user_id/personal_access_tokens' do + let(:name) { 'new pat' } + let(:expires_at) { 3.days.from_now.to_date.to_s } + let(:scopes) { %w(api read_user) } + + context 'when feature flag is enabled' do + before do + stub_feature_flags(pat_creation_api_for_admin: true) + end + + it 'returns error if required attributes are missing' do + post api("/users/#{user.id}/personal_access_tokens", admin) + + expect(response).to have_gitlab_http_status(:bad_request) + expect(json_response['error']).to eq('name is missing, scopes is missing, scopes does not have a valid value') + end + + it 'returns a 404 error if user not found' do + post api("/users/#{non_existing_record_id}/personal_access_tokens", admin), + params: { + name: name, + scopes: scopes, + expires_at: expires_at + } + + expect(response).to have_gitlab_http_status(:not_found) + expect(json_response['message']).to eq('404 User Not Found') + end + + it 'returns a 401 error when not authenticated' do + post api("/users/#{user.id}/personal_access_tokens"), + params: { + name: name, + scopes: scopes, + expires_at: expires_at + } + + expect(response).to have_gitlab_http_status(:unauthorized) + expect(json_response['message']).to eq('401 Unauthorized') + end + + it 'returns a 403 error when authenticated as normal user' do + post api("/users/#{user.id}/personal_access_tokens", user), + params: { + name: name, + scopes: scopes, + expires_at: expires_at + } + + expect(response).to have_gitlab_http_status(:forbidden) + expect(json_response['message']).to eq('403 Forbidden') + end + + it 'creates a personal access token when authenticated as admin' do + post api("/users/#{user.id}/personal_access_tokens", admin), + params: { + name: name, + expires_at: expires_at, + scopes: scopes + } + + expect(response).to have_gitlab_http_status(:created) + expect(json_response['name']).to eq(name) + expect(json_response['scopes']).to eq(scopes) + expect(json_response['expires_at']).to eq(expires_at) + expect(json_response['id']).to be_present + expect(json_response['created_at']).to be_present + expect(json_response['active']).to be_truthy + expect(json_response['revoked']).to be_falsey + expect(json_response['token']).to be_present + end + + context 'when an error is thrown by the model' do + let!(:admin_personal_access_token) { create(:personal_access_token, user: admin) } + let(:error_message) { 'error message' } + + before do + allow_next_instance_of(PersonalAccessToken) do |personal_access_token| + allow(personal_access_token).to receive_message_chain(:errors, :full_messages) + .and_return([error_message]) + + allow(personal_access_token).to receive(:save).and_return(false) + end + end + + it 'returns the error' do + post api("/users/#{user.id}/personal_access_tokens", personal_access_token: admin_personal_access_token), + params: { + name: name, + expires_at: expires_at, + scopes: scopes + } + + expect(response).to have_gitlab_http_status(:unprocessable_entity) + expect(json_response['message']).to eq(error_message) + end + end + end + + context 'when feature flag is disabled' do + before do + stub_feature_flags(pat_creation_api_for_admin: false) + end + + it 'returns a 404' do + post api("/users/#{user.id}/personal_access_tokens", admin), + params: { + name: name, + expires_at: expires_at, + scopes: scopes + } + + expect(response).to have_gitlab_http_status(:not_found) + expect(json_response['message']).to eq('404 Not Found') + end + end + end + describe 'GET /users/:user_id/impersonation_tokens' do let_it_be(:active_personal_access_token) { create(:personal_access_token, user: user) } let_it_be(:revoked_personal_access_token) { create(:personal_access_token, :revoked, user: user) }