diff --git a/app/services/users/support_pin/base_service.rb b/app/services/users/support_pin/base_service.rb index cc363c38403a19f10f7faf68977794efb48796e5..6fa55f33e2ec381696c2af1573e3c1c242e473b3 100644 --- a/app/services/users/support_pin/base_service.rb +++ b/app/services/users/support_pin/base_service.rb @@ -13,6 +13,12 @@ def initialize(user) def pin_key "#{SUPPORT_PIN_PREFIX}:#{@user.id}" end + + def pin_exists? + Gitlab::Redis::Cache.with do |redis| + redis.exists(pin_key).to_i > 0 + end + end end end end diff --git a/app/services/users/support_pin/revoke_service.rb b/app/services/users/support_pin/revoke_service.rb new file mode 100644 index 0000000000000000000000000000000000000000..aecdddaf0309380b468840a252d786f7ce4c8d92 --- /dev/null +++ b/app/services/users/support_pin/revoke_service.rb @@ -0,0 +1,28 @@ +# frozen_string_literal: true + +module Users + module SupportPin + class RevokeService < SupportPin::BaseService + def execute + return { status: :not_found, message: 'Support PIN not found or already expired' } unless pin_exists? + + revoked = revoke_pin + + if revoked + { status: :success } + else + { status: :error, message: 'Failed to revoke support PIN' } + end + end + + private + + def revoke_pin + Gitlab::Redis::Cache.with do |redis| + key = pin_key + redis.expire(key, 0) # Set to expire immediately + end + end + end + end +end diff --git a/doc/api/users.md b/doc/api/users.md index 9c57b8ea04c7c0bf0d4a75f975780a2ffdd3402e..d83cce3b3b61eab465fd8075b4a66408b6db6cdf 100644 --- a/doc/api/users.md +++ b/doc/api/users.md @@ -1584,3 +1584,48 @@ Supported attributes: | Attribute | Type | Required | Description | |:-----------------------|:---------|:---------|:------------| | `id` | integer | yes | ID of user account | + +## Revoke a Support PIN for a user + +{{< details >}} + +- Tier: Free, Premium, Ultimate +- Offering: GitLab.com, GitLab Self-Managed, GitLab Dedicated + +{{< /details >}} + +{{< history >}} + +- [Introduced](https://gitlab.com/gitlab-org/gitlab/-/merge_requests/187657) +in GitLab 17.11. + +{{< /history >}} + +Revokes a Support PIN for the specified user before its natural expiration. +This immediately expires and removes the PIN. + +Prerequisites: + +- You must be an administrator. + +```plaintext +POST /users/:id/support_pin/revoke +``` + +Example request: + +```shell +curl --request POST \ + --header "PRIVATE-TOKEN: " \ + --url "https://gitlab.example.com/api/v4/users/1234/support_pin/revoke" +``` + +Example response: + +If successful, returns `202 Accepted`. + +Supported attributes: + +| Attribute | Type | Required | Description | +|:-------------|:----------|:---------|:--------------------| +| `id` | integer | yes | ID of a user | diff --git a/lib/api/users.rb b/lib/api/users.rb index b48fe9c28ab99f0e581ad8fe879178b1e0fd3a74..f20093f7cafb2e280a7871c5372c5f7cc80787d2 100644 --- a/lib/api/users.rb +++ b/lib/api/users.rb @@ -129,6 +129,36 @@ def reorder_users(users) end end + desc 'Revoke support PIN for a user. Available only for admins.' do + detail 'This feature allows administrators to revoke the support PIN for a specified user before its natural expiration' + success code: 204 + is_array false + end + params do + requires :id, type: Integer, desc: 'The ID of the user' + end + post ":id/support_pin/revoke", feature_category: :user_management do + authenticated_as_admin! + + user = User.find_by_id(params[:id]) + not_found!('User') unless user + + begin + result = ::Users::SupportPin::RevokeService.new(user).execute + rescue StandardError + error!("Error revoking Support PIN for user.", :unprocessable_entity) + end + + case result[:status] + when :success + status :accepted + when :not_found + not_found!(result[:message]) + else + error!(result[:message] || "Failed to revoke Support PIN", :bad_request) + end + end + desc 'Get the list of users' do success Entities::UserBasic end diff --git a/spec/requests/api/users_spec.rb b/spec/requests/api/users_spec.rb index 51bcbd4e3de06788ad1fdfa5267a137794850a45..25c7720e6b84bf375dce49f7211604114a5722a6 100644 --- a/spec/requests/api/users_spec.rb +++ b/spec/requests/api/users_spec.rb @@ -5557,4 +5557,97 @@ def request end end end + + describe 'POST /users/:id/support_pin/revoke' do + let(:path) { "/users/#{user.id}/support_pin/revoke" } + + context 'when current user is an admin' do + context 'when a PIN exists' do + it 'returns accepted status' do + post api(path, admin, admin_mode: true) + + expect(response).to have_gitlab_http_status(:accepted) + end + + it 'revokes the pin' do + post api(path, admin, admin_mode: true) + + # Verify PIN is no longer accessible after revocation + get api("/users/#{user.id}/support_pin", admin, admin_mode: true) + expect(response).to have_gitlab_http_status(:not_found) + end + end + + context 'when no PIN exists' do + it 'returns not found' do + post api(path, admin, admin_mode: true) + + expect(response).to have_gitlab_http_status(:not_found) + expect(json_response['message']).to include('Support PIN not found or already expired') + end + end + + context 'when an error occurs during revocation' do + before do + allow_next_instance_of(Users::SupportPin::RevokeService) do |instance| + allow(instance).to receive(:execute).and_raise(StandardError, 'Something went wrong') + end + end + + it 'returns unprocessable_entity' do + post api(path, admin, admin_mode: true) + + expect(response).to have_gitlab_http_status(:unprocessable_entity) + expect(json_response['error']).to eq('Error revoking Support PIN for user.') + end + end + + context 'when the service returns an error status' do + before do + allow_next_instance_of(Users::SupportPin::RevokeService) do |instance| + allow(instance).to receive(:execute).and_return({ status: :error, message: 'Service error' }) + end + end + + it 'returns bad_request' do + post api(path, admin, admin_mode: true) + + expect(response).to have_gitlab_http_status(:bad_request) + expect(json_response['error']).to eq('Service error') + end + end + end + + context 'when current user is not an admin' do + before do + # First authenticate as the user to create their own PIN + post api("/user/support_pin", user) + end + + it 'returns forbidden' do + # Attempt to revoke as non-admin + post api(path, user) + + expect(response).to have_gitlab_http_status(:forbidden) + end + + it 'does not revoke the PIN' do + # Attempt to revoke as non-admin + post api(path, user) + + # Verify PIN still exists via API + get api('/user/support_pin', user) + + expect(response).to have_gitlab_http_status(:ok) + end + end + + context 'when user is not authenticated' do + it 'returns unauthorized' do + post api(path) + + expect(response).to have_gitlab_http_status(:unauthorized) + end + end + end end diff --git a/spec/services/users/support_pin/revoke_service_spec.rb b/spec/services/users/support_pin/revoke_service_spec.rb new file mode 100644 index 0000000000000000000000000000000000000000..3951eb8bd06d7b87e6700192aa93570df77f10f0 --- /dev/null +++ b/spec/services/users/support_pin/revoke_service_spec.rb @@ -0,0 +1,54 @@ +# frozen_string_literal: true + +require 'spec_helper' + +RSpec.describe Users::SupportPin::RevokeService, feature_category: :user_management do + let(:user) { create(:user) } + let(:service) { described_class.new(user) } + + describe '#execute' do + context 'when a PIN exists' do + before do + # Create a PIN using the UpdateService + Users::SupportPin::UpdateService.new(user).execute + end + + it 'revokes the PIN successfully' do + # Verify PIN exists before revocation + expect(Users::SupportPin::RetrieveService.new(user).execute).not_to be_nil + + result = service.execute + expect(result[:status]).to eq(:success) + + # Verify PIN is no longer accessible after revocation + expect(Users::SupportPin::RetrieveService.new(user).execute).to be_nil + end + end + + context 'when no PIN exists' do + it 'returns not_found status' do + result = service.execute + + expect(result[:status]).to eq(:not_found) + expect(result[:message]).to eq('Support PIN not found or already expired') + end + end + + context 'when Redis operation fails' do + before do + # Create a PIN first + Users::SupportPin::UpdateService.new(user).execute + + allow_next_instance_of(described_class) do |instance| + allow(instance).to receive(:revoke_pin).and_return(false) + end + end + + it 'returns an error' do + result = service.execute + + expect(result).to eq({ status: :error, message: 'Failed to revoke support PIN' }) + end + end + end +end