diff --git a/app/views/groups/settings/_permissions.html.haml b/app/views/groups/settings/_permissions.html.haml index 8ea807003407f1cd74d341734fd20d3b1fbeb02f..b1fa63dbf5601623801d473a350bfbbca38b13b6 100644 --- a/app/views/groups/settings/_permissions.html.haml +++ b/app/views/groups/settings/_permissions.html.haml @@ -44,6 +44,7 @@ = render 'groups/settings/subgroup_creation_level', f: f, group: @group = render_if_exists 'groups/settings/prevent_forking', f: f, group: @group = render_if_exists 'groups/settings/service_access_tokens_expiration_enforced', f: f, group: @group + = render_if_exists 'groups/settings/enforce_ssh_certificates', f: f, group: @group = render 'groups/settings/two_factor_auth', f: f, group: @group = render_if_exists 'groups/personal_access_token_expiration_policy', f: f, group: @group = render 'groups/settings/membership', f: f, group: @group diff --git a/config/feature_flags/development/enforce_ssh_certificates_via_settings.yml b/config/feature_flags/development/enforce_ssh_certificates_via_settings.yml new file mode 100644 index 0000000000000000000000000000000000000000..e721c2afe8c8a0949186e59dc525e4d8bc1bad44 --- /dev/null +++ b/config/feature_flags/development/enforce_ssh_certificates_via_settings.yml @@ -0,0 +1,8 @@ +--- +name: enforce_ssh_certificates_via_settings +introduced_by_url: https://gitlab.com/gitlab-org/gitlab/-/merge_requests/136498 +rollout_issue_url: https://gitlab.com/gitlab-org/gitlab/-/issues/426235 +milestone: '16.7' +type: development +group: group::source code +default_enabled: false diff --git a/db/migrate/20231109165512_add_enforce_ssh_certificates_to_namespace_settings.rb b/db/migrate/20231109165512_add_enforce_ssh_certificates_to_namespace_settings.rb new file mode 100644 index 0000000000000000000000000000000000000000..98c4de0cb6a57e6df4922d19ca05d58a48356f3e --- /dev/null +++ b/db/migrate/20231109165512_add_enforce_ssh_certificates_to_namespace_settings.rb @@ -0,0 +1,11 @@ +# frozen_string_literal: true + +class AddEnforceSshCertificatesToNamespaceSettings < Gitlab::Database::Migration[2.2] + enable_lock_retries! + + milestone '16.7' + + def change + add_column :namespace_settings, :enforce_ssh_certificates, :boolean, default: false, null: false + end +end diff --git a/db/schema_migrations/20231109165512 b/db/schema_migrations/20231109165512 new file mode 100644 index 0000000000000000000000000000000000000000..1e3a229c9d1c30f334a763a884ffb28435c10937 --- /dev/null +++ b/db/schema_migrations/20231109165512 @@ -0,0 +1 @@ +2d3abd070d856db04eea298bbbe82681ca01912e19f978de876fce68ed2ada26 \ No newline at end of file diff --git a/db/structure.sql b/db/structure.sql index 5ea1c69abc56322049be6bc6855f3b1ab35f7278..6116bb5688f8150a45a8caa799a81301d497bb9f 100644 --- a/db/structure.sql +++ b/db/structure.sql @@ -19472,6 +19472,7 @@ CREATE TABLE namespace_settings ( service_access_tokens_expiration_enforced boolean DEFAULT true NOT NULL, product_analytics_enabled boolean DEFAULT false NOT NULL, allow_merge_without_pipeline boolean DEFAULT false NOT NULL, + enforce_ssh_certificates boolean DEFAULT false NOT NULL, CONSTRAINT check_0ba93c78c7 CHECK ((char_length(default_branch_name) <= 255)), CONSTRAINT namespace_settings_unique_project_download_limit_alertlist_size CHECK ((cardinality(unique_project_download_limit_alertlist) <= 100)), CONSTRAINT namespace_settings_unique_project_download_limit_allowlist_size CHECK ((cardinality(unique_project_download_limit_allowlist) <= 100)) diff --git a/ee/app/controllers/ee/groups_controller.rb b/ee/app/controllers/ee/groups_controller.rb index 786e1d630b9210efc9891db09dcc76524453ff98..4b4d96f485c1f684b541aae1915c64bec6044d92 100644 --- a/ee/app/controllers/ee/groups_controller.rb +++ b/ee/app/controllers/ee/groups_controller.rb @@ -98,6 +98,7 @@ def group_params_ee params_ee << :max_personal_access_token_lifetime if current_group&.personal_access_token_expiration_policy_available? params_ee << :prevent_forking_outside_group if can_change_prevent_forking?(current_user, current_group) params_ee << :service_access_tokens_expiration_enforced if can_change_service_access_tokens_expiration?(current_user, current_group) + params_ee << :enforce_ssh_certificates if current_group&.ssh_certificates_available? params_ee << :code_suggestions if ai_assist_ui_enabled? params_ee << { value_stream_dashboard_aggregation_attributes: [:enabled] } if can?(current_user, :modify_value_stream_dashboard_settings, current_group) params_ee << :product_analytics_enabled diff --git a/ee/app/models/ee/namespace.rb b/ee/app/models/ee/namespace.rb index b1948f719050953139cd70a9479cbc3fb16c6c15..c770ef222bec53db57febf924f1b2d18b60dfb1b 100644 --- a/ee/app/models/ee/namespace.rb +++ b/ee/app/models/ee/namespace.rb @@ -114,6 +114,7 @@ module Namespace :additional_purchased_storage_ends_on, :additional_purchased_storage_ends_on=, :temporary_storage_increase_ends_on, :temporary_storage_increase_ends_on=, to: :namespace_limit, allow_nil: true + delegate :enforce_ssh_certificates=, to: :namespace_settings # `eligible_additional_purchased_storage_size` uses a FF to start checking `additional_purchased_storage_ends_on` # if the FF is enabled before returning `additional_purchased_storage_size` @@ -548,6 +549,14 @@ def domain_verification_available? ::Gitlab.com? && root? && licensed_feature_available?(:domain_verification) end + def enforce_ssh_certificates? + root? && namespace_settings.enforce_ssh_certificates? + end + + def ssh_certificates_available? + root? && licensed_feature_available?(:ssh_certificates) + end + def custom_roles_enabled? root_ancestor.licensed_feature_available?(:custom_roles) end diff --git a/ee/app/views/groups/settings/_enforce_ssh_certificates.html.haml b/ee/app/views/groups/settings/_enforce_ssh_certificates.html.haml new file mode 100644 index 0000000000000000000000000000000000000000..74fb495b01e70aca009ab1b835b56207977ef2e2 --- /dev/null +++ b/ee/app/views/groups/settings/_enforce_ssh_certificates.html.haml @@ -0,0 +1,7 @@ +- if group.ssh_certificates_available? + %h5= s_('GroupSettings|Enforce SSH Certificates') + + .form-group.gl-mb-3 + = f.gitlab_ui_checkbox_component :enforce_ssh_certificates, + s_('GroupSettings|Enforce SSH Certificates'), + checkbox_options: { disabled: !can?(current_user, :admin_group, group), checked: group.enforce_ssh_certificates? } diff --git a/ee/lib/ee/gitlab/git_access_project.rb b/ee/lib/ee/gitlab/git_access_project.rb index 7adcac2635d187978f043211c98fbd10d5c7440b..adc816afcd2ccc49f9e7cfbb0e7ca85635e71593 100644 --- a/ee/lib/ee/gitlab/git_access_project.rb +++ b/ee/lib/ee/gitlab/git_access_project.rb @@ -34,14 +34,22 @@ def allowed_access_namespace? # It may happen, when a user authenticates via SSH certificate and tries accessing to personal namespace return allowed_namespace_path.blank? unless namespace&.licensed_feature_available?(:ssh_certificates) - root_namespace = namespace.root_ancestor - - # When allowed_namespace_path is not specified, it's checked whether SSH certificates are not enforced - return true if allowed_namespace_path.blank? && ::Feature.disabled?(:enforce_ssh_certificates, root_namespace) - return root_namespace.enabled_git_access_protocol != 'ssh_certificates' if allowed_namespace_path.blank? + # When allowed_namespace_path is not specified, it's checked whether SSH certificates are enforced + return !enforced_ssh_certificates? if allowed_namespace_path.blank? allowed_namespace = ::Namespace.find_by_full_path(allowed_namespace_path) - allowed_namespace.present? && root_namespace.id == allowed_namespace.id + allowed_namespace.present? && namespace.root_ancestor.id == allowed_namespace.id + end + + # Verify that enabled_git_access_protocol is ssh_certificates and the + # actor is either User or Key + # Deploy Keys are allowed anyway + def enforced_ssh_certificates? + return false if ::Feature.disabled?(:enforce_ssh_certificates_via_settings, namespace.root_ancestor) + return false unless namespace.root_ancestor.enforce_ssh_certificates? + return false unless actor.is_a?(User) || actor.instance_of?(::Key) + + user.human? end end end diff --git a/ee/spec/models/ee/namespace_spec.rb b/ee/spec/models/ee/namespace_spec.rb index 9def58b82a85f8e0740fc010c818d22945277679..6c7c6ff2c30f335567bff2e99f00d49ff36730c1 100644 --- a/ee/spec/models/ee/namespace_spec.rb +++ b/ee/spec/models/ee/namespace_spec.rb @@ -1971,6 +1971,60 @@ end end + describe '#enforce_ssh_certificates?' do + let(:namespace) { create(:group) } + + before do + namespace.enforce_ssh_certificates = true + end + + it 'delegates the field to namespace settings' do + expect(namespace.namespace_settings).to receive(:enforce_ssh_certificates?).and_call_original + + expect(namespace.enforce_ssh_certificates?).to eq(true) + end + + context 'with a subgroup' do + let(:namespace) { create(:group, :nested) } + + it 'returns false' do + expect(namespace.enforce_ssh_certificates?).to eq(false) + end + end + end + + describe '#ssh_certificates_available?' do + let(:namespace) { create(:group) } + + context 'when the feature is not licensed' do + before do + stub_licensed_features(ssh_certificates: false) + end + + it 'is not available' do + expect(namespace.ssh_certificates_available?).to eq(false) + end + end + + context 'when the feature is licensed' do + before do + stub_licensed_features(ssh_certificates: true) + end + + it 'is available' do + expect(namespace.ssh_certificates_available?).to eq(true) + end + + context 'with a subgroup' do + let(:subgroup) { create(:group, :nested) } + + it 'is not available' do + expect(subgroup.ssh_certificates_available?).to eq(false) + end + end + end + end + describe '#custom_roles_enabled?', feature_category: :system_access do let_it_be(:namespace) { create(:group) } diff --git a/ee/spec/requests/api/internal/base_spec.rb b/ee/spec/requests/api/internal/base_spec.rb index a913d7a0be95c4d133c7ebf97aaf80437bcf0d6a..bd2711aecae19cc704a3487c7109037763e0facc 100644 --- a/ee/spec/requests/api/internal/base_spec.rb +++ b/ee/spec/requests/api/internal/base_spec.rb @@ -525,20 +525,26 @@ def check_access_by_alias(alias_name) end context 'when authenticated via an SSH certificate' do - let_it_be(:root_group) { create(:group) } + let_it_be_with_refind(:root_group) { create(:group) } let_it_be(:group) { create(:group, parent: root_group) } let_it_be(:project) { create(:project, :public, :repository, group: group) } - def check_allowed(namespace_path) + let(:namespace_path) { nil } + + let(:params) do + { + action: "git-upload-pack", + user_id: user.id, + project: project.full_path, + protocol: 'ssh', + namespace_path: namespace_path + } + end + + def check_allowed post( api("/internal/allowed"), - params: { - action: "git-upload-pack", - user_id: user.id, - project: project.full_path, - protocol: 'ssh_certificates', - namespace_path: namespace_path - }, + params: params, headers: gitlab_shell_internal_api_request_header ) end @@ -548,32 +554,67 @@ def check_allowed(namespace_path) end context 'when group is not specified' do - it 'is successful' do - check_allowed(nil) + it 'returns success response' do + check_allowed expect(response).to have_gitlab_http_status(:ok) end context 'when auth via SSH certificates is enforced' do - it 'is forbidden' do - root_group.namespace_settings.enabled_git_access_protocol = 'ssh_certificates' + before do + root_group.namespace_settings.enforce_ssh_certificates = true root_group.save! + end - check_allowed(nil) + it 'returns an unauthorized error response' do + check_allowed expect(response).to have_gitlab_http_status(:unauthorized) expect(json_response['status']).to eq(false) expect(json_response['message']).to eq('You are not allowed to access projects in this namespace.') end - context 'when enforce_ssh_certificates feature flag is disabled' do - it 'is successful' do - root_group.namespace_settings.enabled_git_access_protocol = 'ssh_certificates' - root_group.save! + context 'when enforce_ssh_certificates_via_settings feature flag is disabled' do + before do + stub_feature_flags(enforce_ssh_certificates_via_settings: false) + end - stub_feature_flags(enforce_ssh_certificates: false) + it 'returns success response' do + check_allowed - check_allowed(nil) + expect(response).to have_gitlab_http_status(:ok) + end + end + + context 'when service account is used' do + let(:params) do + { + action: "git-upload-pack", + project: project.full_path, + namespace_path: namespace_path, + user_id: create(:user, :service_account).id + } + end + + it 'returns success response' do + check_allowed + + expect(response).to have_gitlab_http_status(:ok) + end + end + + context 'when deploy key is used' do + let(:params) do + { + action: "git-upload-pack", + project: project.full_path, + namespace_path: namespace_path, + key_id: create(:deploy_key).id + } + end + + it 'returns success response' do + check_allowed expect(response).to have_gitlab_http_status(:ok) end @@ -582,8 +623,10 @@ def check_allowed(namespace_path) end context 'when non-root group is specified' do - it 'is forbidden' do - check_allowed(group.full_path) + let(:namespace_path) { group.full_path } + + it 'returns an unauthorized error response' do + check_allowed expect(response).to have_gitlab_http_status(:unauthorized) expect(json_response['status']).to eq(false) @@ -592,17 +635,19 @@ def check_allowed(namespace_path) end context 'when root group is specified' do + let(:namespace_path) { root_group.full_path } + it 'is successful' do - check_allowed(root_group.full_path) + check_allowed expect(response).to have_gitlab_http_status(:ok) end context 'when ssh_certificates licensed feature is not available' do - it 'is forbidden' do + it 'returns an unauthorized error response' do stub_licensed_features(ssh_certificates: false) - check_allowed(root_group.full_path) + check_allowed expect(response).to have_gitlab_http_status(:unauthorized) end @@ -611,8 +656,8 @@ def check_allowed(namespace_path) context 'when personal project is accessed' do let_it_be(:project) { create(:project, :public, :repository, namespace: user.namespace) } - it 'is forbidden' do - check_allowed(root_group.full_path) + it 'returns an unauthorized error response' do + check_allowed expect(response).to have_gitlab_http_status(:unauthorized) end diff --git a/ee/spec/requests/groups_controller_spec.rb b/ee/spec/requests/groups_controller_spec.rb index faadd8fc4a2a9bccc3f27a8cea5722720dfeec05..949eaf546add52ba8f654d3a9dfe4048b096eb4e 100644 --- a/ee/spec/requests/groups_controller_spec.rb +++ b/ee/spec/requests/groups_controller_spec.rb @@ -333,6 +333,35 @@ end end end + + context 'setting enforce_ssh_certificates' do + let(:params) { { group: { enforce_ssh_certificates: true } } } + + it 'does not change the column' do + expect { subject }.not_to change { group.reload.enforce_ssh_certificates? } + expect(response).to have_gitlab_http_status(:found) + end + + context 'when ssh_certificates licensed feature is available' do + before do + stub_licensed_features(ssh_certificates: true) + end + + it 'successfully changes the column' do + expect { subject }.to change { group.reload.enforce_ssh_certificates? } + expect(response).to have_gitlab_http_status(:found) + end + + context 'when a group is not a top-level group' do + let(:group) { create(:group, :nested) } + + it 'does not change the column' do + expect { subject }.not_to change { group.reload.enforce_ssh_certificates? } + expect(response).to have_gitlab_http_status(:found) + end + end + end + end end describe 'PUT #transfer', :saas do diff --git a/ee/spec/views/groups/settings/_enforce_ssh_certificates.html.haml_spec.rb b/ee/spec/views/groups/settings/_enforce_ssh_certificates.html.haml_spec.rb new file mode 100644 index 0000000000000000000000000000000000000000..51884c94df32a08e025a3dd1d9962938ff182e85 --- /dev/null +++ b/ee/spec/views/groups/settings/_enforce_ssh_certificates.html.haml_spec.rb @@ -0,0 +1,37 @@ +# frozen_string_literal: true + +require 'spec_helper' + +RSpec.describe 'groups/settings/_enforce_ssh_certificates.html.haml', feature_category: :source_code_management do + let_it_be(:group) { build_stubbed(:group, namespace_settings: build_stubbed(:namespace_settings)) } + + before do + allow(view).to receive(:group).and_return(group) + end + + context 'when ssh certificates feature is unavailable' do + it 'does not render enforce SSH certificates settings' do + render + + expect(rendered).to be_empty + end + end + + context 'when ssh certificates feature is available' do + it 'renders enforce SSH certificates settings' do + form = instance_double('Gitlab::FormBuilders::GitlabUiFormBuilder') + allow(view).to receive(:can?).and_return(true) + allow(view).to receive(:f).and_return(form) + allow(group).to receive(:ssh_certificates_available?).and_return(true) + + expect(form).to receive(:gitlab_ui_checkbox_component).with( + :enforce_ssh_certificates, "Enforce SSH Certificates", anything + ) + + render + + expect(rendered).to render_template('groups/settings/_enforce_ssh_certificates') + expect(rendered).to have_content('Enforce SSH Certificates') + end + end +end diff --git a/locale/gitlab.pot b/locale/gitlab.pot index 5c827b241365d25d222a1724ba9e69c28623cb5f..3fcd83490f7f59e6172736ae26cb6fb487c26314 100644 --- a/locale/gitlab.pot +++ b/locale/gitlab.pot @@ -23266,6 +23266,9 @@ msgstr "" msgid "GroupSettings|Enabling these features is your acceptance of the %{link_start}GitLab Testing Agreement%{link_end}." msgstr "" +msgid "GroupSettings|Enforce SSH Certificates" +msgstr "" + msgid "GroupSettings|Experiment and Beta features" msgstr ""