Move Web IDE terminal to Core

parent 8f56f4b6
......@@ -6,9 +6,11 @@ class IdeController < ApplicationController
include ClientsidePreviewCSP
include StaticObjectExternalStorageCSP
before_action do
push_frontend_feature_flag(:build_service_proxy)
end
def index
Gitlab::UsageDataCounters::WebIdeCounter.increment_views_count
end
end
IdeController.prepend_if_ee('EE::IdeController')
......@@ -14,6 +14,8 @@ class Projects::JobsController < Projects::ApplicationController
before_action only: [:show] do
push_frontend_feature_flag(:job_log_json, project, default_enabled: true)
end
before_action :authorize_create_proxy_build!, only: :proxy_websocket_authorize
before_action :verify_proxy_request!, only: :proxy_websocket_authorize
layout 'project'
......@@ -151,6 +153,10 @@ class Projects::JobsController < Projects::ApplicationController
render json: Gitlab::Workhorse.channel_websocket(@build.terminal_specification)
end
def proxy_websocket_authorize
render json: proxy_websocket_service(build_service_specification)
end
private
def authorize_update_build!
......@@ -165,10 +171,19 @@ class Projects::JobsController < Projects::ApplicationController
return access_denied! unless can?(current_user, :create_build_terminal, build)
end
def authorize_create_proxy_build!
return access_denied! unless can?(current_user, :create_build_service_proxy, build)
end
def verify_api_request!
Gitlab::Workhorse.verify_api_request!(request.headers)
end
def verify_proxy_request!
verify_api_request!
set_workhorse_internal_api_content_type
end
def raw_send_params
{ type: 'text/plain; charset=utf-8', disposition: 'inline' }
end
......@@ -202,6 +217,27 @@ class Projects::JobsController < Projects::ApplicationController
'attachment'
end
end
Projects::JobsController.prepend_if_ee('EE::Projects::JobsController')
def build_service_specification
build.service_specification(service: params['service'],
port: params['port'],
path: params['path'],
subprotocols: proxy_subprotocol)
end
def proxy_subprotocol
# This will allow to reuse the same subprotocol set
# in the original websocket connection
request.headers['HTTP_SEC_WEBSOCKET_PROTOCOL'].presence || ::Ci::BuildRunnerSession::TERMINAL_SUBPROTOCOL
end
# This method provides the information to Workhorse
# about the service we want to proxy to.
# For security reasons, in case this operation is started by JS,
# it's important to use only sourced GitLab JS code
def proxy_websocket_service(service)
service[:url] = ::Gitlab::UrlHelpers.as_wss(service[:url])
::Gitlab::Workhorse.channel_websocket(service)
end
end
......@@ -55,6 +55,7 @@ module Ci
delegate :url, to: :runner_session, prefix: true, allow_nil: true
delegate :terminal_specification, to: :runner_session, allow_nil: true
delegate :service_specification, to: :runner_session, allow_nil: true
delegate :gitlab_deploy_token, to: :project
delegate :trigger_short_token, to: :trigger_request, allow_nil: true
......
......@@ -7,6 +7,8 @@ module Ci
extend Gitlab::Ci::Model
TERMINAL_SUBPROTOCOL = 'terminal.gitlab.com'
DEFAULT_SERVICE_NAME = 'build'.freeze
DEFAULT_PORT_NAME = 'default_port'.freeze
self.table_name = 'ci_builds_runner_session'
......@@ -23,6 +25,17 @@ module Ci
channel_specification(wss_url, TERMINAL_SUBPROTOCOL)
end
def service_specification(service: nil, path: nil, port: nil, subprotocols: nil)
return {} unless url.present?
port = port.presence || DEFAULT_PORT_NAME
service = service.presence || DEFAULT_SERVICE_NAME
url = "#{self.url}/proxy/#{service}/#{port}/#{path}"
subprotocols = subprotocols.presence || ::Ci::BuildRunnerSession::TERMINAL_SUBPROTOCOL
channel_specification(url, subprotocols)
end
private
def channel_specification(url, subprotocol)
......@@ -37,5 +50,3 @@ module Ci
end
end
end
Ci::BuildRunnerSession.prepend_if_ee('EE::Ci::BuildRunnerSession')
......@@ -27,6 +27,7 @@ module Ci
# https://gitlab.com/gitlab-org/gitlab/issues/195991
pipeline: 7,
chat: 8,
webide: 9,
merge_request_event: 10,
external_pull_request_event: 11,
parent_pipeline: 12
......@@ -40,6 +41,7 @@ module Ci
unknown_source: nil,
repository_source: 1,
auto_devops_source: 2,
webide_source: 3,
remote_source: 4,
external_project_source: 5,
bridge_source: 6
......
......@@ -328,6 +328,8 @@ class Project < ApplicationRecord
has_many :repository_storage_moves, class_name: 'ProjectRepositoryStorageMove'
has_many :webide_pipelines, -> { webide_source }, class_name: 'Ci::Pipeline', inverse_of: :project
accepts_nested_attributes_for :variables, allow_destroy: true
accepts_nested_attributes_for :project_feature, update_only: true
accepts_nested_attributes_for :project_setting, update_only: true
......@@ -733,6 +735,10 @@ class Project < ApplicationRecord
end
end
def active_webide_pipelines(user:)
webide_pipelines.running_or_pending.for_user(user)
end
def autoclose_referenced_issues
return true if super.nil?
......
......@@ -36,6 +36,10 @@ module Ci
@subject.has_terminal?
end
condition(:is_web_ide_terminal, scope: :subject) do
@subject.pipeline.webide?
end
rule { protected_ref | archived }.policy do
prevent :update_build
prevent :update_commit_status
......@@ -50,6 +54,24 @@ module Ci
end
rule { can?(:update_build) & terminal }.enable :create_build_terminal
rule { is_web_ide_terminal & can?(:create_web_ide_terminal) & (admin | owner_of_job) }.policy do
enable :read_web_ide_terminal
enable :update_web_ide_terminal
end
rule { is_web_ide_terminal & ~can?(:update_web_ide_terminal) }.policy do
prevent :create_build_terminal
end
rule { can?(:update_web_ide_terminal) & terminal }.policy do
enable :create_build_terminal
enable :create_build_service_proxy
end
rule { ~can?(:build_service_proxy_enabled) }.policy do
prevent :create_build_service_proxy
end
end
end
......
......@@ -147,6 +147,10 @@ class ProjectPolicy < BasePolicy
@user && @user.confirmed?
end
condition(:build_service_proxy_enabled) do
::Feature.enabled?(:build_service_proxy, @subject)
end
features = %w[
merge_requests
issues
......@@ -559,6 +563,10 @@ class ProjectPolicy < BasePolicy
enable :read_project
end
rule { can?(:create_pipeline) & can?(:maintainer_access) }.enable :create_web_ide_terminal
rule { build_service_proxy_enabled }.enable :build_service_proxy_enabled
private
def team_member?
......
......@@ -49,9 +49,11 @@ constraints(::Constraints::ProjectUrlConstrainer.new) do
get :trace, defaults: { format: 'json' }
get :raw
get :terminal
get :proxy
# This route is also defined in gitlab-workhorse. Make sure to update accordingly.
# These routes are also defined in gitlab-workhorse. Make sure to update accordingly.
get '/terminal.ws/authorize', to: 'jobs#terminal_websocket_authorize', format: false
get '/proxy.ws/authorize', to: 'jobs#proxy_websocket_authorize', format: false
end
resource :artifacts, only: [] do
......@@ -472,6 +474,17 @@ constraints(::Constraints::ProjectUrlConstrainer.new) do
post :web_ide_pipelines_count
end
resources :web_ide_terminals, path: :ide_terminals, only: [:create, :show], constraints: { id: /\d+/, format: :json } do # rubocop: disable Cop/PutProjectRoutesUnderScope
member do
post :cancel
post :retry
end
collection do
post :check_config
end
end
# Deprecated unscoped routing.
# Issue https://gitlab.com/gitlab-org/gitlab/issues/118849
scope as: 'deprecated' do
......
# frozen_string_literal: true
module EE
module IdeController
extend ActiveSupport::Concern
prepended do
before_action do
push_frontend_feature_flag(:build_service_proxy)
end
end
end
end
# frozen_string_literal: true
module EE
module Projects
module JobsController
extend ActiveSupport::Concern
prepended do
before_action :authorize_create_proxy_build!, only: :proxy_websocket_authorize
before_action :verify_proxy_request!, only: :proxy_websocket_authorize
end
def proxy_websocket_authorize
render json: proxy_websocket_service(build_service_specification)
end
private
def authorize_create_proxy_build!
return access_denied! unless can?(current_user, :create_build_service_proxy, build)
end
def verify_proxy_request!
::Gitlab::Workhorse.verify_api_request!(request.headers)
set_workhorse_internal_api_content_type
end
# This method provides the information to Workhorse
# about the service we want to proxy to.
# For security reasons, in case this operation is started by JS,
# it's important to use only sourced GitLab JS code
def proxy_websocket_service(service)
service[:url] = ::Gitlab::UrlHelpers.as_wss(service[:url])
::Gitlab::Workhorse.channel_websocket(service)
end
def build_service_specification
build.service_specification(service: params['service'],
port: params['port'],
path: params['path'],
subprotocols: proxy_subprotocol)
end
def proxy_subprotocol
# This will allow to reuse the same subprotocol set
# in the original websocket connection
request.headers['HTTP_SEC_WEBSOCKET_PROTOCOL'].presence || ::Ci::BuildRunnerSession::TERMINAL_SUBPROTOCOL
end
end
end
end
# frozen_string_literal: true
module EE
module Ci
module BuildRunnerSession
extend ActiveSupport::Concern
DEFAULT_SERVICE_NAME = 'build'.freeze
DEFAULT_PORT_NAME = 'default_port'.freeze
def service_specification(service: nil, path: nil, port: nil, subprotocols: nil)
return {} unless url.present?
port = port.presence || DEFAULT_PORT_NAME
service = service.presence || DEFAULT_SERVICE_NAME
url = "#{self.url}/proxy/#{service}/#{port}/#{path}"
subprotocols = subprotocols.presence || ::Ci::BuildRunnerSession::TERMINAL_SUBPROTOCOL
channel_specification(url, subprotocols)
end
end
end
end
......@@ -16,16 +16,6 @@ module EE
job_activity_limit_exceeded: 22
)
end
override :sources
def sources
super.merge(webide: 9)
end
override :config_sources
def config_sources
super.merge(webide_source: 3)
end
end
end
end
......
......@@ -77,8 +77,6 @@ module EE
has_many :package_files, through: :packages, class_name: 'Packages::PackageFile'
has_many :merge_trains, foreign_key: 'target_project_id', inverse_of: :target_project
has_many :webide_pipelines, -> { webide_source }, class_name: 'Ci::Pipeline', inverse_of: :project
has_many :operations_feature_flags, class_name: 'Operations::FeatureFlag'
has_one :operations_feature_flags_client, class_name: 'Operations::FeatureFlagsClient'
has_many :operations_feature_flags_user_lists, class_name: 'Operations::FeatureFlags::UserList'
......@@ -641,10 +639,6 @@ module EE
end
end
def active_webide_pipelines(user:)
webide_pipelines.running_or_pending.for_user(user)
end
override :lfs_http_url_to_repo
def lfs_http_url_to_repo(operation)
return super unless ::Gitlab::Geo.secondary_with_primary?
......
......@@ -132,7 +132,6 @@ class License < ApplicationRecord
subepics
threat_monitoring
tracing
web_ide_terminal
]
EEU_FEATURES.freeze
......
......@@ -11,28 +11,6 @@ module EE
prevent :update_build
end
condition(:is_web_ide_terminal, scope: :subject) do
@subject.pipeline.webide?
end
rule { is_web_ide_terminal & can?(:create_web_ide_terminal) & (admin | owner_of_job) }.policy do
enable :read_web_ide_terminal
enable :update_web_ide_terminal
end
rule { is_web_ide_terminal & ~can?(:update_web_ide_terminal) }.policy do
prevent :create_build_terminal
end
rule { can?(:update_web_ide_terminal) & terminal }.policy do
enable :create_build_terminal
enable :create_build_service_proxy
end
rule { ~can?(:build_service_proxy_enabled) }.policy do
prevent :create_build_service_proxy
end
private
alias_method :current_user, :user
......
......@@ -329,14 +329,6 @@ module EE
end
end
condition(:web_ide_terminal_available) do
@subject.feature_available?(:web_ide_terminal)
end
condition(:build_service_proxy_enabled) do
::Feature.enabled?(:build_service_proxy, @subject)
end
condition(:needs_new_sso_session) do
::Gitlab::Auth::GroupSaml::SsoEnforcer.group_access_restricted?(subject.group)
end
......@@ -380,10 +372,6 @@ module EE
prevent :modify_approvers_list
end
rule { web_ide_terminal_available & can?(:create_pipeline) & can?(:maintainer_access) }.enable :create_web_ide_terminal
rule { build_service_proxy_enabled }.enable :build_service_proxy_enabled
rule { can?(:read_merge_request) & code_review_analytics_enabled }.enable :read_code_review_analytics
rule { can?(:read_project) & requirements_available }.enable :read_requirement
......
......@@ -22,13 +22,6 @@ constraints(::Constraints::ProjectUrlConstrainer.new) do
end
end
resources :jobs, only: [], constraints: { id: /\d+/ } do
member do
get '/proxy.ws/authorize', to: 'jobs#proxy_websocket_authorize', format: false
get :proxy
end
end
resources :feature_flags, param: :iid
resource :feature_flags_client, only: [] do
post :reset_token
......@@ -115,17 +108,6 @@ constraints(::Constraints::ProjectUrlConstrainer.new) do
resource :tracing, only: [:show]
resources :web_ide_terminals, path: :ide_terminals, only: [:create, :show], constraints: { id: /\d+/, format: :json } do
member do
post :cancel
post :retry
end
collection do
post :check_config
end
end
get '/service_desk' => 'service_desk#show', as: :service_desk
put '/service_desk' => 'service_desk#update', as: :service_desk_refresh
......
......@@ -3,174 +3,12 @@
require 'spec_helper'
describe Projects::JobsController do
let(:owner) { create(:owner) }
let(:admin) { create(:admin) }
let(:maintainer) { create(:user) }
let(:developer) { create(:user) }
let(:reporter) { create(:user) }
let(:guest) { create(:user) }
let(:project) { create(:project, :private, :repository, namespace: owner.namespace) }
let(:pipeline) { create(:ci_pipeline, project: project, source: :webide, config_source: :webide_source, user: user) }
let(:job) { create(:ci_build, :running, :with_runner_session, pipeline: pipeline, user: user) }
let(:user) { maintainer }
let(:extra_params) { { id: job.id } }
before do
stub_licensed_features(web_ide_terminal: true)
stub_feature_flags(build_service_proxy: true)
allow(job).to receive(:has_terminal?).and_return(true)
project.add_maintainer(maintainer)
project.add_developer(developer)
project.add_reporter(reporter)
project.add_guest(guest)
sign_in(user)
end
shared_examples 'proxy access rights' do
before do
allow(Gitlab::Workhorse).to receive(:verify_api_request!).and_return(nil)
make_request
end
context 'with admin' do
let(:user) { admin }
context 'when admin mode is enabled', :enable_admin_mode do
it 'returns 200' do
expect(response).to have_gitlab_http_status(:ok)
end
end
context 'when admin mode is disabled' do
it 'returns 404' do
expect(response).to have_gitlab_http_status(:not_found)
end
end
end
context 'with owner' do
let(:user) { owner }
it 'returns 200' do
expect(response).to have_gitlab_http_status(:ok)
end
end
context 'with maintainer' do
let(:user) { maintainer }
it 'returns 200' do
expect(response).to have_gitlab_http_status(:ok)
end
end
context 'with developer' do
let(:user) { developer }
it 'returns 404' do
expect(response).to have_gitlab_http_status(:not_found)
end
end
context 'with reporter' do
let(:user) { reporter }
it 'returns 404' do
expect(response).to have_gitlab_http_status(:not_found)
end
end
context 'with guest' do
let(:user) { guest }
it 'returns 404' do
expect(response).to have_gitlab_http_status(:not_found)
end
end
context 'with non member' do
let(:user) { create(:user) }
it 'returns 404' do
expect(response).to have_gitlab_http_status(:not_found)
end
end
end
shared_examples 'when pipeline is not from a webide source' do
context 'with admin' do
let(:user) { admin }
let(:pipeline) { create(:ci_pipeline, project: project, source: :chat, user: user) }
before do
allow(Gitlab::Workhorse).to receive(:verify_api_request!).and_return(nil)
make_request
end
it 'returns 404' do
expect(response).to have_gitlab_http_status(:not_found)
end
end
end
shared_examples 'validates workhorse signature' do
context 'with valid workhorse signature' do
before do
allow(Gitlab::Workhorse).to receive(:verify_api_request!).and_return(nil)
end
context 'and valid id' do
it 'returns the proxy data for the service running in the job' do
make_request
expect(response).to have_gitlab_http_status(:ok)
expect(response.headers["Content-Type"]).to eq(Gitlab::Workhorse::INTERNAL_API_CONTENT_TYPE)
expect(response.body).to eq(expected_data)
end
end
context 'and invalid id' do
let(:extra_params) { { id: non_existing_record_id } }
it 'returns 404' do
make_request
expect(response).to have_gitlab_http_status(:not_found)
end
end
end
context 'with invalid workhorse signature' do
it 'aborts with an exception' do
allow(Gitlab::Workhorse).to receive(:verify_api_request!).and_raise(JWT::DecodeError)
expect { make_request }.to raise_error(JWT::DecodeError)
end
end
end
shared_examples 'feature flag "build_service_proxy" is disabled' do
let(:user) { admin }
it 'returns 404' do
allow(Gitlab::Workhorse).to receive(:verify_api_request!).and_return(nil)
stub_feature_flags(build_service_proxy: false)
make_request
expect(response).to have_gitlab_http_status(:not_found)
end
end
describe 'GET #show', :clean_gitlab_redis_shared_state do
context 'when requesting JSON' do
context 'with shared runner that has quota' do
let(:project) { create(:project, :private, shared_runners_enabled: true) }
let_it_be(:user) { create(:user) }
let(:merge_request) { create(:merge_request, source_project: project) }
let(:runner) { create(:ci_runner, :instance, description: 'Shared runner') }
let(:pipeline) { create(:ci_pipeline, project: project) }
let(:job) { create(:ci_build, :success, :artifacts, pipeline: pipeline, runner: runner) }
before do
......@@ -180,7 +18,12 @@ describe Projects::JobsController do
allow_next_instance_of(Ci::Build) do |instance|
allow(instance).to receive(:merge_request).and_return(merge_request)
end
end
context 'with shared runner that has quota' do
let(:project) { create(:project, :private, shared_runners_enabled: true) }
before do
stub_application_setting(shared_runners_minutes: 2)
get_show(id: job.id, format: :json)
......@@ -197,14 +40,8 @@ describe Projects::JobsController do
context 'with shared runner quota exceeded' do
let(:group) { create(:group, :with_used_build_minutes_limit) }
let(:project) { create(:project, :repository, namespace: group, shared_runners_enabled: true) }
let(:runner) { create(:ci_runner, :instance, description: 'Shared runner') }
let(:pipeline) { create(:ci_pipeline, project: project) }
let(:job) { create(:ci_build, :success, :artifacts, pipeline: pipeline, runner: runner) }
before do
project.add_developer(user)
sign_in(user)
get_show(id: job.id, format: :json)
end
......@@ -218,19 +55,8 @@ describe Projects::JobsController do
context 'when shared runner has no quota' do
let(:project) { create(:project, :private, shared_runners_enabled: true) }
let(:merge_request) { create(:merge_request, source_project: project) }
let(:pipeline) { create(:ci_pipeline, project: project) }
let(:runner) { create(:ci_runner, :instance, description: 'Shared runner') }
let(:job) { create(:ci_build, :success, :artifacts, pipeline: pipeline, runner: runner) }
before do
project.add_developer(user)
sign_in(user)
allow_next_instance_of(Ci::Build) do |instance|
allow(instance).to receive(:merge_request).and_return(merge_request)
end
stub_application_setting(shared_runners_minutes: 0)
get_show(id: job.id, format: :json)
......@@ -245,19 +71,8 @@ describe Projects::JobsController do
context 'when project is public' do
let(:project) { create(:project, :public, shared_runners_enabled: true) }
let(:merge_request) { create(:merge_request, source_project: project) }
let(:pipeline) { create(:ci_pipeline, project: project) }
let(:runner) { create(:ci_runner, :instance, description: 'Shared runner') }
let(:job) { create(:ci_build, :success, :artifacts, pipeline: pipeline, runner: runner) }
before do
project.add_developer(user)
sign_in(user)
allow_next_instance_of(Ci::Build) do |instance|
allow(instance).to receive(:merge_request).and_return(merge_request)
end
stub_application_setting(shared_runners_minutes: 2)
get_show(id: job.id, format: :json)
......@@ -282,45 +97,4 @@ describe Projects::JobsController do
get :show, params: params.merge(extra_params)
end
end
describe 'GET #proxy_websocket_authorize' do
let(:path) { :proxy_websocket_authorize }
let(:render_method) { :channel_websocket }
let(:expected_data) do
{
'Channel' => {
'Subprotocols' => ["terminal.gitlab.com"],
'Url' => 'wss://localhost/proxy/build/default_port/',
'Header' => {
'Authorization' => [nil]
},
'MaxSessionTime' => nil,
'CAPem' => nil
}
}.to_json
end
it_behaves_like 'proxy access rights'
it_behaves_like 'when pipeline is not from a webide source'
it_behaves_like 'validates workhorse signature'
it_behaves_like 'feature flag "build_service_proxy" is disabled'
it 'converts the url scheme into wss' do
allow(Gitlab::Workhorse).to receive(:verify_api_request!).and_return(nil)
expect(job.runner_session_url).to start_with('https://')
expect(Gitlab::Workhorse).to receive(:channel_websocket).with(a_hash_including(url: "wss://localhost/proxy/build/default_port/"))
make_request
end
end
def make_request
params = {
namespace_id: project.namespace.to_param,
project_id: project
}
get path, params: params.merge(extra_params)
end
end
......@@ -2,11 +2,6 @@
FactoryBot.define do
factory :ee_ci_pipeline, class: 'Ci::Pipeline', parent: :ci_pipeline do
trait :webide do
source { :webide }
config_source { :webide_source }
end
%i[container_scanning dast dependency_list dependency_scanning license_management license_scanning sast].each do |report_type|
trait "with_#{report_type}_report".to_sym do
status { :success }
......
# frozen_string_literal: true
require 'spec_helper'
describe Ci::BuildRunnerSession, model: true do
let!(:build) { create(:ci_build, :with_runner_session) }
subject { build.runner_session }
describe '#service_specification' do
let(:service) { 'foo'}
let(:port) { 80 }
let(:path) { 'path' }
let(:subprotocols) { nil }
let(:specification) { subject.service_specification(service: service, port: port, path: path, subprotocols: subprotocols) }
it 'returns service proxy url' do
expect(specification[:url]).to eq "https://localhost/proxy/#{service}/#{port}/#{path}"
end
it 'returns default service proxy websocket subprotocol' do
expect(specification[:subprotocols]).to eq %w[terminal.gitlab.com]
end
it 'returns empty hash if no url' do
subject.url = ''
expect(specification).to be_empty
end
context 'when port is not present' do
let(:port) { nil }
it 'uses the default port name' do
expect(specification[:url]).to eq "https://localhost/proxy/#{service}/default_port/#{path}"
end
end
context 'when the service is not present' do
let(:service) { '' }
it 'uses the service name "build" as default' do
expect(specification[:url]).to eq "https://localhost/proxy/build/#{port}/#{path}"
end
end
context 'when url is present' do
it 'returns ca_pem nil if empty certificate' do
subject.certificate = ''
expect(specification[:ca_pem]).to be_nil
end
it 'adds Authorization header if authorization is present' do
subject.authorization = 'foobar'
expect(specification[:headers]).to include(Authorization: ['foobar'])
end
end
context 'when subprotocol is present' do
let(:subprotocols) { 'foobar' }
it 'returns the new subprotocol' do
expect(specification[:subprotocols]).to eq [subprotocols]
end
end
end
end
......@@ -32,142 +32,4 @@ describe Ci::BuildPolicy do
end
end
end
describe 'manage a web ide terminal' do
let(:build_permissions) { %i[read_web_ide_terminal create_build_terminal update_web_ide_terminal create_build_service_proxy] }
let_it_be(:maintainer) { create(:user) }
let(:owner) { create(:owner) }
let(:admin) { create(:admin) }
let(:maintainer) { create(:user) }
let(:developer) { create(:user) }
let(:reporter) { create(:user) }
let(:guest) { create(:user) }
let(:project) { create(:project, :public, namespace: owner.namespace) }
let(:pipeline) { create(:ci_empty_pipeline, project: project, source: :webide) }
let(:build) { create(:ci_build, pipeline: pipeline) }
before do
stub_licensed_features(web_ide_terminal: true)
allow(build).to receive(:has_terminal?).and_return(true)
project.add_maintainer(maintainer)
project.add_developer(developer)
project.add_reporter(reporter)
project.add_guest(guest)
end
subject { described_class.new(current_user, build) }
context 'when create_web_ide_terminal access disabled' do
let(:current_user) { admin }
before do
stub_licensed_features(web_ide_terminal: false)
expect(current_user.can?(:create_web_ide_terminal, project)).to eq false
end
it { expect_disallowed(*build_permissions) }
end
context 'when create_web_ide_terminal access enabled' do
context 'with admin' do
let(:current_user) { admin }
context 'when admin mode enabled', :enable_admin_mode do
it { expect_allowed(*build_permissions) }
end
context 'when admin mode disabled' do
it { expect_disallowed(*build_permissions) }
end
context 'when build is not from a webide pipeline' do
let(:pipeline) { create(:ci_empty_pipeline, project: project, source: :chat) }
it { expect_disallowed(:read_web_ide_terminal, :update_web_ide_terminal, :create_build_service_proxy) }
end
context 'when build has no runner terminal' do
before do
allow(build).to receive(:has_terminal?).and_return(false)
end
context 'when admin mode enabled', :enable_admin_mode do
it { expect_allowed(:read_web_ide_terminal, :update_web_ide_terminal) }
it { expect_disallowed(:create_build_terminal, :create_build_service_proxy) }
end
context 'when admin mode disabled' do
it { expect_disallowed(:read_web_ide_terminal, :update_web_ide_terminal) }
it { expect_disallowed(:create_build_terminal, :create_build_service_proxy) }
end
end
context 'feature flag "build_service_proxy" is disabled' do
before do
stub_feature_flags(build_service_proxy: false)
end
it { expect_disallowed(:create_build_service_proxy) }
end
end
shared_examples 'allowed build owner access' do
it { expect_disallowed(*build_permissions) }
context 'when user is the owner of the job' do
let(:build) { create(:ci_build, pipeline: pipeline, user: current_user) }
it { expect_allowed(*build_permissions) }
end
end
shared_examples 'forbidden access' do
it { expect_disallowed(*build_permissions) }
context 'when user is the owner of the job' do
let(:build) { create(:ci_build, pipeline: pipeline, user: current_user) }
it { expect_disallowed(*build_permissions) }
end
end
context 'with owner' do
let(:current_user) { owner }
it_behaves_like 'allowed build owner access'
end
context 'with maintainer' do
let(:current_user) { maintainer }
it_behaves_like 'allowed build owner access'
end
context 'with developer' do
let(:current_user) { developer }
it_behaves_like 'forbidden access'
end
context 'with reporter' do
let(:current_user) { reporter }
it_behaves_like 'forbidden access'
end
context 'with guest' do
let(:current_user) { guest }
it_behaves_like 'forbidden access'
end
context 'with non member' do
let(:current_user) { create(:user) }
it_behaves_like 'forbidden access'
end
end
end
end
......@@ -942,76 +942,6 @@ describe ProjectPolicy do
end
end
describe 'create_web_ide_terminal' do
before do
stub_licensed_features(web_ide_terminal: true)
end
context 'without ide terminal feature available' do
before do
stub_licensed_features(web_ide_terminal: false)
end
let(:current_user) { admin }
it { is_expected.to be_disallowed(:create_web_ide_terminal) }
end
context 'with admin' do
let(:current_user) { admin }
context 'when admin mode enabled', :enable_admin_mode do
it { is_expected.to be_allowed(:create_web_ide_terminal) }
end
context 'when admin mode disabled' do
it { is_expected.to be_disallowed(:create_web_ide_terminal) }
end
end
context 'with owner' do
let(:current_user) { owner }
it { is_expected.to be_allowed(:create_web_ide_terminal) }
end
context 'with maintainer' do
let(:current_user) { maintainer }
it { is_expected.to be_allowed(:create_web_ide_terminal) }
end
context 'with developer' do
let(:current_user) { developer }
it { is_expected.to be_disallowed(:create_web_ide_terminal) }
end
context 'with reporter' do
let(:current_user) { reporter }
it { is_expected.to be_disallowed(:create_web_ide_terminal) }
end
context 'with guest' do
let(:current_user) { guest }
it { is_expected.to be_disallowed(:create_web_ide_terminal) }
end
context 'with non member' do
let(:current_user) { create(:user) }
it { is_expected.to be_disallowed(:create_web_ide_terminal) }
end
context 'with anonymous' do
let(:current_user) { nil }
it { is_expected.to be_disallowed(:create_web_ide_terminal) }
end
end
describe 'publish_status_page' do
let(:anonymous) { nil }
let(:feature) { :status_page }
......
# frozen_string_literal: true
require 'spec_helper'
describe API::Runner, :clean_gitlab_redis_shared_state do
include StubGitlabCalls
include RedisHelpers
let_it_be(:project) { create(:project, :repository) }
describe '/api/v4/jobs' do
let(:runner) { create(:ci_runner, :project, projects: [project]) }
describe 'POST /api/v4/jobs/request' do
context 'for web-ide job' do
let(:user) { create(:user) }
let(:service) { Ci::CreateWebIdeTerminalService.new(project, user, ref: 'master').execute }
let(:pipeline) { service[:pipeline] }
let(:build) { pipeline.builds.first }
before do
stub_licensed_features(web_ide_terminal: true)
stub_webide_config_file(config_content)
project.add_maintainer(user)
pipeline
end
let(:config_content) do
'terminal: { image: ruby, services: [mysql], before_script: [ls], tags: [tag-1], variables: { KEY: value } }'
end
context 'when runner has matching tag' do
before do
runner.update!(tag_list: ['tag-1'])
end
it 'successfully picks job' do
request_job
build.reload
expect(build).to be_running
expect(build.runner).to eq(runner)
expect(response).to have_gitlab_http_status(:created)
expect(json_response).to include(
"id" => build.id,
"variables" => include("key" => 'KEY', "value" => 'value', "public" => true, "masked" => false),
"image" => a_hash_including("name" => 'ruby'),
"services" => all(a_hash_including("name" => 'mysql')),
"job_info" => a_hash_including("name" => 'terminal', "stage" => 'terminal'))
end
end
context 'when runner does not have matching tags' do
it 'does not pick a job' do
request_job
build.reload
expect(build).to be_pending
expect(response).to have_gitlab_http_status(:no_content)
end
end
end
def request_job(token = runner.token, **params)
post api('/jobs/request'), params: params.merge(token: token)
end
end
end
end
......@@ -2,12 +2,6 @@
module EE
module StubGitlabCalls
def stub_webide_config_file(content, sha: anything)
allow_any_instance_of(Repository)
.to receive(:blob_data_at).with(sha, '.gitlab/.gitlab-webide.yml')
.and_return(content)
end
def stub_registry_replication_config(registry_settings)
allow(::Gitlab.config.geo.registry_replication).to receive_messages(registry_settings)
allow(Auth::ContainerRegistryAuthenticationService)
......
......@@ -1225,4 +1225,198 @@ describe Projects::JobsController, :clean_gitlab_redis_shared_state do
get :terminal_websocket_authorize, params: params.merge(extra_params)
end
end
describe 'GET #proxy_websocket_authorize' do
let_it_be(:owner) { create(:owner) }
let_it_be(:admin) { create(:admin) }
let_it_be(:maintainer) { create(:user) }
let_it_be(:developer) { create(:user) }
let_it_be(:reporter) { create(:user) }
let_it_be(:guest) { create(:user) }
let_it_be(:project) { create(:project, :private, :repository, namespace: owner.namespace) }
let(:user) { maintainer }
let(:pipeline) { create(:ci_pipeline, project: project, source: :webide, config_source: :webide_source, user: user) }
let(:job) { create(:ci_build, :running, :with_runner_session, pipeline: pipeline, user: user) }
let(:extra_params) { { id: job.id } }
let(:path) { :proxy_websocket_authorize }
let(:render_method) { :channel_websocket }
let(:expected_data) do
{
'Channel' => {
'Subprotocols' => ["terminal.gitlab.com"],
'Url' => 'wss://localhost/proxy/build/default_port/',
'Header' => {
'Authorization' => [nil]
},
'MaxSessionTime' => nil,
'CAPem' => nil
}
}.to_json
end
before do
stub_feature_flags(build_service_proxy: true)
allow(job).to receive(:has_terminal?).and_return(true)
project.add_maintainer(maintainer)
project.add_developer(developer)
project.add_reporter(reporter)
project.add_guest(guest)
sign_in(user)
end
context 'access rights' do
before do
allow(Gitlab::Workhorse).to receive(:verify_api_request!).and_return(nil)
make_request
end
context 'with admin' do
let(:user) { admin }
context 'when admin mode is enabled', :enable_admin_mode do
it 'returns 200' do
expect(response).to have_gitlab_http_status(:ok)
end
end
context 'when admin mode is disabled' do
it 'returns 404' do
expect(response).to have_gitlab_http_status(:not_found)
end
end
end
context 'with owner' do
let(:user) { owner }
it 'returns 200' do
expect(response).to have_gitlab_http_status(:ok)
end
end
context 'with maintainer' do
let(:user) { maintainer }
it 'returns 200' do
expect(response).to have_gitlab_http_status(:ok)
end
end
context 'with developer' do
let(:user) { developer }
it 'returns 404' do
expect(response).to have_gitlab_http_status(:not_found)
end
end
context 'with reporter' do
let(:user) { reporter }
it 'returns 404' do
expect(response).to have_gitlab_http_status(:not_found)
end
end
context 'with guest' do
let(:user) { guest }
it 'returns 404' do
expect(response).to have_gitlab_http_status(:not_found)
end
end
context 'with non member' do
let(:user) { create(:user) }
it 'returns 404' do
expect(response).to have_gitlab_http_status(:not_found)
end
end
end
context 'when pipeline is not from a webide source' do
context 'with admin' do
let(:user) { admin }
let(:pipeline) { create(:ci_pipeline, project: project, source: :chat, user: user) }
before do
allow(Gitlab::Workhorse).to receive(:verify_api_request!).and_return(nil)
make_request
end
it 'returns 404' do
expect(response).to have_gitlab_http_status(:not_found)
end
end
end
context 'when workhorse signature is valid' do
before do
allow(Gitlab::Workhorse).to receive(:verify_api_request!).and_return(nil)
end
context 'and the id is valid' do
it 'returns the proxy data for the service running in the job' do
make_request
expect(response).to have_gitlab_http_status(:ok)
expect(response.headers["Content-Type"]).to eq(Gitlab::Workhorse::INTERNAL_API_CONTENT_TYPE)
expect(response.body).to eq(expected_data)
end
end
context 'and the id is invalid' do
let(:extra_params) { { id: non_existing_record_id } }
it 'returns 404' do
make_request
expect(response).to have_gitlab_http_status(:not_found)
end
end
end
context 'with invalid workhorse signature' do
it 'aborts with an exception' do
allow(Gitlab::Workhorse).to receive(:verify_api_request!).and_raise(JWT::DecodeError)
expect { make_request }.to raise_error(JWT::DecodeError)
end
end
context 'when feature flag :build_service_proxy is disabled' do
let(:user) { admin }
it 'returns 404' do
allow(Gitlab::Workhorse).to receive(:verify_api_request!).and_return(nil)
stub_feature_flags(build_service_proxy: false)
make_request
expect(response).to have_gitlab_http_status(:not_found)
end
end
it 'converts the url scheme into wss' do
allow(Gitlab::Workhorse).to receive(:verify_api_request!).and_return(nil)
expect(job.runner_session_url).to start_with('https://')
expect(Gitlab::Workhorse).to receive(:channel_websocket).with(a_hash_including(url: "wss://localhost/proxy/build/default_port/"))
make_request
end
def make_request
params = {
namespace_id: project.namespace.to_param,
project_id: project
}
get path, params: params.merge(extra_params)
end
end
end
......@@ -15,8 +15,6 @@ describe Projects::WebIdeTerminalsController do
let(:user) { maintainer }
before do
stub_licensed_features(web_ide_terminal: true)
project.add_maintainer(maintainer)
project.add_developer(developer)
project.add_reporter(reporter)
......
......@@ -155,6 +155,11 @@ FactoryBot.define do
source_sha { merge_request.source_branch_sha }
target_sha { merge_request.target_branch_sha }
end
trait :webide do
source { :webide }
config_source { :webide_source }
end
end
end
end
......@@ -63,4 +63,64 @@ describe Ci::BuildRunnerSession, model: true do
end
end
end
describe '#service_specification' do
let(:service) { 'foo'}
let(:port) { 80 }
let(:path) { 'path' }
let(:subprotocols) { nil }
let(:specification) { subject.service_specification(service: service, port: port, path: path, subprotocols: subprotocols) }
it 'returns service proxy url' do
expect(specification[:url]).to eq "https://localhost/proxy/#{service}/#{port}/#{path}"
end
it 'returns default service proxy websocket subprotocol' do
expect(specification[:subprotocols]).to eq %w[terminal.gitlab.com]
end
it 'returns empty hash if no url' do
subject.url = ''
expect(specification).to be_empty
end
context 'when port is not present' do
let(:port) { nil }
it 'uses the default port name' do
expect(specification[:url]).to eq "https://localhost/proxy/#{service}/default_port/#{path}"
end
end
context 'when the service is not present' do
let(:service) { '' }
it 'uses the service name "build" as default' do
expect(specification[:url]).to eq "https://localhost/proxy/build/#{port}/#{path}"
end
end
context 'when url is present' do
it 'returns ca_pem nil if empty certificate' do
subject.certificate = ''
expect(specification[:ca_pem]).to be_nil
end
it 'adds Authorization header if authorization is present' do
subject.authorization = 'foobar'
expect(specification[:headers]).to include(Authorization: ['foobar'])
end
end
context 'when subprotocol is present' do
let(:subprotocols) { 'foobar' }
it 'returns the new subprotocol' do
expect(specification[:subprotocols]).to eq [subprotocols]
end
end
end
end
......@@ -249,4 +249,129 @@ describe Ci::BuildPolicy do
end
end
end
describe 'manage a web ide terminal' do
let(:build_permissions) { %i[read_web_ide_terminal create_build_terminal update_web_ide_terminal create_build_service_proxy] }
let_it_be(:maintainer) { create(:user) }
let(:owner) { create(:owner) }
let(:admin) { create(:admin) }
let(:maintainer) { create(:user) }
let(:developer) { create(:user) }
let(:reporter) { create(:user) }
let(:guest) { create(:user) }
let(:project) { create(:project, :public, namespace: owner.namespace) }
let(:pipeline) { create(:ci_empty_pipeline, project: project, source: :webide) }
let(:build) { create(:ci_build, pipeline: pipeline) }
before do
allow(build).to receive(:has_terminal?).and_return(true)
project.add_maintainer(maintainer)
project.add_developer(developer)
project.add_reporter(reporter)
project.add_guest(guest)
end
subject { described_class.new(current_user, build) }
context 'when create_web_ide_terminal access enabled' do
context 'with admin' do
let(:current_user) { admin }
context 'when admin mode enabled', :enable_admin_mode do
it { expect_allowed(*build_permissions) }
end
context 'when admin mode disabled' do
it { expect_disallowed(*build_permissions) }
end
context 'when build is not from a webide pipeline' do
let(:pipeline) { create(:ci_empty_pipeline, project: project, source: :chat) }
it { expect_disallowed(:read_web_ide_terminal, :update_web_ide_terminal, :create_build_service_proxy) }
end
context 'when build has no runner terminal' do
before do
allow(build).to receive(:has_terminal?).and_return(false)
end
context 'when admin mode enabled', :enable_admin_mode do
it { expect_allowed(:read_web_ide_terminal, :update_web_ide_terminal) }
it { expect_disallowed(:create_build_terminal, :create_build_service_proxy) }
end
context 'when admin mode disabled' do
it { expect_disallowed(:read_web_ide_terminal, :update_web_ide_terminal) }
it { expect_disallowed(:create_build_terminal, :create_build_service_proxy) }
end
end
context 'feature flag "build_service_proxy" is disabled' do
before do
stub_feature_flags(build_service_proxy: false)
end
it { expect_disallowed(:create_build_service_proxy) }
end
end
shared_examples 'allowed build owner access' do
it { expect_disallowed(*build_permissions) }
context 'when user is the owner of the job' do
let(:build) { create(:ci_build, pipeline: pipeline, user: current_user) }
it { expect_allowed(*build_permissions) }
end
end
shared_examples 'forbidden access' do
it { expect_disallowed(*build_permissions) }
context 'when user is the owner of the job' do
let(:build) { create(:ci_build, pipeline: pipeline, user: current_user) }
it { expect_disallowed(*build_permissions) }
end
end
context 'with owner' do
let(:current_user) { owner }
it_behaves_like 'allowed build owner access'
end
context 'with maintainer' do
let(:current_user) { maintainer }
it_behaves_like 'allowed build owner access'
end
context 'with developer' do
let(:current_user) { developer }
it_behaves_like 'forbidden access'
end
context 'with reporter' do
let(:current_user) { reporter }
it_behaves_like 'forbidden access'
end
context 'with guest' do
let(:current_user) { guest }
it_behaves_like 'forbidden access'
end
context 'with non member' do
let(:current_user) { create(:user) }
it_behaves_like 'forbidden access'
end
end
end
end
......@@ -742,4 +742,62 @@ describe ProjectPolicy do
it { is_expected.to be_disallowed(:destroy_package) }
end
end
describe 'create_web_ide_terminal' do
subject { described_class.new(current_user, project) }
context 'with admin' do
let(:current_user) { admin }
context 'when admin mode enabled', :enable_admin_mode do
it { is_expected.to be_allowed(:create_web_ide_terminal) }
end
context 'when admin mode disabled' do
it { is_expected.to be_disallowed(:create_web_ide_terminal) }
end
end
context 'with owner' do
let(:current_user) { owner }
it { is_expected.to be_allowed(:create_web_ide_terminal) }
end
context 'with maintainer' do
let(:current_user) { maintainer }
it { is_expected.to be_allowed(:create_web_ide_terminal) }
end
context 'with developer' do
let(:current_user) { developer }
it { is_expected.to be_disallowed(:create_web_ide_terminal) }
end
context 'with reporter' do
let(:current_user) { reporter }
it { is_expected.to be_disallowed(:create_web_ide_terminal) }
end
context 'with guest' do
let(:current_user) { guest }
it { is_expected.to be_disallowed(:create_web_ide_terminal) }
end
context 'with non member' do
let(:current_user) { create(:user) }
it { is_expected.to be_disallowed(:create_web_ide_terminal) }
end
context 'with anonymous' do
let(:current_user) { nil }
it { is_expected.to be_disallowed(:create_web_ide_terminal) }
end
end
end
......@@ -1055,6 +1055,65 @@ describe API::Runner, :clean_gitlab_redis_shared_state do
post api('/jobs/request'), params: new_params, headers: { 'User-Agent' => user_agent }
end
end
context 'for web-ide job' do
let_it_be(:user) { create(:user) }
let_it_be(:project) { create(:project, :repository) }
let(:runner) { create(:ci_runner, :project, projects: [project]) }
let(:service) { Ci::CreateWebIdeTerminalService.new(project, user, ref: 'master').execute }
let(:pipeline) { service[:pipeline] }
let(:build) { pipeline.builds.first }
let(:job) { {} }
let(:config_content) do
'terminal: { image: ruby, services: [mysql], before_script: [ls], tags: [tag-1], variables: { KEY: value } }'
end
before do
stub_webide_config_file(config_content)
project.add_maintainer(user)
pipeline
end
context 'when runner has matching tag' do
before do
runner.update!(tag_list: ['tag-1'])
end
it 'successfully picks job' do
request_job
build.reload
expect(build).to be_running
expect(build.runner).to eq(runner)
expect(response).to have_gitlab_http_status(:created)
expect(json_response).to include(
"id" => build.id,
"variables" => include("key" => 'KEY', "value" => 'value', "public" => true, "masked" => false),
"image" => a_hash_including("name" => 'ruby'),
"services" => all(a_hash_including("name" => 'mysql')),
"job_info" => a_hash_including("name" => 'terminal', "stage" => 'terminal'))
end
end
context 'when runner does not have matching tags' do
it 'does not pick a job' do
request_job
build.reload
expect(build).to be_pending
expect(response).to have_gitlab_http_status(:no_content)
end
end
def request_job(token = runner.token, **params)
post api('/jobs/request'), params: params.merge(token: token)
end
end
end
describe 'PUT /api/v4/jobs/:id' do
......
......@@ -15,14 +15,7 @@ describe WebIdeTerminalEntity do
it { is_expected.to have_key(:retry_path) }
it { is_expected.to have_key(:terminal_path) }
it { is_expected.to have_key(:services) }
context 'when feature flag build_service_proxy is enabled' do
before do
stub_feature_flags(build_service_proxy: true)
end
it { is_expected.to have_key(:proxy_websocket_path) }
end
context 'when feature flag build_service_proxy is disabled' do
before do
......
......@@ -7,10 +7,6 @@ describe Ci::CreateWebIdeTerminalService do
let_it_be(:user) { create(:user) }
let(:ref) { 'master' }
before do
stub_licensed_features(web_ide_terminal: true)
end
describe '#execute' do
subject { described_class.new(project, user, ref: ref).execute }
......@@ -101,7 +97,7 @@ describe Ci::CreateWebIdeTerminalService do
end
context 'when terminal is already running' do
let!(:webide_pipeline) { create(:ee_ci_pipeline, :webide, :running, project: project, user: user) }
let!(:webide_pipeline) { create(:ci_pipeline, :webide, :running, project: project, user: user) }
it_behaves_like 'having an error', 'There is already a terminal running'
end
......
......@@ -141,6 +141,12 @@ module StubGitlabCalls
.to_return(status: 200, body: "", headers: {})
end
def stub_webide_config_file(content, sha: anything)
allow_any_instance_of(Repository)
.to receive(:blob_data_at).with(sha, '.gitlab/.gitlab-webide.yml')
.and_return(content)
end
def project_hash_array
f = File.read(Rails.root.join('spec/support/gitlab_stubs/projects.json'))
Gitlab::Json.parse(f)
......
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