diff --git a/ee/lib/api/remote_development/api.rb b/ee/lib/api/remote_development/api.rb index 09ee09c7a4d4ba5f0048c4b9393f0c3b6a412835..25530a8530dd1f58b0c0b1c2a7dee58a1daba52a 100644 --- a/ee/lib/api/remote_development/api.rb +++ b/ee/lib/api/remote_development/api.rb @@ -3,8 +3,9 @@ module API module RemoteDevelopment class API < ::API::Base - mount ::API::RemoteDevelopment::Internal::Agents::Agentw::ServerConfig mount ::API::RemoteDevelopment::Internal::Agents::Agentw::AgentInfo + mount ::API::RemoteDevelopment::Internal::Agents::Agentw::AuthorizeUserAccess + mount ::API::RemoteDevelopment::Internal::Agents::Agentw::ServerConfig end end end diff --git a/ee/lib/api/remote_development/internal/agents/agentw/authorize_user_access.rb b/ee/lib/api/remote_development/internal/agents/agentw/authorize_user_access.rb new file mode 100644 index 0000000000000000000000000000000000000000..f429b050b125cd6a7a2ac8ef7a7f41b7b0706255 --- /dev/null +++ b/ee/lib/api/remote_development/internal/agents/agentw/authorize_user_access.rb @@ -0,0 +1,47 @@ +# frozen_string_literal: true + +module API + module RemoteDevelopment + module Internal + module Agents + module Agentw + class AuthorizeUserAccess < ::API::Base + before do + authenticate_gitlab_kas_request! + end + + helpers ::API::Helpers::KasHelpers + + namespace "internal" do + namespace "agents" do + namespace "agentw" do + desc "authorize_user_access" do + detail "Returns whether the user is authorized to access the workspace." + end + params do + requires :workspace_host, type: String, desc: 'Host of the workspace being accessed' + requires :user_id, type: Integer, desc: 'User ID of the user accessing the workspace' + end + get "/authorize_user_access", feature_category: :workspaces, urgency: :low do + response = ::RemoteDevelopment::CommonService.execute( + domain_main_class: ::RemoteDevelopment::WorkspacesServerOperations::AuthorizeUserAccess::Main, + domain_main_class_args: { + workspace_host: params[:workspace_host], + user_id: params[:user_id] + } + ) + + # NOTE: There's currently no way an error can be returned other than an unexpected raised + # exception, so we assume success. + + response.payload + end + end + end + end + end + end + end + end + end +end diff --git a/ee/lib/gitlab/auth/remote_development/auth_finders.rb b/ee/lib/gitlab/auth/remote_development/auth_finders.rb index c9333a1f849b32d4d794e85a102f6df61ba26eb9..d2dee17052b6695db0bf2ae1ba850772d0aac788 100644 --- a/ee/lib/gitlab/auth/remote_development/auth_finders.rb +++ b/ee/lib/gitlab/auth/remote_development/auth_finders.rb @@ -9,7 +9,7 @@ module AuthFinders included do # @return [RemoteDevelopment::WorkspaceToken] def workspace_token_from_authorization_token - # NOTE: "current_token" is the JWT token from KAS, because the agentw requests are proxied through KAS + # NOTE: "workspace_token_string" is the JWT token from KAS, because agentw requests are proxied through KAS workspace_token_string = current_request.headers[Gitlab::Kas::INTERNAL_API_AGENT_REQUEST_HEADER] return unless workspace_token_string.present? diff --git a/ee/lib/remote_development/messages.rb b/ee/lib/remote_development/messages.rb index aaed84872295f4f2386c14fc4e28bba713f377f6..d01c69878c78e46ecb361f07246212c7f915aa49 100644 --- a/ee/lib/remote_development/messages.rb +++ b/ee/lib/remote_development/messages.rb @@ -49,6 +49,9 @@ module Messages DevfileRestrictionsFailed = Class.new(Gitlab::Fp::Message) DevfileFlattenFailed = Class.new(Gitlab::Fp::Message) + # Workspace Authorize User Access errors + WorkspaceAuthorizeUserAccessFailed = Class.new(Gitlab::Fp::Message) + #--------------------------------------------------------- # Domain Events - message name should describe the outcome #--------------------------------------------------------- @@ -78,5 +81,8 @@ module Messages # Workspaces Server Operations domain events WorkspacesServerConfigSuccessful = Class.new(Gitlab::Fp::Message) + + # Workspace Authorize User Access domain events + WorkspaceAuthorizeUserAccessSuccessful = Class.new(Gitlab::Fp::Message) end end diff --git a/ee/lib/remote_development/workspaces_server_operations/authorize_user_access/authorizer.rb b/ee/lib/remote_development/workspaces_server_operations/authorize_user_access/authorizer.rb new file mode 100644 index 0000000000000000000000000000000000000000..e9540e6870add795e7dfcc1e88fd2bdf97114c11 --- /dev/null +++ b/ee/lib/remote_development/workspaces_server_operations/authorize_user_access/authorizer.rb @@ -0,0 +1,42 @@ +# frozen_string_literal: true + +module RemoteDevelopment + module WorkspacesServerOperations + module AuthorizeUserAccess + class Authorizer + include Messages + extend Gitlab::Fp::MessageSupport + + # @param [Hash] context + # @return [Gitlab::Fp::Result] + def self.authorize(context) + context => { + user_id: Integer => user_id, + workspace: workspace, + port: String => port + } + + # TODO: check if port is available in processed_devfile + + unless workspace.user_id == user_id + return Gitlab::Fp::Result.err( + WorkspaceAuthorizeUserAccessFailed.new({ status: Status::NOT_AUTHORIZED }) + ) + end + + Gitlab::Fp::Result.ok( + context.merge( + response_payload: { + status: Status::AUTHORIZED, + info: { + port: port, + workspace_id: workspace.id + } + } + ) + ) + end + end + end + end +end diff --git a/ee/lib/remote_development/workspaces_server_operations/authorize_user_access/main.rb b/ee/lib/remote_development/workspaces_server_operations/authorize_user_access/main.rb new file mode 100644 index 0000000000000000000000000000000000000000..4d6df914aaf271581a8e9991a96f7448bf8732da --- /dev/null +++ b/ee/lib/remote_development/workspaces_server_operations/authorize_user_access/main.rb @@ -0,0 +1,50 @@ +# frozen_string_literal: true + +module RemoteDevelopment + module WorkspacesServerOperations + module AuthorizeUserAccess + class Main + include Messages + extend Gitlab::Fp::MessageSupport + + # @param [Hash] context + # @return [Hash] + def self.main(context) + initial_result = Gitlab::Fp::Result.ok(context) + + result = + initial_result + .and_then(WorkspaceHostParser.method(:parse_workspace_host)) + .and_then(WorkspaceFinder.method(:find_workspace)) + .and_then(Authorizer.method(:authorize)) + .map( + # As the final step, return the response_payload content in a WorkspaceAuthorizeUserAccessSuccessful + # message + ->(context) do + WorkspaceAuthorizeUserAccessSuccessful.new(context.fetch(:response_payload)) + end + ) + + # noinspection RubyMismatchedReturnType -- RubyMine not properly detecting return type of Hash + case result + in { ok: WorkspaceAuthorizeUserAccessSuccessful => message } + # Type-check the payload before returning it + message.content => { + status: String, + info: Hash + } + { status: :success, payload: message.content } + in { err: WorkspaceAuthorizeUserAccessFailed => message } + # Type-check the payload before returning it + message.content => { + status: String + } + { status: :success, payload: message.content.merge(info: {}) } + else + raise Gitlab::Fp::UnmatchedResultError.new(result: result) + end + end + end + end + end +end diff --git a/ee/lib/remote_development/workspaces_server_operations/authorize_user_access/status.rb b/ee/lib/remote_development/workspaces_server_operations/authorize_user_access/status.rb new file mode 100644 index 0000000000000000000000000000000000000000..233960f26bc6f08c124c68606c33df817cc4966a --- /dev/null +++ b/ee/lib/remote_development/workspaces_server_operations/authorize_user_access/status.rb @@ -0,0 +1,15 @@ +# frozen_string_literal: true + +module RemoteDevelopment + module WorkspacesServerOperations + module AuthorizeUserAccess + class Status + INVALID_HOST = "INVALID_HOST" + NOT_AUTHORIZED = "NOT_AUTHORIZED" + AUTHORIZED = "AUTHORIZED" + WORKSPACE_NOT_FOUND = "WORKSPACE_NOT_FOUND" + PORT_NOT_FOUND = "PORT_NOT_FOUND" + end + end + end +end diff --git a/ee/lib/remote_development/workspaces_server_operations/authorize_user_access/workspace_finder.rb b/ee/lib/remote_development/workspaces_server_operations/authorize_user_access/workspace_finder.rb new file mode 100644 index 0000000000000000000000000000000000000000..4cac1147819e75bba51689ca8713d84d75d35609 --- /dev/null +++ b/ee/lib/remote_development/workspaces_server_operations/authorize_user_access/workspace_finder.rb @@ -0,0 +1,34 @@ +# frozen_string_literal: true + +module RemoteDevelopment + module WorkspacesServerOperations + module AuthorizeUserAccess + class WorkspaceFinder + include Messages + extend Gitlab::Fp::MessageSupport + + # Find the workspace by name + # @param [Hash] context + # @return [Gitlab::Fp::Result] + def self.find_workspace(context) + context => { + workspace_name: String => workspace_name + } + + workspace = ::RemoteDevelopment::Workspace.find_by_name(workspace_name) + + unless workspace + return Gitlab::Fp::Result.err( + WorkspaceAuthorizeUserAccessFailed.new({ status: Status::WORKSPACE_NOT_FOUND }) + ) + end + + # Add the workspace to context + Gitlab::Fp::Result.ok( + context.merge(workspace: workspace) + ) + end + end + end + end +end diff --git a/ee/lib/remote_development/workspaces_server_operations/authorize_user_access/workspace_host_parser.rb b/ee/lib/remote_development/workspaces_server_operations/authorize_user_access/workspace_host_parser.rb new file mode 100644 index 0000000000000000000000000000000000000000..a05b3675e6a157625b119ca6e83a9b887f61deb2 --- /dev/null +++ b/ee/lib/remote_development/workspaces_server_operations/authorize_user_access/workspace_host_parser.rb @@ -0,0 +1,64 @@ +# frozen_string_literal: true + +module RemoteDevelopment + module WorkspacesServerOperations + module AuthorizeUserAccess + class WorkspaceHostParser + include Messages + extend Gitlab::Fp::MessageSupport + + # Parse the workspace host to extract port and workspace name + # @param [Hash] context + # @return [Gitlab::Fp::Result] + def self.parse_workspace_host(context) + context => { + workspace_host: String => workspace_host + } + + # Parse the workspace host to extract port and workspace name + # Expected format: port-workspace_name.domain.com + begin + # If workspace_host looks like a URL, extract just the host part + if workspace_host.include?('://') + parsed_uri = URI.parse(workspace_host) + hostname = parsed_uri.host + else + hostname = workspace_host + end + + # Validate hostname is present + if hostname.blank? + return Gitlab::Fp::Result.err( + WorkspaceAuthorizeUserAccessFailed.new({ status: Status::INVALID_HOST }) + ) + end + + # Extract the subdomain part (everything before the first dot) + subdomain = hostname.split('.', 2).first + # Split subdomain into port and workspace name + port, workspace_name = subdomain.split('-', 2) + + # Validate that we have both port and workspace name + if port.blank? || workspace_name.blank? + return Gitlab::Fp::Result.err( + WorkspaceAuthorizeUserAccessFailed.new({ status: Status::INVALID_HOST }) + ) + end + + # Add the parsed values to context + Gitlab::Fp::Result.ok( + context.merge( + port: port, + workspace_name: workspace_name + ) + ) + rescue URI::InvalidURIError, StandardError + Gitlab::Fp::Result.err( + WorkspaceAuthorizeUserAccessFailed.new({ status: Status::INVALID_HOST }) + ) + end + end + end + end + end +end diff --git a/ee/spec/lib/remote_development/workspaces_server_operations/authorize_user_access/authorizer_spec.rb b/ee/spec/lib/remote_development/workspaces_server_operations/authorize_user_access/authorizer_spec.rb new file mode 100644 index 0000000000000000000000000000000000000000..17cda4dd312c032425a6be8113a13b24b9114712 --- /dev/null +++ b/ee/spec/lib/remote_development/workspaces_server_operations/authorize_user_access/authorizer_spec.rb @@ -0,0 +1,89 @@ +# frozen_string_literal: true + +require "fast_spec_helper" + +RSpec.describe RemoteDevelopment::WorkspacesServerOperations::AuthorizeUserAccess::Authorizer, feature_category: :workspaces do + include ResultMatchers + + let(:user_id) { 123 } + let(:workspace_id) { 456 } + let(:port) { "60001" } + let(:workspace_name) { "workspace-abc123" } + let(:workspace) { instance_double("RemoteDevelopment::Workspace", id: workspace_id, name: workspace_name, user_id: workspace_owner_id) } # rubocop:disable RSpec/VerifiedDoubleReference -- We're using the quoted version so we can use fast_spec_helper + + let(:context) do + { + workspace_host: "#{port}-#{workspace_name}.example.com", + user_id: user_id, + port: port, + workspace_name: workspace_name, + workspace: workspace + } + end + + subject(:result) do + described_class.authorize(context) + end + + describe "#authorize" do + context "when user is authorized (owns the workspace)" do + let(:workspace_owner_id) { user_id } + + it "returns an ok Result with authorized status and workspace info" do + expect(result).to be_ok_result do |returned_context| + expect(returned_context).to eq( + context.merge( + response_payload: { + status: "AUTHORIZED", + info: { + port: port, + workspace_id: workspace_id + } + } + ) + ) + end + end + end + + context "when user is not authorized (does not own the workspace)" do + let(:workspace_owner_id) { 789 } # Different user ID + + it "returns an err Result with NOT_AUTHORIZED status" do + expect(result).to be_err_result do |message| + expect(message).to be_a RemoteDevelopment::Messages::WorkspaceAuthorizeUserAccessFailed + expect(message.content).to eq({ status: "NOT_AUTHORIZED" }) + end + end + end + + context "with different user and workspace IDs" do + let(:user_id) { 999 } + let(:workspace_id) { 888 } + let(:workspace_owner_id) { user_id } + + it "returns authorized when IDs match" do + expect(result).to be_ok_result do |returned_context| + expect(returned_context[:response_payload]).to include( + status: "AUTHORIZED", + info: { + port: port, + workspace_id: workspace_id + } + ) + end + end + end + + context "with different port values" do + let(:port) { "8080" } + let(:workspace_owner_id) { user_id } + + it "includes the correct port in the response" do + expect(result).to be_ok_result do |returned_context| + expect(returned_context[:response_payload][:info][:port]).to eq("8080") + end + end + end + end +end diff --git a/ee/spec/lib/remote_development/workspaces_server_operations/authorize_user_access/main_integration_spec.rb b/ee/spec/lib/remote_development/workspaces_server_operations/authorize_user_access/main_integration_spec.rb new file mode 100644 index 0000000000000000000000000000000000000000..06dac32610820376930c9db94f6b13153ee3ef31 --- /dev/null +++ b/ee/spec/lib/remote_development/workspaces_server_operations/authorize_user_access/main_integration_spec.rb @@ -0,0 +1,109 @@ +# frozen_string_literal: true + +require "spec_helper" + +# noinspection RubyArgCount -- Rubymine detecting wrong types, it thinks some #create are from Minitest, not FactoryBot +RSpec.describe ::RemoteDevelopment::WorkspacesServerOperations::AuthorizeUserAccess::Main, feature_category: :workspaces do + let_it_be(:user) { create(:user) } + let_it_be(:other_user) { create(:user) } + let_it_be(:workspace) { create(:workspace, user: user) } + let_it_be(:dns_zone) { "integration-spec-workspaces.localdev.me" } + let_it_be(:port) { "60001" } + + let(:workspace_host) { "#{port}-#{workspace.name}.#{dns_zone}" } + + subject(:response) do + described_class.main( + workspace_host: workspace_host, + user_id: user_id + ) + end + + shared_examples "successful authorization response" do + it "returns success with authorized status and workspace info" do + expect(response).to eq({ + status: :success, + payload: { + status: "AUTHORIZED", + info: { + port: port, + workspace_id: workspace.id + } + } + }) + end + end + + shared_examples "failed authorization response" do |expected_status| + it "returns success with failed status and empty info" do + expect(response).to eq({ + status: :success, + payload: { + status: expected_status, + info: {} + } + }) + end + end + + context "when user is authorized" do + let(:user_id) { user.id } + + it_behaves_like "successful authorization response" + end + + context "when user is not authorized" do + let(:user_id) { other_user.id } + + it_behaves_like "failed authorization response", "NOT_AUTHORIZED" + end + + context "when workspace does not exist" do + let(:user_id) { user.id } + let(:workspace_host) { "#{port}-nonexistent-workspace.#{dns_zone}" } + + it_behaves_like "failed authorization response", "WORKSPACE_NOT_FOUND" + end + + context "when workspace host is invalid" do + let(:user_id) { user.id } + + context "when host format is completely invalid" do + let(:workspace_host) { "invalid-format" } + + it_behaves_like "failed authorization response", "WORKSPACE_NOT_FOUND" + end + + context "when host is missing port" do + let(:workspace_host) { "#{workspace.name}.#{dns_zone}" } + + it_behaves_like "failed authorization response", "WORKSPACE_NOT_FOUND" + end + + context "when host is missing workspace name" do + let(:workspace_host) { "#{port}-.#{dns_zone}" } + + it_behaves_like "failed authorization response", "INVALID_HOST" + end + + context "when host is empty" do + let(:workspace_host) { "" } + + it_behaves_like "failed authorization response", "INVALID_HOST" + end + end + + context "when workspace host is a full URL" do + let(:user_id) { user.id } + let(:workspace_host) { "https://#{port}-#{workspace.name}.#{dns_zone}/path" } + + it_behaves_like "successful authorization response" + end + + context "when workspace host contains invalid URI characters" do + let(:user_id) { user.id } + let(:workspace_host) { "https://#{port}-#{workspace.name}.#{dns_zone}/path with spaces" } + + it_behaves_like "failed authorization response", "INVALID_HOST" + end +end diff --git a/ee/spec/lib/remote_development/workspaces_server_operations/authorize_user_access/main_spec.rb b/ee/spec/lib/remote_development/workspaces_server_operations/authorize_user_access/main_spec.rb new file mode 100644 index 0000000000000000000000000000000000000000..57f5f3175c09349bf16d04c63a880cdc9e2f6493 --- /dev/null +++ b/ee/spec/lib/remote_development/workspaces_server_operations/authorize_user_access/main_spec.rb @@ -0,0 +1,134 @@ +# frozen_string_literal: true + +require "fast_spec_helper" + +RSpec.describe RemoteDevelopment::WorkspacesServerOperations::AuthorizeUserAccess::Main, feature_category: :workspaces do + let(:workspace_host) { "60001-workspace-abc123.example.com" } + let(:user_id) { 123 } + let(:context_passed_along_steps) { { workspace_host: workspace_host, user_id: user_id } } + + let(:rop_steps) do + [ + [RemoteDevelopment::WorkspacesServerOperations::AuthorizeUserAccess::WorkspaceHostParser, :and_then], + [RemoteDevelopment::WorkspacesServerOperations::AuthorizeUserAccess::WorkspaceFinder, :and_then], + [RemoteDevelopment::WorkspacesServerOperations::AuthorizeUserAccess::Authorizer, :and_then] + ] + end + + describe "happy path" do + let(:response_payload) do + { + status: "AUTHORIZED", + info: { + port: "60001", + workspace_id: 456 + } + } + end + + let(:context_passed_along_steps) do + { + workspace_host: workspace_host, + user_id: user_id, + response_payload: response_payload + } + end + + let(:expected_response) do + { + status: :success, + payload: response_payload + } + end + + it "returns expected response" do + # noinspection RubyResolve - https://handbook.gitlab.com/handbook/tools-and-tips/editors-and-ides/jetbrains-ides/tracked-jetbrains-issues/#ruby-31542 + expect do + described_class.main(context_passed_along_steps) + end + .to invoke_rop_steps(rop_steps) + .from_main_class(described_class) + .with_context_passed_along_steps(context_passed_along_steps) + .and_return_expected_value(expected_response) + end + end + + describe "error cases" do + shared_examples "rop invocation with error response" do + it "returns expected response" do + # noinspection RubyResolve - https://handbook.gitlab.com/handbook/tools-and-tips/editors-and-ides/jetbrains-ides/tracked-jetbrains-issues/#ruby-31542 + expect do + described_class.main(context_passed_along_steps) + end + .to invoke_rop_steps(rop_steps) + .from_main_class(described_class) + .with_context_passed_along_steps(context_passed_along_steps) + .with_err_result_for_step(err_result_for_step) + .and_return_expected_value(expected_response) + end + end + + # rubocop:disable Style/TrailingCommaInArrayLiteral -- let the last element have a comma for simpler diffs + # rubocop:disable Layout/LineLength -- we want to avoid excessive wrapping for RSpec::Parameterized Nested Array Style so we can have formatting consistency between entries + where(:case_name, :err_result_for_step, :expected_response) do + [ + [ + "when WorkspaceHostParser returns WorkspaceAuthorizeUserAccessFailed with INVALID_HOST", + { + step_class: RemoteDevelopment::WorkspacesServerOperations::AuthorizeUserAccess::WorkspaceHostParser, + returned_message: lazy { RemoteDevelopment::Messages::WorkspaceAuthorizeUserAccessFailed.new({ status: "INVALID_HOST" }) } + }, + { + status: :success, + payload: { + status: "INVALID_HOST", + info: {} + } + }, + ], + [ + "when WorkspaceFinder returns WorkspaceAuthorizeUserAccessFailed with WORKSPACE_NOT_FOUND", + { + step_class: RemoteDevelopment::WorkspacesServerOperations::AuthorizeUserAccess::WorkspaceFinder, + returned_message: lazy { RemoteDevelopment::Messages::WorkspaceAuthorizeUserAccessFailed.new({ status: "WORKSPACE_NOT_FOUND" }) } + }, + { + status: :success, + payload: { + status: "WORKSPACE_NOT_FOUND", + info: {} + } + }, + ], + [ + "when Authorizer returns WorkspaceAuthorizeUserAccessFailed with NOT_AUTHORIZED", + { + step_class: RemoteDevelopment::WorkspacesServerOperations::AuthorizeUserAccess::Authorizer, + returned_message: lazy { RemoteDevelopment::Messages::WorkspaceAuthorizeUserAccessFailed.new({ status: "NOT_AUTHORIZED" }) } + }, + { + status: :success, + payload: { + status: "NOT_AUTHORIZED", + info: {} + } + }, + ], + [ + "when an unmatched error is returned, an exception is raised", + { + step_class: RemoteDevelopment::WorkspacesServerOperations::AuthorizeUserAccess::WorkspaceHostParser, + returned_message: lazy { Class.new(Gitlab::Fp::Message).new({ status: "UNKNOWN" }) } + }, + Gitlab::Fp::UnmatchedResultError + ], + ] + end + # rubocop:enable Style/TrailingCommaInArrayLiteral + # rubocop:enable Layout/LineLength + + with_them do + it_behaves_like "rop invocation with error response" + end + end +end diff --git a/ee/spec/lib/remote_development/workspaces_server_operations/authorize_user_access/workspace_finder_spec.rb b/ee/spec/lib/remote_development/workspaces_server_operations/authorize_user_access/workspace_finder_spec.rb new file mode 100644 index 0000000000000000000000000000000000000000..1045de007298f2a40cb08da015bbebcf0496a18b --- /dev/null +++ b/ee/spec/lib/remote_development/workspaces_server_operations/authorize_user_access/workspace_finder_spec.rb @@ -0,0 +1,65 @@ +# frozen_string_literal: true + +require "spec_helper" + +# noinspection RubyArgCount -- Rubymine detecting wrong types, it thinks some #create are from Minitest, not FactoryBot +RSpec.describe RemoteDevelopment::WorkspacesServerOperations::AuthorizeUserAccess::WorkspaceFinder, feature_category: :workspaces do + include ResultMatchers + + let_it_be(:user) { create(:user) } + let(:workspace_name) { "workspace-abc123" } + let(:context) do + { + workspace_host: "60001-#{workspace_name}.example.com", + user_id: user.id, + port: "60001", + workspace_name: workspace_name + } + end + + subject(:result) do + described_class.find_workspace(context) + end + + describe "#find_workspace" do + context "when workspace exists" do + let_it_be(:workspace) { create(:workspace, name: "workspace-abc123", user: user) } + + it "returns an ok Result with the workspace added to context" do + expect(result).to be_ok_result do |returned_context| + expect(returned_context).to eq( + context.merge(workspace: workspace) + ) + end + end + end + + context "when workspace does not exist" do + it "returns an err Result with WORKSPACE_NOT_FOUND status" do + expect(result).to be_err_result do |message| + expect(message).to be_a RemoteDevelopment::Messages::WorkspaceAuthorizeUserAccessFailed + expect(message.content).to eq({ status: "WORKSPACE_NOT_FOUND" }) + end + end + end + + context "when workspace name is different" do + let(:different_workspace_name) { "different-workspace" } + let(:context) do + { + workspace_host: "60001-#{different_workspace_name}.example.com", + user_id: user.id, + port: "60001", + workspace_name: different_workspace_name + } + end + + it "returns an err Result when not found" do + expect(result).to be_err_result do |message| + expect(message).to be_a RemoteDevelopment::Messages::WorkspaceAuthorizeUserAccessFailed + expect(message.content).to eq({ status: "WORKSPACE_NOT_FOUND" }) + end + end + end + end +end diff --git a/ee/spec/lib/remote_development/workspaces_server_operations/authorize_user_access/workspace_host_parser_spec.rb b/ee/spec/lib/remote_development/workspaces_server_operations/authorize_user_access/workspace_host_parser_spec.rb new file mode 100644 index 0000000000000000000000000000000000000000..acfab67ecb7806b6956fde578cc8f3c77f0020dd --- /dev/null +++ b/ee/spec/lib/remote_development/workspaces_server_operations/authorize_user_access/workspace_host_parser_spec.rb @@ -0,0 +1,109 @@ +# frozen_string_literal: true + +require "fast_spec_helper" + +RSpec.describe RemoteDevelopment::WorkspacesServerOperations::AuthorizeUserAccess::WorkspaceHostParser, feature_category: :workspaces do + include ResultMatchers + + let(:context) do + { + workspace_host: workspace_host, + user_id: 123 + } + end + + subject(:result) do + described_class.parse_workspace_host(context) + end + + describe "#parse_workspace_host" do + context "when workspace host is valid" do + context "with a simple hostname" do + let(:workspace_host) { "60001-workspace-abc123.example.com" } + + it "returns an ok Result with parsed port and workspace name" do + expect(result).to be_ok_result do |returned_context| + expect(returned_context).to eq( + context.merge( + port: "60001", + workspace_name: "workspace-abc123" + ) + ) + end + end + end + + context "with a full URL" do + let(:workspace_host) { "https://60001-workspace-xyz.example.com/path" } + + it "returns an ok Result with parsed port and workspace name from hostname" do + expect(result).to be_ok_result do |returned_context| + expect(returned_context).to eq( + context.merge( + port: "60001", + workspace_name: "workspace-xyz" + ) + ) + end + end + end + end + + context "when workspace host is invalid" do + context "when hostname is blank after URL parsing" do + let(:workspace_host) { "https://" } + + it "returns an err Result with INVALID_HOST status" do + expect(result).to be_err_result do |message| + expect(message).to be_a RemoteDevelopment::Messages::WorkspaceAuthorizeUserAccessFailed + expect(message.content).to eq({ status: "INVALID_HOST" }) + end + end + end + + context "when workspace host is empty string" do + let(:workspace_host) { "" } + + it "returns an err Result with INVALID_HOST status" do + expect(result).to be_err_result do |message| + expect(message).to be_a RemoteDevelopment::Messages::WorkspaceAuthorizeUserAccessFailed + expect(message.content).to eq({ status: "INVALID_HOST" }) + end + end + end + + context "when port is missing (no hyphen in subdomain)" do + let(:workspace_host) { "workspace.example.com" } + + it "returns an err Result with INVALID_HOST status" do + expect(result).to be_err_result do |message| + expect(message).to be_a RemoteDevelopment::Messages::WorkspaceAuthorizeUserAccessFailed + expect(message.content).to eq({ status: "INVALID_HOST" }) + end + end + end + + context "when workspace name is missing" do + let(:workspace_host) { "60001-.example.com" } + + it "returns an err Result with INVALID_HOST status" do + expect(result).to be_err_result do |message| + expect(message).to be_a RemoteDevelopment::Messages::WorkspaceAuthorizeUserAccessFailed + expect(message.content).to eq({ status: "INVALID_HOST" }) + end + end + end + + context "when URL is malformed" do + let(:workspace_host) { "https://invalid url with spaces" } + + it "returns an err Result with INVALID_HOST status" do + expect(result).to be_err_result do |message| + expect(message).to be_a RemoteDevelopment::Messages::WorkspaceAuthorizeUserAccessFailed + expect(message.content).to eq({ status: "INVALID_HOST" }) + end + end + end + end + end +end diff --git a/ee/spec/requests/remote_development/integration_spec.rb b/ee/spec/requests/remote_development/integration_spec.rb index 9f6aebe23be0f21d3f40fc46805fd50b2658628d..21816cffd439119bd897d860426bca5c6af1432a 100644 --- a/ee/spec/requests/remote_development/integration_spec.rb +++ b/ee/spec/requests/remote_development/integration_spec.rb @@ -212,6 +212,7 @@ def do_get_internal_agents_agentw_server_config nil end + # @param [RemoteDevelopment::Workspace] workspace # @return [void] def do_get_internal_agents_agentw_agent_info(workspace:) # Perform an /internal/agents/agentw/agent_info GET which will authenticate with the workspace_token @@ -241,6 +242,44 @@ def do_get_internal_agents_agentw_agent_info(workspace:) nil end + # @param [RemoteDevelopment::Workspace] workspace + # @param [User] user + # @return [void] + def do_get_internal_agents_agentw_authorize_user_access(workspace:, user:) + # Perform an /internal/agents/agentw/authorize_user_access GET which will check if the user + # is authorized to access the workspace based on the workspace host and user ID. + + # Extract the host from the workspace URL instead of manually constructing it + workspace_host = URI.parse(workspace.url).host + + params = { + workspace_host: workspace_host, + user_id: user.id + } + + headers = { + Gitlab::Kas::INTERNAL_API_KAS_REQUEST_HEADER => kas_jwt_token + } + authorize_user_access_url = api("/internal/agents/agentw/authorize_user_access") + + get authorize_user_access_url, params: params, headers: headers + + expect(response).to have_gitlab_http_status(:ok) + + expected_status = ::RemoteDevelopment::WorkspacesServerOperations::AuthorizeUserAccess::Status::AUTHORIZED + expect(json_response).to eq( + { + "status" => expected_status, + "info" => { + "port" => ::RemoteDevelopment::WorkspaceOperations::Create::CreateConstants::WORKSPACE_EDITOR_PORT.to_s, + "workspace_id" => workspace.id + } + } + ) + + nil + end + # @return [void] def do_create_workspaces_agent_config # Perform an agent update post which will cause a workspaces_agent_config record to be created @@ -542,6 +581,9 @@ def do_reconcile_post(params:, agent_token:) # AGENTW INTERNAL REQUEST: GET THE AGENTW AGENT INFO VIA REST API do_get_internal_agents_agentw_agent_info(workspace: workspace) + # KAS INTERNAL REQUEST: AUTHORIZE USER ACCESS VIA REST API + do_get_internal_agents_agentw_authorize_user_access(workspace: workspace, user: user) + # SIMULATE RECONCILE RESPONSE TO AGENTK SENDING NEW WORKSPACE simulate_first_poll( workspace: workspace.reload, diff --git a/scripts/remote_development/run-smoke-test-suite.sh b/scripts/remote_development/run-smoke-test-suite.sh index 4307f225b2986ddcbfa04456352bc945ad580cb0..199cd6f360bb724eb55f5729bdfbc1eb0b2d5106 100755 --- a/scripts/remote_development/run-smoke-test-suite.sh +++ b/scripts/remote_development/run-smoke-test-suite.sh @@ -40,7 +40,7 @@ function run_rubocop { while IFS='' read -r file; do files_for_rubocop+=("$file") - done < <(git ls-files -- '**/remote_development/*.rb' '**/gitlab/fp/*.rb' '*_rop_*.rb' '*railway_oriented_programming*.rb' '*_result_matchers*.rb') + done < <(git ls-files -- '**/remote_development/*.rb' '**/remote_development/**/*.rb' '**/gitlab/fp/*.rb' '**/gitlab/fp/**/*.rb' '*_rop_*.rb' '*railway_oriented_programming*.rb' '*_result_matchers*.rb') REVEAL_RUBOCOP_TODO=${REVEAL_RUBOCOP_TODO:-1} bundle exec rubocop --parallel --force-exclusion --no-server "${files_for_rubocop[@]}" } @@ -54,7 +54,7 @@ function run_fp { while IFS='' read -r file; do files_for_fp+=("$file") - done < <(git ls-files -- '**/gitlab/fp/*_spec.rb') + done < <(git ls-files -- '**/gitlab/fp/*_spec.rb' '**/gitlab/fp/**/*_spec.rb') bin/rspec "${files_for_fp[@]}" @@ -69,7 +69,7 @@ function run_rspec_fast { while IFS='' read -r file; do files_for_fast+=("$file") - done < <(git grep -l -E '^require .fast_spec_helper' -- '**/remote_development/*_spec.rb') + done < <(git grep -l -E '^require .fast_spec_helper' -- '**/remote_development/*_spec.rb' '**/remote_development/**/*_spec.rb') printf "Running rspec command:\n\n" printf "bin/rspec " @@ -102,7 +102,7 @@ function run_rspec_non_fast { # Running all fast and slow specs here ensures that we catch those cases. while IFS='' read -r file; do files_for_non_fast+=("$file") - done < <(git ls-files -- '**/remote_development/*_spec.rb' | grep -v 'qa/qa' | grep -v '/features/') + done < <(git ls-files -- '**/remote_development/*_spec.rb' '**/remote_development/**/*_spec.rb' | grep -v 'qa/qa' | grep -v '/features/') files_for_non_fast+=( "ee/spec/graphql/resolvers/clusters/agents_resolver_spec.rb" @@ -129,9 +129,9 @@ function run_rspec_feature { files_for_feature=() while IFS='' read -r file; do files_for_feature+=("$file") - done < <(git ls-files -- '**/remote_development/*_spec.rb' | grep -v 'qa/qa' | grep '/features/') + done < <(git ls-files -- '**/remote_development/*_spec.rb' '**/remote_development/**/*_spec.rb' | grep -v 'qa/qa' | grep '/features/') - bin/rspec -r spec_helper "${files_for_feature[@]}" + bin/rspec --format documentation -r spec_helper "${files_for_feature[@]}" } function print_success_message {