Commit ab8d113a authored by Sean McGivern's avatar Sean McGivern

Merge branch 'incubation_5mp_google_cloud_backend_new_service_account' into 'master'

Service Account Creation Flow

See merge request gitlab-org/gitlab!73896
parents 46b0ea50 86a53b7e
......@@ -280,7 +280,10 @@ class Clusters::ClustersController < Clusters::BaseController
end
def generate_gcp_authorize_url
state = generate_session_key_redirect(clusterable.new_path(provider: :gcp).to_s)
new_path = clusterable.new_path(provider: :gcp).to_s
error_path = @project ? project_clusters_path(@project) : new_path
state = generate_session_key_redirect(new_path, error_path)
@authorize_url = GoogleApi::CloudPlatform::Client.new(
nil, callback_google_api_auth_url,
......@@ -339,9 +342,10 @@ class Clusters::ClustersController < Clusters::BaseController
session[GoogleApi::CloudPlatform::Client.session_key_for_expires_at]
end
def generate_session_key_redirect(uri)
def generate_session_key_redirect(uri, error_uri)
GoogleApi::CloudPlatform::Client.new_session_key_for_redirect_uri do |key|
session[key] = uri
session[:error_uri] = error_uri
end
end
......
......@@ -8,19 +8,36 @@ module GoogleApi
feature_category :kubernetes_management
##
# handle the response from google after the user
# goes through authentication and authorization process
def callback
token, expires_at = GoogleApi::CloudPlatform::Client
.new(nil, callback_google_api_auth_url)
.get_token(params[:code])
session[GoogleApi::CloudPlatform::Client.session_key_for_token] = token
session[GoogleApi::CloudPlatform::Client.session_key_for_expires_at] =
expires_at.to_s
redirect_uri = redirect_uri_from_session
##
# when the user declines authorizations
# `error` param is returned
if params[:error]
flash[:alert] = _('Google Cloud authorizations required')
redirect_uri = session[:error_uri]
##
# on success, the `code` param is returned
elsif params[:code]
token, expires_at = GoogleApi::CloudPlatform::Client
.new(nil, callback_google_api_auth_url)
.get_token(params[:code])
session[GoogleApi::CloudPlatform::Client.session_key_for_token] = token
session[GoogleApi::CloudPlatform::Client.session_key_for_expires_at] = expires_at.to_s
redirect_uri = redirect_uri_from_session
end
##
# or google may just timeout
rescue ::Faraday::TimeoutError, ::Faraday::ConnectionFailed
flash[:alert] = _('Timeout connecting to the Google API. Please try again.')
##
# regardless, we redirect the user appropriately
ensure
redirect_to redirect_uri_from_session
redirect_to redirect_uri
end
private
......
# frozen_string_literal: true
class Projects::GoogleCloud::BaseController < Projects::ApplicationController
feature_category :google_cloud
before_action :admin_project_google_cloud!
before_action :google_oauth2_enabled!
before_action :feature_flag_enabled!
private
def admin_project_google_cloud!
access_denied! unless can?(current_user, :admin_project_google_cloud, project)
end
def google_oauth2_enabled!
config = Gitlab::Auth::OAuth::Provider.config_for('google_oauth2')
if config.app_id.blank? || config.app_secret.blank?
access_denied! 'This GitLab instance not configured for Google Oauth2.'
end
end
def feature_flag_enabled!
access_denied! unless Feature.enabled?(:incubation_5mp_google_cloud)
end
end
# frozen_string_literal: true
class Projects::GoogleCloud::ServiceAccountsController < Projects::GoogleCloud::BaseController
before_action :validate_gcp_token!
def index
@google_cloud_path = project_google_cloud_index_path(project)
google_api_client = GoogleApi::CloudPlatform::Client.new(token_in_session, nil)
gcp_projects = google_api_client.list_projects
if gcp_projects.empty?
@js_data = {}.to_json
render status: :unauthorized, template: 'projects/google_cloud/errors/no_gcp_projects'
else
@js_data = {
gcpProjects: gcp_projects,
environments: project.environments,
cancelPath: project_google_cloud_index_path(project)
}.to_json
end
rescue Google::Apis::ClientError => error
handle_gcp_error(error, project)
end
def create
google_api_client = GoogleApi::CloudPlatform::Client.new(token_in_session, nil)
service_accounts_service = GoogleCloud::ServiceAccountsService.new(project)
gcp_project = params[:gcp_project]
environment = params[:environment]
generated_name = "GitLab :: #{@project.name} :: #{environment}"
generated_desc = "GitLab generated service account for project '#{@project.name}' and environment '#{environment}'"
service_account = google_api_client.create_service_account(gcp_project, generated_name, generated_desc)
service_account_key = google_api_client.create_service_account_key(gcp_project, service_account.unique_id)
service_accounts_service.add_for_project(
environment,
service_account.project_id,
service_account.to_json,
service_account_key.to_json
)
redirect_to project_google_cloud_index_path(project), notice: _('Service account generated successfully')
rescue Google::Apis::ClientError, Google::Apis::ServerError, Google::Apis::AuthorizationError => error
handle_gcp_error(error, project)
end
private
def validate_gcp_token!
is_token_valid = GoogleApi::CloudPlatform::Client.new(token_in_session, nil)
.validate_token(expires_at_in_session)
return if is_token_valid
return_url = project_google_cloud_service_accounts_path(project)
state = generate_session_key_redirect(request.url, return_url)
@authorize_url = GoogleApi::CloudPlatform::Client.new(nil,
callback_google_api_auth_url,
state: state).authorize_url
redirect_to @authorize_url
end
def generate_session_key_redirect(uri, error_uri)
GoogleApi::CloudPlatform::Client.new_session_key_for_redirect_uri do |key|
session[key] = uri
session[:error_uri] = error_uri
end
end
def token_in_session
session[GoogleApi::CloudPlatform::Client.session_key_for_token]
end
def expires_at_in_session
session[GoogleApi::CloudPlatform::Client.session_key_for_expires_at]
end
def handle_gcp_error(error, project)
Gitlab::ErrorTracking.track_exception(error, project_id: project.id)
@js_data = { error: error.to_s }.to_json
render status: :unauthorized, template: 'projects/google_cloud/errors/gcp_error'
end
end
# frozen_string_literal: true
class Projects::GoogleCloudController < Projects::ApplicationController
feature_category :google_cloud
before_action :admin_project_google_cloud?
before_action :google_oauth2_enabled?
before_action :feature_flag_enabled?
class Projects::GoogleCloudController < Projects::GoogleCloud::BaseController
def index
@js_data = {
serviceAccounts: GoogleCloud::ServiceAccountsService.new(project).find_for_project,
createServiceAccountUrl: '#mocked-url-create-service',
createServiceAccountUrl: project_google_cloud_service_accounts_path(project),
emptyIllustrationUrl: ActionController::Base.helpers.image_path('illustrations/pipelines_empty.svg')
}.to_json
end
private
def admin_project_google_cloud?
access_denied! unless can?(current_user, :admin_project_google_cloud, project)
end
def google_oauth2_enabled?
config = Gitlab::Auth::OAuth::Provider.config_for('google_oauth2')
if config.app_id.blank? || config.app_secret.blank?
access_denied! 'This GitLab instance not configured for Google Oauth2.'
end
end
def feature_flag_enabled?
access_denied! unless Feature.enabled?(:incubation_5mp_google_cloud)
end
end
......@@ -27,6 +27,24 @@ module GoogleCloud
end
end
def add_for_project(environment, gcp_project_id, service_account, service_account_key)
project_var_create_or_replace(
environment,
'GCP_PROJECT_ID',
gcp_project_id
)
project_var_create_or_replace(
environment,
'GCP_SERVICE_ACCOUNT',
service_account
)
project_var_create_or_replace(
environment,
'GCP_SERVICE_ACCOUNT_KEY',
service_account_key
)
end
private
def group_vars_by_environment
......@@ -36,5 +54,12 @@ module GoogleCloud
grouped[variable.environment_scope][variable.key] = variable.value
end
end
def project_var_create_or_replace(environment_scope, key, value)
params = { key: key, filter: { environment_scope: environment_scope } }
existing_variable = ::Ci::VariablesFinder.new(@project, params).execute.first
existing_variable.destroy if existing_variable
@project.variables.create!(key: key, value: value, environment_scope: environment_scope, protected: true)
end
end
end
- breadcrumb_title _('Google Cloud')
- page_title _('Google Cloud')
- @content_class = "limit-container-width" unless fluid_layout
#js-google-cloud-error-gcp-error{ data: @js_data }
- breadcrumb_title _('Google Cloud')
- page_title _('Google Cloud')
- @content_class = "limit-container-width" unless fluid_layout
#js-google-cloud-error-no-gcp-projects{ data: @js_data }
- add_to_breadcrumbs _('Google Cloud'), @google_cloud_path
- breadcrumb_title _('Service Account')
- page_title _('Service Account')
- @content_class = "limit-container-width" unless fluid_layout
= form_tag project_google_cloud_service_accounts_path(@project), method: 'post' do
#js-google-cloud-service-accounts{ data: @js_data }
......@@ -317,6 +317,10 @@ constraints(::Constraints::ProjectUrlConstrainer.new) do
resources :google_cloud, only: [:index]
namespace :google_cloud do
resources :service_accounts, only: [:index, :create]
end
resources :environments, except: [:destroy] do
member do
post :stop
......
# frozen_string_literal: true
require 'securerandom'
require 'google/apis/compute_v1'
require 'google/apis/container_v1'
require 'google/apis/container_v1beta1'
require 'google/apis/cloudbilling_v1'
require 'google/apis/cloudresourcemanager_v1'
require 'google/apis/iam_v1'
module GoogleApi
module CloudPlatform
......@@ -83,6 +85,51 @@ module GoogleApi
m[1] if m
end
def list_projects
result = []
service = Google::Apis::CloudresourcemanagerV1::CloudResourceManagerService.new
service.authorization = access_token
response = service.fetch_all(items: :projects) do |token|
service.list_projects
end
# Google API results are paged by default, so we need to iterate through
response.each do |project|
result.append(project)
end
result
end
def create_service_account(gcp_project_id, display_name, description)
name = "projects/#{gcp_project_id}"
# initialize google iam service
service = Google::Apis::IamV1::IamService.new
service.authorization = access_token
# generate account id
random_account_id = "gitlab-" + SecureRandom.hex(11)
body_params = { account_id: random_account_id,
service_account: { display_name: display_name,
description: description } }
request_body = Google::Apis::IamV1::CreateServiceAccountRequest.new(**body_params)
service.create_service_account(name, request_body)
end
def create_service_account_key(gcp_project_id, service_account_id)
service = Google::Apis::IamV1::IamService.new
service.authorization = access_token
name = "projects/#{gcp_project_id}/serviceAccounts/#{service_account_id}"
request_body = Google::Apis::IamV1::CreateServiceAccountKeyRequest.new
service.create_service_account_key(name, request_body)
end
private
def make_cluster_options(cluster_name, cluster_size, machine_type, legacy_abac, enable_addons)
......
......@@ -16212,6 +16212,9 @@ msgstr ""
msgid "Google Cloud Project"
msgstr ""
msgid "Google Cloud authorizations required"
msgstr ""
msgid "Google authentication is not %{link_start}properly configured%{link_end}. Ask your GitLab administrator if you want to use this service."
msgstr ""
......@@ -31628,6 +31631,9 @@ msgstr ""
msgid "Service URL"
msgstr ""
msgid "Service account generated successfully"
msgstr ""
msgid "Service ping is disabled in your configuration file, and cannot be enabled through this form."
msgstr ""
......
......@@ -88,5 +88,26 @@ RSpec.describe GoogleApi::AuthorizationsController do
it_behaves_like 'access denied'
end
context 'user logs in but declines authorizations' do
subject { get :callback, params: { error: 'xxx', state: state } }
let(:session_key) { 'session-key' }
let(:redirect_uri) { 'example.com' }
let(:error_uri) { 'error.com' }
let(:state) { session_key }
before do
session[GoogleApi::CloudPlatform::Client.session_key_for_redirect_uri(session_key)] = redirect_uri
session[:error_uri] = error_uri
allow_next_instance_of(GoogleApi::CloudPlatform::Client) do |instance|
allow(instance).to receive(:get_token).and_return([token, expires_at])
end
end
it 'redirects to error uri' do
expect(subject).to redirect_to(error_uri)
end
end
end
end
......@@ -209,4 +209,47 @@ RSpec.describe GoogleApi::CloudPlatform::Client do
expect(subject.header).to eq({ 'User-Agent': 'GitLab/10.3 (GPN:GitLab;)' })
end
end
describe '#list_projects' do
subject { client.list_projects }
let(:list_of_projects) { [{}, {}, {}] }
let(:next_page_token) { nil }
let(:operation) { double('projects': list_of_projects, 'next_page_token': next_page_token) }
it 'calls Google Api CloudResourceManagerService#list_projects' do
expect_any_instance_of(Google::Apis::CloudresourcemanagerV1::CloudResourceManagerService)
.to receive(:list_projects)
.and_return(operation)
is_expected.to eq(list_of_projects)
end
end
describe '#create_service_account' do
subject { client.create_service_account(spy, spy, spy) }
let(:operation) { double('Service Account') }
it 'calls Google Api IamService#create_service_account' do
expect_any_instance_of(Google::Apis::IamV1::IamService)
.to receive(:create_service_account)
.with(any_args)
.and_return(operation)
is_expected.to eq(operation)
end
end
describe '#create_service_account_key' do
subject { client.create_service_account_key(spy, spy) }
let(:operation) { double('Service Account Key') }
it 'class Google Api IamService#create_service_account_key' do
expect_any_instance_of(Google::Apis::IamV1::IamService)
.to receive(:create_service_account_key)
.with(any_args)
.and_return(operation)
is_expected.to eq(operation)
end
end
end
# frozen_string_literal: true
require 'spec_helper'
# Mock Types
MockGoogleOAuth2Credentials = Struct.new(:app_id, :app_secret)
MockServiceAccount = Struct.new(:project_id, :unique_id)
RSpec.describe Projects::GoogleCloud::ServiceAccountsController do
let_it_be(:project) { create(:project, :public) }
describe 'GET index' do
let_it_be(:url) { "#{project_google_cloud_service_accounts_path(project)}" }
let(:user_guest) { create(:user) }
let(:user_developer) { create(:user) }
let(:user_maintainer) { create(:user) }
let(:user_creator) { project.creator }
let(:unauthorized_members) { [user_guest, user_developer] }
let(:authorized_members) { [user_maintainer, user_creator] }
before do
project.add_guest(user_guest)
project.add_developer(user_developer)
project.add_maintainer(user_maintainer)
end
context 'when a public request is made' do
it 'returns not found on GET request' do
get url
expect(response).to have_gitlab_http_status(:not_found)
end
it 'returns not found on POST request' do
post url
expect(response).to have_gitlab_http_status(:not_found)
end
end
context 'when unauthorized members make requests' do
it 'returns not found on GET request' do
unauthorized_members.each do |unauthorized_member|
sign_in(unauthorized_member)
get url
expect(response).to have_gitlab_http_status(:not_found)
end
end
it 'returns not found on POST request' do
unauthorized_members.each do |unauthorized_member|
sign_in(unauthorized_member)
post url
expect(response).to have_gitlab_http_status(:not_found)
end
end
end
context 'when authorized members make requests' do
it 'redirects on GET request' do
authorized_members.each do |authorized_member|
sign_in(authorized_member)
get url
expect(response).to redirect_to(assigns(:authorize_url))
end
end
it 'redirects on POST request' do
authorized_members.each do |authorized_member|
sign_in(authorized_member)
post url
expect(response).to redirect_to(assigns(:authorize_url))
end
end
context 'and user has successfully completed the google oauth2 flow' do
before do
allow_next_instance_of(GoogleApi::CloudPlatform::Client) do |client|
allow(client).to receive(:validate_token).and_return(true)
allow(client).to receive(:list_projects).and_return([{}, {}, {}])
allow(client).to receive(:create_service_account).and_return(MockServiceAccount.new(123, 456))
allow(client).to receive(:create_service_account_key).and_return({})
end
end
it 'returns success on GET' do
authorized_members.each do |authorized_member|
sign_in(authorized_member)
get url
expect(response).to have_gitlab_http_status(:ok)
end
end
it 'returns success on POST' do
authorized_members.each do |authorized_member|
sign_in(authorized_member)
post url, params: { gcp_project: 'prj1', environment: 'env1' }
expect(response).to redirect_to(project_google_cloud_index_path(project))
end
end
end
context 'but google returns client error' do
before do
allow_next_instance_of(GoogleApi::CloudPlatform::Client) do |client|
allow(client).to receive(:validate_token).and_return(true)
allow(client).to receive(:list_projects).and_raise(Google::Apis::ClientError.new(''))
allow(client).to receive(:create_service_account).and_raise(Google::Apis::ClientError.new(''))
allow(client).to receive(:create_service_account_key).and_raise(Google::Apis::ClientError.new(''))
end
end
it 'renders gcp_error template on GET' do
authorized_members.each do |authorized_member|
sign_in(authorized_member)
get url
expect(response).to render_template(:gcp_error)
end
end
it 'renders gcp_error template on POST' do
authorized_members.each do |authorized_member|
sign_in(authorized_member)
post url, params: { gcp_project: 'prj1', environment: 'env1' }
expect(response).to render_template(:gcp_error)
end
end
end
context 'but gitlab instance is not configured for google oauth2' do
before do
unconfigured_google_oauth2 = MockGoogleOAuth2Credentials.new('', '')
allow(Gitlab::Auth::OAuth::Provider).to receive(:config_for)
.with('google_oauth2')
.and_return(unconfigured_google_oauth2)
end
it 'returns forbidden' do
authorized_members.each do |authorized_member|
sign_in(authorized_member)
get url
expect(response).to have_gitlab_http_status(:forbidden)
end
end
end
context 'but feature flag is disabled' do
before do
stub_feature_flags(incubation_5mp_google_cloud: false)
end
it 'returns not found' do
authorized_members.each do |authorized_member|
sign_in(authorized_member)
get url
expect(response).to have_gitlab_http_status(:not_found)
end
end
end
end
end
end
......@@ -3,11 +3,11 @@
require 'spec_helper'
RSpec.describe GoogleCloud::ServiceAccountsService do
let_it_be(:project) { create(:project) }
let(:service) { described_class.new(project) }
describe 'find_for_project' do
let_it_be(:project) { create(:project) }
context 'when a project does not have GCP service account vars' do
before do
project.variables.build(key: 'blah', value: 'foo', environment_scope: 'world')
......@@ -21,13 +21,13 @@ RSpec.describe GoogleCloud::ServiceAccountsService do
context 'when a project has GCP service account ci vars' do
before do
project.variables.build(environment_scope: '*', key: 'GCP_PROJECT_ID', value: 'prj1')
project.variables.build(environment_scope: '*', key: 'GCP_SERVICE_ACCOUNT_KEY', value: 'mock')
project.variables.build(environment_scope: 'staging', key: 'GCP_PROJECT_ID', value: 'prj2')
project.variables.build(environment_scope: 'staging', key: 'GCP_SERVICE_ACCOUNT', value: 'mock')
project.variables.build(environment_scope: 'production', key: 'GCP_PROJECT_ID', value: 'prj3')
project.variables.build(environment_scope: 'production', key: 'GCP_SERVICE_ACCOUNT', value: 'mock')
project.variables.build(environment_scope: 'production', key: 'GCP_SERVICE_ACCOUNT_KEY', value: 'mock')
project.variables.build(protected: true, environment_scope: '*', key: 'GCP_PROJECT_ID', value: 'prj1')
project.variables.build(protected: true, environment_scope: '*', key: 'GCP_SERVICE_ACCOUNT_KEY', value: 'mock')
project.variables.build(protected: true, environment_scope: 'staging', key: 'GCP_PROJECT_ID', value: 'prj2')
project.variables.build(protected: true, environment_scope: 'staging', key: 'GCP_SERVICE_ACCOUNT', value: 'mock')
project.variables.build(protected: true, environment_scope: 'production', key: 'GCP_PROJECT_ID', value: 'prj3')
project.variables.build(protected: true, environment_scope: 'production', key: 'GCP_SERVICE_ACCOUNT', value: 'mock')
project.variables.build(protected: true, environment_scope: 'production', key: 'GCP_SERVICE_ACCOUNT_KEY', value: 'mock')
project.save!
end
......@@ -55,4 +55,55 @@ RSpec.describe GoogleCloud::ServiceAccountsService do
end
end
end
describe 'add_for_project' do
let_it_be(:project) { create(:project) }
it 'saves GCP creds as project CI vars' do
service.add_for_project('env_1', 'gcp_prj_id_1', 'srv_acc_1', 'srv_acc_key_1')
service.add_for_project('env_2', 'gcp_prj_id_2', 'srv_acc_2', 'srv_acc_key_2')
list = service.find_for_project
aggregate_failures 'testing list of service accounts' do
expect(list.length).to eq(2)
expect(list.first[:environment]).to eq('env_1')
expect(list.first[:gcp_project]).to eq('gcp_prj_id_1')
expect(list.first[:service_account_exists]).to eq(true)
expect(list.first[:service_account_key_exists]).to eq(true)
expect(list.second[:environment]).to eq('env_2')
expect(list.second[:gcp_project]).to eq('gcp_prj_id_2')
expect(list.second[:service_account_exists]).to eq(true)
expect(list.second[:service_account_key_exists]).to eq(true)
end
end
it 'replaces previously stored CI vars with new CI vars' do
service.add_for_project('env_1', 'new_project', 'srv_acc_1', 'srv_acc_key_1')
list = service.find_for_project
aggregate_failures 'testing list of service accounts' do
expect(list.length).to eq(2)
# asserting that the first service account is replaced
expect(list.first[:environment]).to eq('env_1')
expect(list.first[:gcp_project]).to eq('new_project')
expect(list.first[:service_account_exists]).to eq(true)
expect(list.first[:service_account_key_exists]).to eq(true)
expect(list.second[:environment]).to eq('env_2')
expect(list.second[:gcp_project]).to eq('gcp_prj_id_2')
expect(list.second[:service_account_exists]).to eq(true)
expect(list.second[:service_account_key_exists]).to eq(true)
end
end
it 'underlying project CI vars must be protected' do
expect(project.variables.first.protected).to eq(true)
expect(project.variables.second.protected).to eq(true)
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