diff --git a/app/controllers/concerns/kas_cookie.rb b/app/controllers/concerns/kas_cookie.rb new file mode 100644 index 0000000000000000000000000000000000000000..ef58ab1972be16bdd3f56804fcabf6fa35f57d04 --- /dev/null +++ b/app/controllers/concerns/kas_cookie.rb @@ -0,0 +1,16 @@ +# frozen_string_literal: true + +module KasCookie + extend ActiveSupport::Concern + + def set_kas_cookie + return unless ::Gitlab::Kas::UserAccess.enabled? + + public_session_id = Gitlab::Session.current&.id&.public_id + return unless public_session_id + + cookie_data = ::Gitlab::Kas::UserAccess.cookie_data(public_session_id) + + cookies[::Gitlab::Kas::COOKIE_KEY] = cookie_data + end +end diff --git a/app/controllers/projects/cluster_agents_controller.rb b/app/controllers/projects/cluster_agents_controller.rb index 3f759e5c18c4460ed17b357918b2bb1d3e1c41a3..e0c9763abb69958d32e15773f74e7cf455f45b28 100644 --- a/app/controllers/projects/cluster_agents_controller.rb +++ b/app/controllers/projects/cluster_agents_controller.rb @@ -1,7 +1,10 @@ # frozen_string_literal: true class Projects::ClusterAgentsController < Projects::ApplicationController + include KasCookie + before_action :authorize_can_read_cluster_agent! + before_action :set_kas_cookie, only: [:show], if: -> { current_user } feature_category :kubernetes_management urgency :low diff --git a/app/policies/clusters/instance_policy.rb b/app/policies/clusters/instance_policy.rb index 3c5ca4bf4e19823d833be61c3067921e0473995b..2781e943bae0b8259792aedf0192746d5d84493f 100644 --- a/app/policies/clusters/instance_policy.rb +++ b/app/policies/clusters/instance_policy.rb @@ -9,6 +9,7 @@ class InstancePolicy < BasePolicy enable :update_cluster enable :admin_cluster enable :read_prometheus + enable :use_k8s_proxies end end end diff --git a/app/policies/group_policy.rb b/app/policies/group_policy.rb index ba6e760fd2be09259a5195c9b4e680f188329500..9f06fe4db538ae9036974cf2a80d579a20b03cda 100644 --- a/app/policies/group_policy.rb +++ b/app/policies/group_policy.rb @@ -169,6 +169,7 @@ class GroupPolicy < Namespaces::GroupProjectNamespaceSharedPolicy enable :admin_crm_contact enable :read_cluster enable :read_group_all_available_runners + enable :use_k8s_proxies end rule { reporter }.policy do diff --git a/app/policies/project_policy.rb b/app/policies/project_policy.rb index 2bdd8b23c62830496e8b692ed61699a44cd2169c..091ee08e9617bdf414c7652329709984966887cd 100644 --- a/app/policies/project_policy.rb +++ b/app/policies/project_policy.rb @@ -455,6 +455,7 @@ class ProjectPolicy < BasePolicy enable :create_deployment enable :update_deployment enable :read_cluster + enable :use_k8s_proxies enable :create_release enable :update_release enable :destroy_release diff --git a/app/services/clusters/agents/authorize_proxy_user_service.rb b/app/services/clusters/agents/authorize_proxy_user_service.rb new file mode 100644 index 0000000000000000000000000000000000000000..ec6645b2db4b527d3ad20a822face378d2437986 --- /dev/null +++ b/app/services/clusters/agents/authorize_proxy_user_service.rb @@ -0,0 +1,99 @@ +# frozen_string_literal: true + +module Clusters + module Agents + class AuthorizeProxyUserService < ::BaseService + include ::Gitlab::Utils::StrongMemoize + + def initialize(current_user, agent) + @current_user = current_user + @agent = agent + end + + def execute + return forbidden unless user_access_config.present? + + access_as = user_access_config[:access_as] + return forbidden unless access_as.present? + return forbidden if access_as.size != 1 + + if authorizations = handle_access(access_as, user_access_config) + return success(payload: authorizations) + end + + forbidden + end + + private + + attr_reader :current_user, :agent + + # Override in EE + def handle_access(access_as, user_access) + access_as_agent(user_access) if access_as.key?(:agent) + end + + def response_base + { + agent: { + id: agent.id, + config_project: { id: agent.project.id } + }, + user: { + id: current_user.id, + username: current_user.username + } + } + end + + def access_as_agent(user_access) + projects = authorized_projects(user_access) + groups = authorized_groups(user_access) + return unless projects.size + groups.size > 0 + + response_base.merge(access_as: { agent: {} }) + end + + def authorized_projects(user_access) + strong_memoize_with(:authorized_projects, user_access) do + user_access.fetch(:projects, []) + .first(::Clusters::Agents::RefreshAuthorizationService::AUTHORIZED_ENTITY_LIMIT) + .map { |project| ::Project.find_by_full_path(project[:id]) } + .select { |project| current_user.can?(:use_k8s_proxies, project) } + end + end + + def authorized_groups(user_access) + strong_memoize_with(:authorized_groups, user_access) do + user_access.fetch(:groups, []) + .first(::Clusters::Agents::RefreshAuthorizationService::AUTHORIZED_ENTITY_LIMIT) + .map { |group| ::Group.find_by_full_path(group[:id]) } + .select { |group| current_user.can?(:use_k8s_proxies, group) } + end + end + + def user_access_config + # TODO: Read the configuration from the database once it has been + # indexed. See https://gitlab.com/gitlab-org/gitlab/-/issues/389430 + branch = agent.project.default_branch_or_main + path = ".gitlab/agents/#{agent.name}/config.yaml" + config_yaml = agent.project.repository + &.blob_at_branch(branch, path) + &.data + return unless config_yaml.present? + + config = YAML.safe_load(config_yaml, aliases: true, symbolize_names: true) + config[:user_access] + end + strong_memoize_attr :user_access_config + + delegate :success, to: ServiceResponse, private: true + + def forbidden + ServiceResponse.error(reason: :forbidden, message: '403 Forbidden') + end + end + end +end + +Clusters::Agents::AuthorizeProxyUserService.prepend_mod diff --git a/config/feature_flags/development/kas_user_access.yml b/config/feature_flags/development/kas_user_access.yml new file mode 100644 index 0000000000000000000000000000000000000000..efcf0c15227b505fd8efd83590396b026a911a40 --- /dev/null +++ b/config/feature_flags/development/kas_user_access.yml @@ -0,0 +1,8 @@ +--- +name: kas_user_access +introduced_by_url: 'https://gitlab.com/gitlab-org/gitlab/-/merge_requests/104504' +rollout_issue_url: 'https://gitlab.com/gitlab-org/gitlab/-/issues/391201' +milestone: '15.10' +type: development +group: group::configure +default_enabled: false diff --git a/config/feature_flags/development/kas_user_access_project.yml b/config/feature_flags/development/kas_user_access_project.yml new file mode 100644 index 0000000000000000000000000000000000000000..34a4ac1271ac3d7c7fc360e6ca1d32d72f445a48 --- /dev/null +++ b/config/feature_flags/development/kas_user_access_project.yml @@ -0,0 +1,8 @@ +--- +name: kas_user_access_project +introduced_by_url: 'https://gitlab.com/gitlab-org/gitlab/-/merge_requests/104504' +rollout_issue_url: 'https://gitlab.com/gitlab-org/gitlab/-/issues/391211' +milestone: '15.10' +type: development +group: group::configure +default_enabled: false diff --git a/ee/app/services/ee/clusters/agents/authorize_proxy_user_service.rb b/ee/app/services/ee/clusters/agents/authorize_proxy_user_service.rb new file mode 100644 index 0000000000000000000000000000000000000000..a9bcd0fe919896368ef9983cf88d45972151d538 --- /dev/null +++ b/ee/app/services/ee/clusters/agents/authorize_proxy_user_service.rb @@ -0,0 +1,47 @@ +# frozen_string_literal: true + +module EE + module Clusters + module Agents + module AuthorizeProxyUserService + extend ::Gitlab::Utils::Override + + override :handle_access + def handle_access(access_as, user_access) + super || (access_as.key?(:user) && access_as_user(user_access)) + end + + def access_as_user(user_access) + projects = authorized_projects(user_access) + groups = authorized_groups(user_access) + return unless projects.size + groups.size > 0 + + response_base.merge( + access_as: { + user: { + # FIXME: N+1 queries on projects and groups + # see https://gitlab.com/gitlab-org/gitlab/-/issues/393336 + projects: projects.map { |p| { id: p.id, roles: project_roles(p) } }, + groups: groups.map { |g| { id: g.id, roles: group_roles(g) } } + } + } + ) + end + + def project_roles(project) + user_access_level = current_user.max_member_access_for_project(project.id) + ::Gitlab::Access.sym_options_with_owner + .select { |_role, role_access_level| role_access_level <= user_access_level } + .map(&:first) + end + + def group_roles(group) + user_access_level = current_user.max_member_access_for_group(group.id) + ::Gitlab::Access.sym_options_with_owner + .select { |_role, role_access_level| role_access_level <= user_access_level } + .map(&:first) + end + end + end + end +end diff --git a/ee/spec/services/ee/clusters/agents/authorize_proxy_user_service_spec.rb b/ee/spec/services/ee/clusters/agents/authorize_proxy_user_service_spec.rb new file mode 100644 index 0000000000000000000000000000000000000000..491ce56bba35b2aa7e69939fe79d6ecf549663ea --- /dev/null +++ b/ee/spec/services/ee/clusters/agents/authorize_proxy_user_service_spec.rb @@ -0,0 +1,51 @@ +# frozen_string_literal: true + +require 'spec_helper' + +RSpec.describe Clusters::Agents::AuthorizeProxyUserService, feature_category: :kubernetes_management do + subject(:service_response) { service.execute } + + let(:service) { described_class.new(user, agent) } + let(:user) { create(:user) } + + let_it_be(:project) { create(:project) } + let_it_be(:group) { create(:group) } + let_it_be(:user_access_config) do + { + 'user_access' => { + 'access_as' => { 'user' => {} }, + 'projects' => [{ 'id' => project.full_path }], + 'groups' => [{ 'id' => group.full_path }] + } + } + end + + let_it_be(:configuration_project) do + create( + :project, :custom_repo, + files: { + ".gitlab/agents/the-agent/config.yaml" => user_access_config.to_yaml + } + ) + end + + let_it_be(:agent) { create(:cluster_agent, name: 'the-agent', project: configuration_project) } + + it 'returns forbidden when user has no access to any project', :aggregate_failures do + expect(service_response).to be_error + expect(service_response.reason).to eq :forbidden + end + + it "returns the user's authorizations when they have access", :aggregate_failures do + project.add_member(user, :developer) + group.add_member(user, :maintainer) + + expect(service_response).to be_success + expect(service_response.payload[:access_as]).to eq({ + user: { + projects: [{ id: project.id, roles: %i[guest reporter developer] }], + groups: [{ id: group.id, roles: %i[guest reporter developer maintainer] }] + } + }) + end +end diff --git a/lib/api/internal/kubernetes.rb b/lib/api/internal/kubernetes.rb index 5f12275b7a06deae965440276a26a052e023fade..bf9612db6bf53734ed078bb6b718a707d8cdd6b5 100644 --- a/lib/api/internal/kubernetes.rb +++ b/lib/api/internal/kubernetes.rb @@ -47,7 +47,7 @@ def gitaly_repository(project) end def check_feature_enabled - not_found! unless Feature.enabled?(:kubernetes_agent_internal_api, type: :ops) + not_found!('Internal API not found') unless Feature.enabled?(:kubernetes_agent_internal_api, type: :ops) end def check_agent_token @@ -135,6 +135,49 @@ def increment_count_events end end + namespace 'kubernetes/authorize_proxy_user' do + desc 'Authorize a proxy user request' + params do + requires :agent_id, type: Integer, desc: 'ID of the agent accessed' + requires :access_type, type: String, values: ['session_cookie'], desc: 'The type of the access key being verified.' + requires :access_key, type: String, desc: 'The authentication secret for the given access type.' + given access_type: ->(val) { val == 'session_cookie' } do + requires :csrf_token, type: String, allow_blank: false, desc: 'CSRF token that must be checked when access_type is "session_cookie", to ensure the request originates from a GitLab browsing session.' + end + end + post '/', feature_category: :kubernetes_management do + # Load session + public_session_id_string = + begin + Gitlab::Kas::UserAccess.decrypt_public_session_id(params[:access_key]) + rescue StandardError + bad_request!('Invalid access_key') + end + + session_id = Rack::Session::SessionId.new(public_session_id_string) + session = ActiveSession.sessions_from_ids([session_id.private_id]).first + unauthorized!('Invalid session') unless session + + # CSRF check + unless ::Gitlab::Kas::UserAccess.valid_authenticity_token?(session.symbolize_keys, params[:csrf_token]) + unauthorized!('CSRF token does not match') + end + + # Load user + user = Warden::SessionSerializer.new('rack.session' => session).fetch(:user) + unauthorized!('Invalid user in session') unless user + + # Load agent + agent = ::Clusters::Agent.find(params[:agent_id]) + unauthorized!('Feature disabled for agent') unless ::Gitlab::Kas::UserAccess.enabled_for?(agent) + + service_response = ::Clusters::Agents::AuthorizeProxyUserService.new(user, agent).execute + render_api_error!(service_response[:message], service_response[:reason]) unless service_response.success? + + service_response.payload + end + end + namespace 'kubernetes/usage_metrics' do desc 'POST usage metrics' do detail 'Updates usage metrics for agent' diff --git a/lib/gitlab/content_security_policy/config_loader.rb b/lib/gitlab/content_security_policy/config_loader.rb index ceca206b0840638f67c7a1f303556b321c7ac543..477877e6a7ce2d921edb0a54f26bf4e9d8d0389d 100644 --- a/lib/gitlab/content_security_policy/config_loader.rb +++ b/lib/gitlab/content_security_policy/config_loader.rb @@ -50,6 +50,7 @@ def self.default_directives allow_sentry(directives) if Gitlab::CurrentSettings.try(:sentry_enabled) && Gitlab::CurrentSettings.try(:sentry_clientside_dsn) allow_framed_gitlab_paths(directives) allow_customersdot(directives) if ENV['CUSTOMER_PORTAL_URL'].present? + allow_kas(directives) allow_review_apps(directives) if ENV['REVIEW_APPS_ENABLED'] # The follow section contains workarounds to patch Safari's lack of support for CSP Level 3 @@ -147,6 +148,17 @@ def self.allow_customersdot(directives) append_to_directive(directives, 'frame_src', customersdot_host) end + def self.allow_kas(directives) + return unless ::Gitlab::Kas::UserAccess.enabled? + + kas_url = ::Gitlab::Kas.tunnel_url + return if URI(kas_url).host == ::Gitlab.config.gitlab.host # already allowed, no need for exception + + kas_url += '/' unless kas_url.end_with?('/') + + append_to_directive(directives, 'connect_src', kas_url) + end + def self.allow_legacy_sentry(directives) # Support for Sentry setup via configuration files will be removed in 16.0 # in favor of Gitlab::CurrentSettings. diff --git a/lib/gitlab/kas/user_access.rb b/lib/gitlab/kas/user_access.rb new file mode 100644 index 0000000000000000000000000000000000000000..65ae399d82650e0d30cd8d123d5a0827c5fb04b6 --- /dev/null +++ b/lib/gitlab/kas/user_access.rb @@ -0,0 +1,73 @@ +# frozen_string_literal: true + +module Gitlab + module Kas + # The name of the cookie that will be used for the KAS cookie + COOKIE_KEY = '_gitlab_kas' + DEFAULT_ENCRYPTED_COOKIE_CIPHER = 'aes-256-gcm' + + class UserAccess + class << self + def enabled? + ::Gitlab::Kas.enabled? && ::Feature.enabled?(:kas_user_access) + end + + def enabled_for?(agent) + enabled? && ::Feature.enabled?(:kas_user_access_project, agent.project) + end + + def encrypt_public_session_id(data) + encryptor.encrypt_and_sign(data.to_json, purpose: public_session_id_purpose) + end + + def decrypt_public_session_id(data) + decrypted = encryptor.decrypt_and_verify(data, purpose: public_session_id_purpose) + ::Gitlab::Json.parse(decrypted) + end + + def valid_authenticity_token?(session, masked_authenticity_token) + # rubocop:disable GitlabSecurity/PublicSend + ActionController::Base.new.send(:valid_authenticity_token?, session, masked_authenticity_token) + # rubocop:enable GitlabSecurity/PublicSend + end + + def cookie_data(public_session_id) + uri = URI(::Gitlab::Kas.tunnel_url) + + cookie = { + value: encrypt_public_session_id(public_session_id), + expires: 1.day, + httponly: true, + path: uri.path.presence || '/', + secure: Gitlab.config.gitlab.https + } + # Only set domain attribute if KAS is on a subdomain. + # When on the same domain, we can omit the attribute. + gitlab_host = Gitlab.config.gitlab.host + cookie[:domain] = gitlab_host if uri.host.end_with?(".#{gitlab_host}") + + cookie + end + + private + + def encryptor + action_dispatch_config = Gitlab::Application.config.action_dispatch + serializer = ActiveSupport::MessageEncryptor::NullSerializer + key_generator = ::Gitlab::Application.key_generator + + cipher = action_dispatch_config.encrypted_cookie_cipher || DEFAULT_ENCRYPTED_COOKIE_CIPHER + salt = action_dispatch_config.authenticated_encrypted_cookie_salt + key_len = ActiveSupport::MessageEncryptor.key_len(cipher) + secret = key_generator.generate_key(salt, key_len) + + ActiveSupport::MessageEncryptor.new(secret, cipher: cipher, serializer: serializer) + end + + def public_session_id_purpose + "kas.user_public_session_id" + end + end + end + end +end diff --git a/spec/controllers/concerns/kas_cookie_spec.rb b/spec/controllers/concerns/kas_cookie_spec.rb new file mode 100644 index 0000000000000000000000000000000000000000..e2ca19457ffc5d894668e1252fd2bd9fe5a96207 --- /dev/null +++ b/spec/controllers/concerns/kas_cookie_spec.rb @@ -0,0 +1,55 @@ +# frozen_string_literal: true + +require 'spec_helper' + +RSpec.describe KasCookie, feature_category: :kubernetes_management do + describe '#set_kas_cookie' do + controller(ApplicationController) do + include KasCookie + + def index + set_kas_cookie + + render json: {}, status: :ok + end + end + + before do + allow(::Gitlab::Kas).to receive(:enabled?).and_return(true) + end + + subject(:kas_cookie) do + get :index + + request.env['action_dispatch.cookies'][Gitlab::Kas::COOKIE_KEY] + end + + context 'when user is signed out' do + it { is_expected.to be_blank } + end + + context 'when user is signed in' do + let_it_be(:user) { create(:user) } + + before do + sign_in(user) + end + + it 'sets the KAS cookie', :aggregate_failures do + allow(::Gitlab::Kas::UserAccess).to receive(:cookie_data).and_return('foobar') + + expect(kas_cookie).to be_present + expect(kas_cookie).to eq('foobar') + expect(::Gitlab::Kas::UserAccess).to have_received(:cookie_data) + end + + context 'when feature flag is disabled' do + before do + stub_feature_flags(kas_user_access: false) + end + + it { is_expected.to be_blank } + end + end + end +end diff --git a/spec/lib/gitlab/content_security_policy/config_loader_spec.rb b/spec/lib/gitlab/content_security_policy/config_loader_spec.rb index b40829d72a0db7101b994503f37dde4a062cca91..ffb651fe23c5b27b9c37b1d37464987d05e8b8e7 100644 --- a/spec/lib/gitlab/content_security_policy/config_loader_spec.rb +++ b/spec/lib/gitlab/content_security_policy/config_loader_spec.rb @@ -178,6 +178,53 @@ end end + context 'when KAS is configured' do + before do + stub_config_setting(host: 'gitlab.example.com') + allow(::Gitlab::Kas).to receive(:enabled?).and_return true + end + + context 'when user access feature flag is disabled' do + before do + stub_feature_flags(kas_user_access: false) + end + + it 'does not add KAS url to CSP' do + expect(directives['connect_src']).not_to eq("'self' ws://gitlab.example.com #{::Gitlab::Kas.tunnel_url}") + end + end + + context 'when user access feature flag is enabled' do + before do + stub_feature_flags(kas_user_access: true) + end + + context 'when KAS is on same domain as rails' do + let_it_be(:kas_tunnel_url) { "ws://gitlab.example.com/-/k8s-proxy/" } + + before do + allow(::Gitlab::Kas).to receive(:tunnel_url).and_return(kas_tunnel_url) + end + + it 'does not add KAS url to CSP' do + expect(directives['connect_src']).not_to eq("'self' ws://gitlab.example.com #{::Gitlab::Kas.tunnel_url}") + end + end + + context 'when KAS is on subdomain' do + let_it_be(:kas_tunnel_url) { "ws://kas.gitlab.example.com/k8s-proxy/" } + + before do + allow(::Gitlab::Kas).to receive(:tunnel_url).and_return(kas_tunnel_url) + end + + it 'does add KAS url to CSP' do + expect(directives['connect_src']).to eq("'self' ws://gitlab.example.com #{kas_tunnel_url}") + end + end + end + end + context 'when CUSTOMER_PORTAL_URL is set' do let(:customer_portal_url) { 'https://customers.example.com' } diff --git a/spec/lib/gitlab/kas/user_access_spec.rb b/spec/lib/gitlab/kas/user_access_spec.rb new file mode 100644 index 0000000000000000000000000000000000000000..8795ad565d0ab5836e3fb5c348f4f58c9e148103 --- /dev/null +++ b/spec/lib/gitlab/kas/user_access_spec.rb @@ -0,0 +1,96 @@ +# frozen_string_literal: true + +require 'spec_helper' + +RSpec.describe Gitlab::Kas::UserAccess, feature_category: :kubernetes_management do + describe '.enabled?' do + subject { described_class.enabled? } + + before do + allow(::Gitlab::Kas).to receive(:enabled?).and_return true + end + + it { is_expected.to be true } + + context 'when flag kas_user_access is disabled' do + before do + stub_feature_flags(kas_user_access: false) + end + + it { is_expected.to be false } + end + end + + describe '.enabled_for?' do + subject { described_class.enabled_for?(agent) } + + let(:agent) { build(:cluster_agent) } + + before do + allow(::Gitlab::Kas).to receive(:enabled?).and_return true + end + + it { is_expected.to be true } + + context 'when flag kas_user_access is disabled' do + before do + stub_feature_flags(kas_user_access: false) + end + + it { is_expected.to be false } + end + + context 'when flag kas_user_access_project is disabled' do + before do + stub_feature_flags(kas_user_access_project: false) + end + + it { is_expected.to be false } + end + end + + describe '.{encrypt,decrypt}_public_session_id' do + let(:data) { 'the data' } + let(:encrypted) { described_class.encrypt_public_session_id(data) } + let(:decrypted) { described_class.decrypt_public_session_id(encrypted) } + + it { expect(encrypted).not_to include data } + it { expect(decrypted).to eq data } + end + + describe '.cookie_data' do + subject(:cookie_data) { described_class.cookie_data(public_session_id) } + + let(:public_session_id) { 'the-public-session-id' } + let(:external_k8s_proxy_url) { 'https://example.com:1234' } + + before do + stub_config( + gitlab: { host: 'example.com', https: true }, + gitlab_kas: { external_k8s_proxy_url: external_k8s_proxy_url } + ) + end + + it 'is encrypted, secure, httponly', :aggregate_failures do + expect(cookie_data[:value]).not_to include public_session_id + expect(cookie_data).to include(httponly: true, secure: true, path: '/') + expect(cookie_data).not_to have_key(:domain) + end + + context 'when on non-root path' do + let(:external_k8s_proxy_url) { 'https://example.com/k8s-proxy' } + + it 'sets :path' do + expect(cookie_data).to include(httponly: true, secure: true, path: '/k8s-proxy') + end + end + + context 'when on subdomain' do + let(:external_k8s_proxy_url) { 'https://k8s-proxy.example.com' } + + it 'sets :domain' do + expect(cookie_data[:domain]).to eq "example.com" + end + end + end +end diff --git a/spec/requests/api/internal/kubernetes_spec.rb b/spec/requests/api/internal/kubernetes_spec.rb index be76e55269ab7096fca99b1bc9b6dcee066af17c..547b9071f94899535f05969cc1a346871a3f38ba 100644 --- a/spec/requests/api/internal/kubernetes_spec.rb +++ b/spec/requests/api/internal/kubernetes_spec.rb @@ -306,4 +306,151 @@ def send_request(headers: {}, params: {}) end end end + + describe 'POST /internal/kubernetes/authorize_proxy_user', :clean_gitlab_redis_sessions do + include SessionHelpers + + def send_request(headers: {}, params: {}) + post api('/internal/kubernetes/authorize_proxy_user'), params: params, headers: headers.reverse_merge(jwt_auth_headers) + end + + def stub_user_session(user, csrf_token) + stub_session( + { + 'warden.user.user.key' => [[user.id], user.authenticatable_salt], + '_csrf_token' => csrf_token + } + ) + end + + def stub_user_session_with_no_user_id(user, csrf_token) + stub_session( + { + 'warden.user.user.key' => [[nil], user.authenticatable_salt], + '_csrf_token' => csrf_token + } + ) + end + + def mask_token(encoded_token) + controller = ActionController::Base.new + raw_token = controller.send(:decode_csrf_token, encoded_token) + controller.send(:mask_token, raw_token) + end + + def new_token + ActionController::Base.new.send(:generate_csrf_token) + end + + let_it_be(:project) { create(:project) } + let_it_be(:group) { create(:group) } + let_it_be(:user_access_config) do + { + 'user_access' => { + 'access_as' => { 'agent' => {} }, + 'projects' => [{ 'id' => project.full_path }], + 'groups' => [{ 'id' => group.full_path }] + } + } + end + + let_it_be(:configuration_project) do + create( + :project, :custom_repo, + files: { + ".gitlab/agents/the-agent/config.yaml" => user_access_config.to_yaml + } + ) + end + + let_it_be(:agent) { create(:cluster_agent, name: 'the-agent', project: configuration_project) } + let_it_be(:another_agent) { create(:cluster_agent) } + + let(:user) { create(:user) } + + before do + allow(::Gitlab::Kas).to receive(:enabled?).and_return true + end + + it 'returns 400 when cookie is invalid' do + send_request(params: { agent_id: agent.id, access_type: 'session_cookie', access_key: '123', csrf_token: mask_token(new_token) }) + + expect(response).to have_gitlab_http_status(:bad_request) + end + + it 'returns 401 when session is not found' do + access_key = Gitlab::Kas::UserAccess.encrypt_public_session_id('abc') + send_request(params: { agent_id: agent.id, access_type: 'session_cookie', access_key: access_key, csrf_token: mask_token(new_token) }) + + expect(response).to have_gitlab_http_status(:unauthorized) + end + + it 'returns 401 when CSRF token does not match' do + public_id = stub_user_session(user, new_token) + access_key = Gitlab::Kas::UserAccess.encrypt_public_session_id(public_id) + send_request(params: { agent_id: agent.id, access_type: 'session_cookie', access_key: access_key, csrf_token: mask_token(new_token) }) + + expect(response).to have_gitlab_http_status(:unauthorized) + end + + it 'returns 404 for non-existent agent' do + token = new_token + public_id = stub_user_session(user, token) + access_key = Gitlab::Kas::UserAccess.encrypt_public_session_id(public_id) + send_request(params: { agent_id: non_existing_record_id, access_type: 'session_cookie', access_key: access_key, csrf_token: mask_token(token) }) + + expect(response).to have_gitlab_http_status(:not_found) + end + + it 'returns 403 when user has no access' do + token = new_token + public_id = stub_user_session(user, token) + access_key = Gitlab::Kas::UserAccess.encrypt_public_session_id(public_id) + send_request(params: { agent_id: agent.id, access_type: 'session_cookie', access_key: access_key, csrf_token: mask_token(token) }) + + expect(response).to have_gitlab_http_status(:forbidden) + end + + it 'returns 200 when user has access' do + project.add_member(user, :developer) + token = new_token + public_id = stub_user_session(user, token) + access_key = Gitlab::Kas::UserAccess.encrypt_public_session_id(public_id) + send_request(params: { agent_id: agent.id, access_type: 'session_cookie', access_key: access_key, csrf_token: mask_token(token) }) + + expect(response).to have_gitlab_http_status(:success) + end + + it 'returns 401 when user has valid KAS cookie and CSRF token but has no access to requested agent' do + project.add_member(user, :developer) + token = new_token + public_id = stub_user_session(user, token) + access_key = Gitlab::Kas::UserAccess.encrypt_public_session_id(public_id) + send_request(params: { agent_id: another_agent.id, access_type: 'session_cookie', access_key: access_key, csrf_token: mask_token(token) }) + + expect(response).to have_gitlab_http_status(:forbidden) + end + + it 'returns 401 when global flag is disabled' do + stub_feature_flags(kas_user_access: false) + + project.add_member(user, :developer) + token = new_token + public_id = stub_user_session(user, token) + access_key = Gitlab::Kas::UserAccess.encrypt_public_session_id(public_id) + send_request(params: { agent_id: agent.id, access_type: 'session_cookie', access_key: access_key, csrf_token: mask_token(token) }) + + expect(response).to have_gitlab_http_status(:unauthorized) + end + + it 'returns 401 when user id is not found in session' do + project.add_member(user, :developer) + token = new_token + public_id = stub_user_session_with_no_user_id(user, token) + access_key = Gitlab::Kas::UserAccess.encrypt_public_session_id(public_id) + send_request(params: { agent_id: agent.id, access_type: 'session_cookie', access_key: access_key, csrf_token: mask_token(token) }) + + expect(response).to have_gitlab_http_status(:unauthorized) + end + end end diff --git a/spec/services/clusters/agents/authorize_proxy_user_service_spec.rb b/spec/services/clusters/agents/authorize_proxy_user_service_spec.rb new file mode 100644 index 0000000000000000000000000000000000000000..c099d87f6eb629fae4cd66f858ac93e69d6406de --- /dev/null +++ b/spec/services/clusters/agents/authorize_proxy_user_service_spec.rb @@ -0,0 +1,68 @@ +# frozen_string_literal: true + +require 'spec_helper' + +RSpec.describe Clusters::Agents::AuthorizeProxyUserService, feature_category: :kubernetes_management do + subject(:service_response) { service.execute } + + let(:service) { described_class.new(user, agent) } + let(:user) { create(:user) } + + let_it_be(:project) { create(:project) } + let_it_be(:group) { create(:group) } + let_it_be(:user_access_config) do + { + 'user_access' => { + 'access_as' => { 'agent' => {} }, + 'projects' => [{ 'id' => project.full_path }], + 'groups' => [{ 'id' => group.full_path }] + } + } + end + + let_it_be(:configuration_project) do + create( + :project, :custom_repo, + files: { + ".gitlab/agents/the-agent/config.yaml" => user_access_config.to_yaml + } + ) + end + + let_it_be(:agent) { create(:cluster_agent, name: 'the-agent', project: configuration_project) } + + it 'returns forbidden when user has no access to any project', :aggregate_failures do + expect(service_response).to be_error + expect(service_response.reason).to eq :forbidden + end + + context 'when user is member of an authorized group' do + it 'authorizes developers', :aggregate_failures do + group.add_member(user, :developer) + expect(service_response).to be_success + expect(service_response.payload[:user]).to include(id: user.id, username: user.username) + expect(service_response.payload[:agent]).to include(id: agent.id, config_project: { id: agent.project.id }) + end + + it 'does not authorize reporters', :aggregate_failures do + group.add_member(user, :reporter) + expect(service_response).to be_error + expect(service_response.reason).to eq :forbidden + end + end + + context 'when user is member of an authorized project' do + it 'authorizes developers', :aggregate_failures do + project.add_member(user, :developer) + expect(service_response).to be_success + expect(service_response.payload[:user]).to include(id: user.id, username: user.username) + expect(service_response.payload[:agent]).to include(id: agent.id, config_project: { id: agent.project.id }) + end + + it 'does not authorize reporters', :aggregate_failures do + project.add_member(user, :reporter) + expect(service_response).to be_error + expect(service_response.reason).to eq :forbidden + end + end +end