Commit c69eca4d authored by Aishwarya Subramanian's avatar Aishwarya Subramanian

Api for Project Access Token

Adds list, creation and revoke api for
project access token.
The create api generates a Project bot
and makes it a Maintainer in the project.
The list api lists all active and inactive
Project access tokens.
The revoke action revokes the token and removes
the member from the project.
parent 1f15c228
# frozen_string_literal: true
module Projects
module Settings
class AccessTokensController < Projects::ApplicationController
include ProjectsHelper
before_action :check_feature_availability
def index
@project_access_token = PersonalAccessToken.new
set_index_vars
end
def create
token_response = ResourceAccessTokens::CreateService.new(current_user, @project, create_params).execute
if token_response.success?
@project_access_token = token_response.payload[:access_token]
PersonalAccessToken.redis_store!(key_identity, @project_access_token.token)
redirect_to namespace_project_settings_access_tokens_path, notice: _("Your new project access token has been created.")
else
render :index
end
end
def revoke
@project_access_token = finder.find(params[:id])
revoked_response = ResourceAccessTokens::RevokeService.new(current_user, @project, @project_access_token).execute
if revoked_response.success?
flash[:notice] = _("Revoked project access token %{project_access_token_name}!") % { project_access_token_name: @project_access_token.name }
else
flash[:alert] = _("Could not revoke project access token %{project_access_token_name}.") % { project_access_token_name: @project_access_token.name }
end
redirect_to namespace_project_settings_access_tokens_path
end
private
def check_feature_availability
render_404 unless project_access_token_available?(@project)
end
def create_params
params.require(:project_access_token).permit(:name, :expires_at, scopes: [])
end
def set_index_vars
@scopes = Gitlab::Auth.resource_bot_scopes
@active_project_access_tokens = finder(state: 'active').execute
@inactive_project_access_tokens = finder(state: 'inactive', sort: 'expires_at_asc').execute
@new_project_access_token = PersonalAccessToken.redis_getdel(key_identity)
end
def finder(options = {})
PersonalAccessTokensFinder.new({ user: bot_users, impersonation: false }.merge(options))
end
def bot_users
@project.bots
end
def key_identity
"#{current_user.id}:#{@project.id}"
end
end
end
end
...@@ -740,6 +740,12 @@ module ProjectsHelper ...@@ -740,6 +740,12 @@ module ProjectsHelper
Gitlab.config.registry.enabled && Gitlab.config.registry.enabled &&
can?(current_user, :destroy_container_image, project) can?(current_user, :destroy_container_image, project)
end end
def project_access_token_available?(project)
return false if ::Gitlab.com?
::Feature.enabled?(:resource_access_token, project)
end
end end
ProjectsHelper.prepend_if_ee('EE::ProjectsHelper') ProjectsHelper.prepend_if_ee('EE::ProjectsHelper')
...@@ -4,6 +4,7 @@ class PersonalAccessToken < ApplicationRecord ...@@ -4,6 +4,7 @@ class PersonalAccessToken < ApplicationRecord
include Expirable include Expirable
include TokenAuthenticatable include TokenAuthenticatable
include Sortable include Sortable
extend ::Gitlab::Utils::Override
add_authentication_token_field :token, digest: true add_authentication_token_field :token, digest: true
...@@ -23,6 +24,8 @@ class PersonalAccessToken < ApplicationRecord ...@@ -23,6 +24,8 @@ class PersonalAccessToken < ApplicationRecord
scope :without_impersonation, -> { where(impersonation: false) } scope :without_impersonation, -> { where(impersonation: false) }
scope :for_user, -> (user) { where(user: user) } scope :for_user, -> (user) { where(user: user) }
scope :preload_users, -> { preload(:user) } scope :preload_users, -> { preload(:user) }
scope :order_expires_at_asc, -> { reorder(expires_at: :asc) }
scope :order_expires_at_desc, -> { reorder(expires_at: :desc) }
validates :scopes, presence: true validates :scopes, presence: true
validate :validate_scopes validate :validate_scopes
...@@ -39,12 +42,14 @@ class PersonalAccessToken < ApplicationRecord ...@@ -39,12 +42,14 @@ class PersonalAccessToken < ApplicationRecord
def self.redis_getdel(user_id) def self.redis_getdel(user_id)
Gitlab::Redis::SharedState.with do |redis| Gitlab::Redis::SharedState.with do |redis|
encrypted_token = redis.get(redis_shared_state_key(user_id)) redis_key = redis_shared_state_key(user_id)
redis.del(redis_shared_state_key(user_id)) encrypted_token = redis.get(redis_key)
redis.del(redis_key)
begin begin
Gitlab::CryptoHelper.aes256_gcm_decrypt(encrypted_token) Gitlab::CryptoHelper.aes256_gcm_decrypt(encrypted_token)
rescue => ex rescue => ex
logger.warn "Failed to decrypt PersonalAccessToken value stored in Redis for User ##{user_id}: #{ex.class}" logger.warn "Failed to decrypt #{self.name} value stored in Redis for key ##{redis_key}: #{ex.class}"
encrypted_token encrypted_token
end end
end end
...@@ -58,6 +63,16 @@ class PersonalAccessToken < ApplicationRecord ...@@ -58,6 +63,16 @@ class PersonalAccessToken < ApplicationRecord
end end
end end
override :simple_sorts
def self.simple_sorts
super.merge(
{
'expires_at_asc' => -> { order_expires_at_asc },
'expires_at_desc' => -> { order_expires_at_desc }
}
)
end
protected protected
def validate_scopes def validate_scopes
......
...@@ -1519,6 +1519,10 @@ class Project < ApplicationRecord ...@@ -1519,6 +1519,10 @@ class Project < ApplicationRecord
end end
end end
def bots
users.project_bot
end
# Filters `users` to return only authorized users of the project # Filters `users` to return only authorized users of the project
def members_among(users) def members_among(users)
if users.is_a?(ActiveRecord::Relation) && !users.loaded? if users.is_a?(ActiveRecord::Relation) && !users.loaded?
......
# frozen_string_literal: true # frozen_string_literal: true
module Resources module ResourceAccessTokens
class CreateAccessTokenService < BaseService class CreateService < BaseService
attr_accessor :resource_type, :resource def initialize(current_user, resource, params = {})
@resource_type = resource.class.name.downcase
def initialize(resource_type, resource, user, params = {})
@resource_type = resource_type
@resource = resource @resource = resource
@current_user = user @current_user = current_user
@params = params.dup @params = params.dup
end end
...@@ -33,6 +31,8 @@ module Resources ...@@ -33,6 +31,8 @@ module Resources
private private
attr_reader :resource_type, :resource
def feature_enabled? def feature_enabled?
::Feature.enabled?(:resource_access_token, resource) ::Feature.enabled?(:resource_access_token, resource)
end end
...@@ -85,7 +85,7 @@ module Resources ...@@ -85,7 +85,7 @@ module Resources
def personal_access_token_params def personal_access_token_params
{ {
name: "#{resource_type}_bot", name: params[:name] || "#{resource_type}_bot",
impersonation: false, impersonation: false,
scopes: params[:scopes] || default_scopes, scopes: params[:scopes] || default_scopes,
expires_at: params[:expires_at] || nil expires_at: params[:expires_at] || nil
...@@ -93,7 +93,7 @@ module Resources ...@@ -93,7 +93,7 @@ module Resources
end end
def default_scopes def default_scopes
Gitlab::Auth::API_SCOPES + Gitlab::Auth::REPOSITORY_SCOPES + Gitlab::Auth.registry_scopes - [:read_user] Gitlab::Auth.resource_bot_scopes
end end
def provision_access(resource, user) def provision_access(resource, user)
......
# frozen_string_literal: true
module ResourceAccessTokens
class RevokeService < BaseService
include Gitlab::Utils::StrongMemoize
RevokeAccessTokenError = Class.new(RuntimeError)
def initialize(current_user, resource, access_token)
@current_user = current_user
@access_token = access_token
@bot_user = access_token.user
@resource = resource
end
def execute
return error("Failed to find bot user") unless find_member
PersonalAccessToken.transaction do
access_token.revoke!
raise RevokeAccessTokenError, "Failed to remove #{bot_user.name} member from: #{resource.name}" unless remove_member
raise RevokeAccessTokenError, "Migration to ghost user failed" unless migrate_to_ghost_user
end
success("Revoked access token: #{access_token.name}")
rescue ActiveRecord::ActiveRecordError, RevokeAccessTokenError => error
log_error("Failed to revoke access token for #{bot_user.name}: #{error.message}")
error(error.message)
end
private
attr_reader :current_user, :access_token, :bot_user, :resource
def remove_member
::Members::DestroyService.new(current_user).execute(find_member)
end
def migrate_to_ghost_user
::Users::MigrateToGhostUserService.new(bot_user).execute
end
def find_member
strong_memoize(:member) do
if resource.is_a?(Project)
resource.project_member(bot_user)
elsif resource.is_a?(Group)
resource.group_member(bot_user)
else
false
end
end
end
def error(message)
ServiceResponse.error(message: message)
end
def success(message)
ServiceResponse.success(message: message)
end
end
end
...@@ -90,6 +90,12 @@ constraints(::Constraints::ProjectUrlConstrainer.new) do ...@@ -90,6 +90,12 @@ constraints(::Constraints::ProjectUrlConstrainer.new) do
post :create_deploy_token, path: 'deploy_token/create' post :create_deploy_token, path: 'deploy_token/create'
post :cleanup post :cleanup
end end
resources :access_tokens, only: [:index, :create] do
member do
put :revoke
end
end
end end
resources :autocomplete_sources, only: [] do resources :autocomplete_sources, only: [] do
......
...@@ -337,6 +337,10 @@ module Gitlab ...@@ -337,6 +337,10 @@ module Gitlab
REGISTRY_SCOPES REGISTRY_SCOPES
end end
def resource_bot_scopes
Gitlab::Auth::API_SCOPES + Gitlab::Auth::REPOSITORY_SCOPES + Gitlab::Auth.registry_scopes - [:read_user]
end
private private
def non_admin_available_scopes def non_admin_available_scopes
......
...@@ -6072,6 +6072,9 @@ msgstr "" ...@@ -6072,6 +6072,9 @@ msgstr ""
msgid "Could not revoke personal access token %{personal_access_token_name}." msgid "Could not revoke personal access token %{personal_access_token_name}."
msgstr "" msgstr ""
msgid "Could not revoke project access token %{project_access_token_name}."
msgstr ""
msgid "Could not save group ID" msgid "Could not save group ID"
msgstr "" msgstr ""
...@@ -17799,6 +17802,9 @@ msgstr "" ...@@ -17799,6 +17802,9 @@ msgstr ""
msgid "Revoked personal access token %{personal_access_token_name}!" msgid "Revoked personal access token %{personal_access_token_name}!"
msgstr "" msgstr ""
msgid "Revoked project access token %{project_access_token_name}!"
msgstr ""
msgid "RightSidebar|adding a" msgid "RightSidebar|adding a"
msgstr "" msgstr ""
...@@ -24524,6 +24530,9 @@ msgstr "" ...@@ -24524,6 +24530,9 @@ msgstr ""
msgid "Your new personal access token has been created." msgid "Your new personal access token has been created."
msgstr "" msgstr ""
msgid "Your new project access token has been created."
msgstr ""
msgid "Your password isn't required to view this page. If a password or any other personal details are requested, please contact your administrator to report abuse." msgid "Your password isn't required to view this page. If a password or any other personal details are requested, please contact your administrator to report abuse."
msgstr "" msgstr ""
......
# frozen_string_literal: true
require('spec_helper')
describe Projects::Settings::AccessTokensController do
let_it_be(:user) { create(:user) }
let_it_be(:project) { create(:project) }
before_all do
project.add_maintainer(user)
end
before do
sign_in(user)
end
shared_examples 'feature unavailability' do
context 'when flag is disabled' do
before do
stub_feature_flags(resource_access_token: false)
end
it { is_expected.to have_gitlab_http_status(:not_found) }
end
context 'when environment is Gitlab.com' do
before do
allow(Gitlab).to receive(:com?).and_return(true)
end
it { is_expected.to have_gitlab_http_status(:not_found) }
end
end
describe '#index' do
subject { get :index, params: { namespace_id: project.namespace, project_id: project } }
it_behaves_like 'feature unavailability'
context 'when feature is available' do
let_it_be(:bot_user) { create(:user, :project_bot) }
let_it_be(:active_project_access_token) { create(:personal_access_token, user: bot_user) }
let_it_be(:inactive_project_access_token) { create(:personal_access_token, :revoked, user: bot_user) }
before_all do
project.add_maintainer(bot_user)
end
before do
enable_feature
end
it 'retrieves active project access tokens' do
subject
expect(assigns(:active_project_access_tokens)).to contain_exactly(active_project_access_token)
end
it 'retrieves inactive project access tokens' do
subject
expect(assigns(:inactive_project_access_tokens)).to contain_exactly(inactive_project_access_token)
end
it 'lists all available scopes' do
subject
expect(assigns(:scopes)).to eq(Gitlab::Auth.resource_bot_scopes)
end
it 'retrieves newly created personal access token value' do
token_value = 'random-value'
allow(PersonalAccessToken).to receive(:redis_getdel).with("#{user.id}:#{project.id}").and_return(token_value)
subject
expect(assigns(:new_project_access_token)).to eq(token_value)
end
end
end
describe '#create', :clean_gitlab_redis_shared_state do
subject { post :create, params: { namespace_id: project.namespace, project_id: project }.merge(project_access_token: access_token_params) }
let_it_be(:access_token_params) { {} }
it_behaves_like 'feature unavailability'
context 'when feature is available' do
let_it_be(:access_token_params) { { name: 'Nerd bot', scopes: ["api"], expires_at: 1.month.since.to_date } }
before do
enable_feature
end
def created_token
PersonalAccessToken.order(:created_at).last
end
it 'returns success message' do
subject
expect(response.flash[:notice]).to match(/\AYour new project access token has been created./i)
end
it 'creates project access token' do
subject
expect(created_token.name).to eq(access_token_params[:name])
expect(created_token.scopes).to eq(access_token_params[:scopes])
expect(created_token.expires_at).to eq(access_token_params[:expires_at])
end
it 'creates project bot user' do
subject
expect(created_token.user).to be_project_bot
end
it 'stores newly created token redis store' do
expect(PersonalAccessToken).to receive(:redis_store!)
subject
end
it { expect { subject }.to change { User.count }.by(1) }
it { expect { subject }.to change { PersonalAccessToken.count }.by(1) }
context 'when unsuccessful' do
before do
allow_next_instance_of(ResourceAccessTokens::CreateService) do |service|
allow(service).to receive(:execute).and_return ServiceResponse.error(message: 'Failed!')
end
end
it { expect(subject).to render_template(:index) }
end
end
end
describe '#revoke' do
subject { put :revoke, params: { namespace_id: project.namespace, project_id: project, id: project_access_token } }
let_it_be(:bot_user) { create(:user, :project_bot) }
let_it_be(:project_access_token) { create(:personal_access_token, user: bot_user) }
before_all do
project.add_maintainer(bot_user)
end
it_behaves_like 'feature unavailability'
context 'when feature is available' do
before do
enable_feature
end
it 'revokes token access' do
subject
expect(project_access_token.reload.revoked?).to be true
end
it 'removed membership of bot user' do
subject
expect(project.reload.bots).not_to include(bot_user)
end
it 'blocks project bot user' do
subject
expect(bot_user.reload.blocked?).to be true
end
it 'converts issuables of the bot user to ghost user' do
issue = create(:issue, author: bot_user)
subject
expect(issue.reload.author.ghost?).to be true
end
end
end
def enable_feature
allow(Gitlab).to receive(:com?).and_return(false)
stub_feature_flags(resource_access_token: true)
end
end
...@@ -715,6 +715,14 @@ describe Gitlab::Auth, :use_clean_rails_memory_store_caching do ...@@ -715,6 +715,14 @@ describe Gitlab::Auth, :use_clean_rails_memory_store_caching do
end end
end end
describe ".resource_bot_scopes" do
subject { described_class.resource_bot_scopes }
it { is_expected.to include(*described_class::API_SCOPES - [:read_user]) }
it { is_expected.to include(*described_class::REPOSITORY_SCOPES) }
it { is_expected.to include(*described_class.registry_scopes) }
end
private private
def expect_results_with_abilities(personal_access_token, abilities, success = true) def expect_results_with_abilities(personal_access_token, abilities, success = true)
......
...@@ -179,4 +179,27 @@ describe PersonalAccessToken do ...@@ -179,4 +179,27 @@ describe PersonalAccessToken do
end end
end end
end end
describe '.simple_sorts' do
it 'includes overriden keys' do
expect(described_class.simple_sorts.keys).to include(*%w(expires_at_asc expires_at_desc))
end
end
describe 'ordering by expires_at' do
let_it_be(:earlier_token) { create(:personal_access_token, expires_at: 2.days.ago) }
let_it_be(:later_token) { create(:personal_access_token, expires_at: 1.day.ago) }
describe '.order_expires_at_asc' do
it 'returns ordered list in asc order of expiry date' do
expect(described_class.order_expires_at_asc).to match [earlier_token, later_token]
end
end
describe '.order_expires_at_desc' do
it 'returns ordered list in desc order of expiry date' do
expect(described_class.order_expires_at_desc).to match [later_token, earlier_token]
end
end
end
end end
...@@ -6081,6 +6081,23 @@ describe Project do ...@@ -6081,6 +6081,23 @@ describe Project do
end end
end end
describe '#bots' do
subject { project.bots }
let_it_be(:project) { create(:project) }
let_it_be(:project_bot) { create(:user, :project_bot) }
let_it_be(:user) { create(:user) }
before_all do
[project_bot, user].each do |member|
project.add_maintainer(member)
end
end
it { is_expected.to contain_exactly(project_bot) }
it { is_expected.not_to include(user) }
end
def finish_job(export_job) def finish_job(export_job)
export_job.start export_job.start
export_job.finish export_job.finish
......
...@@ -2,8 +2,8 @@ ...@@ -2,8 +2,8 @@
require 'spec_helper' require 'spec_helper'
describe Resources::CreateAccessTokenService do describe ResourceAccessTokens::CreateService do
subject { described_class.new(resource_type, resource, user, params).execute } subject { described_class.new(user, resource, params).execute }
let_it_be(:user) { create(:user) } let_it_be(:user) { create(:user) }
let_it_be(:project) { create(:project, :private) } let_it_be(:project) { create(:project, :private) }
...@@ -12,7 +12,7 @@ describe Resources::CreateAccessTokenService do ...@@ -12,7 +12,7 @@ describe Resources::CreateAccessTokenService do
describe '#execute' do describe '#execute' do
# Created shared_examples as it will easy to include specs for group bots in https://gitlab.com/gitlab-org/gitlab/-/issues/214046 # Created shared_examples as it will easy to include specs for group bots in https://gitlab.com/gitlab-org/gitlab/-/issues/214046
shared_examples 'fails when user does not have the permission to create a Resource Bot' do shared_examples 'fails when user does not have the permission to create a Resource Bot' do
before do before_all do
resource.add_developer(user) resource.add_developer(user)
end end
...@@ -56,7 +56,7 @@ describe Resources::CreateAccessTokenService do ...@@ -56,7 +56,7 @@ describe Resources::CreateAccessTokenService do
end end
context 'when user provides value' do context 'when user provides value' do
let(:params) { { name: 'Random bot' } } let_it_be(:params) { { name: 'Random bot' } }
it 'overrides the default value' do it 'overrides the default value' do
response = subject response = subject
...@@ -83,12 +83,12 @@ describe Resources::CreateAccessTokenService do ...@@ -83,12 +83,12 @@ describe Resources::CreateAccessTokenService do
response = subject response = subject
access_token = response.payload[:access_token] access_token = response.payload[:access_token]
expect(access_token.scopes).to eq(Gitlab::Auth::API_SCOPES + Gitlab::Auth::REPOSITORY_SCOPES + Gitlab::Auth.registry_scopes - [:read_user]) expect(access_token.scopes).to eq(Gitlab::Auth.resource_bot_scopes)
end end
end end
context 'when user provides scope explicitly' do context 'when user provides scope explicitly' do
let(:params) { { scopes: Gitlab::Auth::REPOSITORY_SCOPES } } let_it_be(:params) { { scopes: Gitlab::Auth::REPOSITORY_SCOPES } }
it 'overrides the default value' do it 'overrides the default value' do
response = subject response = subject
...@@ -109,7 +109,7 @@ describe Resources::CreateAccessTokenService do ...@@ -109,7 +109,7 @@ describe Resources::CreateAccessTokenService do
end end
context 'when user provides value' do context 'when user provides value' do
let(:params) { { expires_at: Date.today + 1.month } } let_it_be(:params) { { expires_at: Date.today + 1.month } }
it 'overrides the default value' do it 'overrides the default value' do
response = subject response = subject
...@@ -120,7 +120,7 @@ describe Resources::CreateAccessTokenService do ...@@ -120,7 +120,7 @@ describe Resources::CreateAccessTokenService do
end end
context 'when invalid scope is passed' do context 'when invalid scope is passed' do
let(:params) { { scopes: [:invalid_scope] } } let_it_be(:params) { { scopes: [:invalid_scope] } }
it 'returns error' do it 'returns error' do
response = subject response = subject
...@@ -145,14 +145,14 @@ describe Resources::CreateAccessTokenService do ...@@ -145,14 +145,14 @@ describe Resources::CreateAccessTokenService do
end end
context 'when resource is a project' do context 'when resource is a project' do
let(:resource_type) { 'project' } let_it_be(:resource_type) { 'project' }
let(:resource) { project } let_it_be(:resource) { project }
it_behaves_like 'fails when user does not have the permission to create a Resource Bot' it_behaves_like 'fails when user does not have the permission to create a Resource Bot'
it_behaves_like 'fails when flag is disabled' it_behaves_like 'fails when flag is disabled'
context 'user with valid permission' do context 'user with valid permission' do
before do before_all do
resource.add_maintainer(user) resource.add_maintainer(user)
end end
......
# frozen_string_literal: true
require 'spec_helper'
describe ResourceAccessTokens::RevokeService do
subject { described_class.new(user, resource, access_token).execute }
let_it_be(:user) { create(:user) }
let(:access_token) { create(:personal_access_token, user: resource_bot) }
describe '#execute' do
# Created shared_examples as it will easy to include specs for group bots in https://gitlab.com/gitlab-org/gitlab/-/issues/214046
shared_examples 'revokes access token' do
it { expect(subject.success?).to be true }
it { expect(subject.message).to eq("Revoked access token: #{access_token.name}") }
it 'revokes token access' do
subject
expect(access_token.reload.revoked?).to be true
end
it 'removes membership of bot user' do
subject
expect(resource.reload.users).not_to include(resource_bot)
end
it 'transfer issuables of bot user to ghost user' do
issue = create(:issue, author: resource_bot)
subject
expect(issue.reload.author.ghost?).to be true
end
end
shared_examples 'rollback revoke steps' do
it 'does not revoke the access token' do
subject
expect(access_token.reload.revoked?).to be false
end
it 'does not remove bot from member list' do
subject
expect(resource.reload.users).to include(resource_bot)
end
it 'does not transfer issuables of bot user to ghost user' do
issue = create(:issue, author: resource_bot)
subject
expect(issue.reload.author.ghost?).to be false
end
end
context 'when resource is a project' do
let_it_be(:resource) { create(:project, :private) }
let_it_be(:resource_bot) { create(:user, :project_bot) }
before_all do
resource.add_maintainer(user)
resource.add_maintainer(resource_bot)
end
it_behaves_like 'revokes access token'
context 'when revoke fails' do
context 'invalid resource type' do
subject { described_class.new(user, resource, access_token).execute }
let_it_be(:resource) { double }
let_it_be(:resource_bot) { create(:user, :project_bot) }
it 'returns error response' do
response = subject
expect(response.success?).to be false
expect(response.message).to eq("Failed to find bot user")
end
it { expect { subject }.not_to change(access_token.reload, :revoked) }
end
context 'when migration to ghost user fails' do
before do
allow_next_instance_of(::Members::DestroyService) do |service|
allow(service).to receive(:execute).and_return(false)
end
end
it_behaves_like 'rollback revoke steps'
end
context 'when migration to ghost user fails' do
before do
allow_next_instance_of(::Users::MigrateToGhostUserService) do |service|
allow(service).to receive(:execute).and_return(false)
end
end
it_behaves_like 'rollback revoke steps'
end
end
end
end
end
Markdown is supported
0%
or
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment