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
Gitlab.config.registry.enabled &&
can?(current_user, :destroy_container_image, project)
end
def project_access_token_available?(project)
return false if ::Gitlab.com?
::Feature.enabled?(:resource_access_token, project)
end
end
ProjectsHelper.prepend_if_ee('EE::ProjectsHelper')
......@@ -4,6 +4,7 @@ class PersonalAccessToken < ApplicationRecord
include Expirable
include TokenAuthenticatable
include Sortable
extend ::Gitlab::Utils::Override
add_authentication_token_field :token, digest: true
......@@ -23,6 +24,8 @@ class PersonalAccessToken < ApplicationRecord
scope :without_impersonation, -> { where(impersonation: false) }
scope :for_user, -> (user) { where(user: 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
validate :validate_scopes
......@@ -39,12 +42,14 @@ class PersonalAccessToken < ApplicationRecord
def self.redis_getdel(user_id)
Gitlab::Redis::SharedState.with do |redis|
encrypted_token = redis.get(redis_shared_state_key(user_id))
redis.del(redis_shared_state_key(user_id))
redis_key = redis_shared_state_key(user_id)
encrypted_token = redis.get(redis_key)
redis.del(redis_key)
begin
Gitlab::CryptoHelper.aes256_gcm_decrypt(encrypted_token)
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
end
end
......@@ -58,6 +63,16 @@ class PersonalAccessToken < ApplicationRecord
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
def validate_scopes
......
......@@ -1519,6 +1519,10 @@ class Project < ApplicationRecord
end
end
def bots
users.project_bot
end
# Filters `users` to return only authorized users of the project
def members_among(users)
if users.is_a?(ActiveRecord::Relation) && !users.loaded?
......
# frozen_string_literal: true
module Resources
class CreateAccessTokenService < BaseService
attr_accessor :resource_type, :resource
def initialize(resource_type, resource, user, params = {})
@resource_type = resource_type
module ResourceAccessTokens
class CreateService < BaseService
def initialize(current_user, resource, params = {})
@resource_type = resource.class.name.downcase
@resource = resource
@current_user = user
@current_user = current_user
@params = params.dup
end
......@@ -33,6 +31,8 @@ module Resources
private
attr_reader :resource_type, :resource
def feature_enabled?
::Feature.enabled?(:resource_access_token, resource)
end
......@@ -85,7 +85,7 @@ module Resources
def personal_access_token_params
{
name: "#{resource_type}_bot",
name: params[:name] || "#{resource_type}_bot",
impersonation: false,
scopes: params[:scopes] || default_scopes,
expires_at: params[:expires_at] || nil
......@@ -93,7 +93,7 @@ module Resources
end
def default_scopes
Gitlab::Auth::API_SCOPES + Gitlab::Auth::REPOSITORY_SCOPES + Gitlab::Auth.registry_scopes - [:read_user]
Gitlab::Auth.resource_bot_scopes
end
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
post :create_deploy_token, path: 'deploy_token/create'
post :cleanup
end
resources :access_tokens, only: [:index, :create] do
member do
put :revoke
end
end
end
resources :autocomplete_sources, only: [] do
......
......@@ -337,6 +337,10 @@ module Gitlab
REGISTRY_SCOPES
end
def resource_bot_scopes
Gitlab::Auth::API_SCOPES + Gitlab::Auth::REPOSITORY_SCOPES + Gitlab::Auth.registry_scopes - [:read_user]
end
private
def non_admin_available_scopes
......
......@@ -6072,6 +6072,9 @@ msgstr ""
msgid "Could not revoke personal access token %{personal_access_token_name}."
msgstr ""
msgid "Could not revoke project access token %{project_access_token_name}."
msgstr ""
msgid "Could not save group ID"
msgstr ""
......@@ -17799,6 +17802,9 @@ msgstr ""
msgid "Revoked personal access token %{personal_access_token_name}!"
msgstr ""
msgid "Revoked project access token %{project_access_token_name}!"
msgstr ""
msgid "RightSidebar|adding a"
msgstr ""
......@@ -24524,6 +24530,9 @@ msgstr ""
msgid "Your new personal access token has been created."
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."
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
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
def expect_results_with_abilities(personal_access_token, abilities, success = true)
......
......@@ -179,4 +179,27 @@ describe PersonalAccessToken do
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
......@@ -6081,6 +6081,23 @@ describe Project do
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)
export_job.start
export_job.finish
......
......@@ -2,8 +2,8 @@
require 'spec_helper'
describe Resources::CreateAccessTokenService do
subject { described_class.new(resource_type, resource, user, params).execute }
describe ResourceAccessTokens::CreateService do
subject { described_class.new(user, resource, params).execute }
let_it_be(:user) { create(:user) }
let_it_be(:project) { create(:project, :private) }
......@@ -12,7 +12,7 @@ describe Resources::CreateAccessTokenService 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
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)
end
......@@ -56,7 +56,7 @@ describe Resources::CreateAccessTokenService do
end
context 'when user provides value' do
let(:params) { { name: 'Random bot' } }
let_it_be(:params) { { name: 'Random bot' } }
it 'overrides the default value' do
response = subject
......@@ -83,12 +83,12 @@ describe Resources::CreateAccessTokenService do
response = subject
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
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
response = subject
......@@ -109,7 +109,7 @@ describe Resources::CreateAccessTokenService do
end
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
response = subject
......@@ -120,7 +120,7 @@ describe Resources::CreateAccessTokenService do
end
context 'when invalid scope is passed' do
let(:params) { { scopes: [:invalid_scope] } }
let_it_be(:params) { { scopes: [:invalid_scope] } }
it 'returns error' do
response = subject
......@@ -145,14 +145,14 @@ describe Resources::CreateAccessTokenService do
end
context 'when resource is a project' do
let(:resource_type) { 'project' }
let(:resource) { project }
let_it_be(:resource_type) { '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 flag is disabled'
context 'user with valid permission' do
before do
before_all do
resource.add_maintainer(user)
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