diff --git a/doc/api/groups.md b/doc/api/groups.md index bac4353cbe78fdcf5ac3daa75824967c3ed23736..26d28cd039924d0bb11a7a647b4f928871e3510b 100644 --- a/doc/api/groups.md +++ b/doc/api/groups.md @@ -1809,6 +1809,7 @@ PUT /groups/:id | `require_two_factor_authentication` | boolean | no | Require all users in this group to set up two-factor authentication. | | `shared_runners_setting` | string | no | See [Options for `shared_runners_setting`](#options-for-shared_runners_setting). Enable or disable instance runners for a group's subgroups and projects. | | `share_with_group_lock` | boolean | no | Prevent sharing a project with another group within this group. | +| `step_up_auth_required_oauth_provider` | string | no | OAuth provider required for step-up authentication. Pass empty string to disable. [Introduced](https://gitlab.com/gitlab-org/gitlab/-/issues/556943) in GitLab 18.4. Available when `omniauth_step_up_auth_for_namespace` feature flag is enabled. | | `subgroup_creation_level` | string | no | Allowed to [create subgroups](../user/group/subgroups/_index.md#create-a-subgroup). Can be `owner` (users with the Owner role), or `maintainer` (users with the Maintainer role). | | `two_factor_grace_period` | integer | no | Time before Two-factor authentication is enforced (in hours). | | `visibility` | string | no | The visibility level of the group. Can be `private`, `internal`, or `public`. | diff --git a/doc/api/openapi/openapi_v2.yaml b/doc/api/openapi/openapi_v2.yaml index d2d49d755d102ce8a4cb1639783c6b3c44b26a1c..8e04165d6b416281ba8a41e46141eacb01278f93 100644 --- a/doc/api/openapi/openapi_v2.yaml +++ b/doc/api/openapi/openapi_v2.yaml @@ -47750,6 +47750,10 @@ definitions: type: boolean description: Prevent sharing groups within this namespace with any groups outside the namespace. Only available on top-level groups. + step_up_auth_required_oauth_provider: + type: string + description: OAuth provider required for step-up authentication. Pass empty + string to disable. lock_math_rendering_limits_enabled: type: boolean description: Indicates if math rendering limits are locked for all descendent @@ -47950,6 +47954,9 @@ definitions: type: string prevent_sharing_groups_outside_hierarchy: type: string + step_up_auth_required_oauth_provider: + type: string + description: OAuth provider required for step-up authentication. projects: "$ref": "#/definitions/API_Entities_Project" shared_projects: diff --git a/lib/api/entities/group_detail.rb b/lib/api/entities/group_detail.rb index 9f3ced7d717e04e75e3843937637711b991a4ae9..0e735b97b52c9ac27b4271dedbdda150fad7313d 100644 --- a/lib/api/entities/group_detail.rb +++ b/lib/api/entities/group_detail.rb @@ -10,6 +10,18 @@ class GroupDetail < Group expose :enabled_git_access_protocol, if: ->(group, options) { group.root? && options[:user_can_admin_group] } expose :prevent_sharing_groups_outside_hierarchy, if: ->(group) { group.root? && group.namespace_settings.present? } + expose :step_up_auth_required_oauth_provider, + documentation: { + type: 'string', + desc: 'OAuth provider required for step-up authentication.' + }, + if: ->(group, options) { + ::Feature.enabled?(:omniauth_step_up_auth_for_namespace, group) && + group.namespace_settings.present? && + options[:user_can_admin_group] + } do |group| + group.namespace_settings.step_up_auth_required_oauth_provider + end expose :projects, if: ->(_, options) { options[:with_projects] }, diff --git a/lib/api/helpers/groups_helpers.rb b/lib/api/helpers/groups_helpers.rb index d4487d7b3aa9c41f5b4882894806f0cb674e60c0..299744ca4427865f612ac3b74465e9b4d0ba5d89 100644 --- a/lib/api/helpers/groups_helpers.rb +++ b/lib/api/helpers/groups_helpers.rb @@ -46,6 +46,10 @@ module GroupsHelpers params :optional_update_params do optional :prevent_sharing_groups_outside_hierarchy, type: Boolean, desc: 'Prevent sharing groups within this namespace with any groups outside the namespace. Only available on top-level groups.' + optional :step_up_auth_required_oauth_provider, + type: String, + allow_blank: true, + desc: 'OAuth provider required for step-up authentication. Pass empty string to disable.' optional :lock_math_rendering_limits_enabled, type: Boolean, desc: 'Indicates if math rendering limits are locked for all descendent groups.' optional :math_rendering_limits_enabled, type: Boolean, desc: 'Indicates if math rendering limits are used for this group.' optional :max_artifacts_size, type: Integer, desc: "Set the maximum file size for each job's artifacts" diff --git a/spec/lib/api/entities/group_detail_spec.rb b/spec/lib/api/entities/group_detail_spec.rb index f3200b28c4dda6946b1657f2657d72ab3c73f6d2..55cbfc34afc3b7f8e5b11fde7defd82ccbf09b7e 100644 --- a/spec/lib/api/entities/group_detail_spec.rb +++ b/spec/lib/api/entities/group_detail_spec.rb @@ -47,5 +47,57 @@ end end end + + describe '#step_up_auth_required_oauth_provider' do + let(:group) { root_group } + let(:options) { { user_can_admin_group: true } } + + it { is_expected.to include(:step_up_auth_required_oauth_provider) } + + context 'when user_can_admin_group is false' do + let(:options) { { user_can_admin_group: false } } + + it { is_expected.not_to include(:step_up_auth_required_oauth_provider) } + end + + context 'when namespace setting is blank' do + before do + allow(group).to receive(:namespace_settings).and_return(nil) + end + + it { is_expected.not_to include(:step_up_auth_required_oauth_provider) } + end + + context 'when step-up auth required oauth provider is set in namespace setting' do + let(:openid_connect_config) do + GitlabSettings::Options.new( + name: 'openid_connect', + step_up_auth: { + namespace: { + id_token: { + required: { acr: 'gold' } + } + } + } + ) + end + + before do + stub_omniauth_setting(enabled: true, providers: [openid_connect_config]) + + group.namespace_settings.update!(step_up_auth_required_oauth_provider: 'openid_connect') + end + + it { is_expected.to include step_up_auth_required_oauth_provider: 'openid_connect' } + end + + context 'when feature flag :omniauth_step_up_auth_for_namespace is disabled' do + before do + stub_feature_flags(omniauth_step_up_auth_for_namespace: false) + end + + it { is_expected.not_to include(:step_up_auth_required_oauth_provider) } + end + end end end diff --git a/spec/requests/api/groups_spec.rb b/spec/requests/api/groups_spec.rb index c556b2a86a394cb2a0c0f1c344598e5c9b0077c0..bfcf884ebcc0d3c347fba9f3ead4cf78ff23af88 100644 --- a/spec/requests/api/groups_spec.rb +++ b/spec/requests/api/groups_spec.rb @@ -1037,6 +1037,73 @@ def request expect(response_groups).to contain_exactly(group1.id, group_with_deletion_on.id, group_without_deletion.id) end end + + context 'step_up_auth_required_oauth_provider attribute' do + let(:ommiauth_provider_config) do + GitlabSettings::Options.new( + name: "openid_connect", + step_up_auth: { + namespace: { + id_token: { + required: { + acr: 'gold' + } + } + } + } + ) + end + + before do + stub_omniauth_setting(enabled: true, providers: [ommiauth_provider_config]) + end + + context 'when user has admin_group permission' do + it 'includes step_up_auth_required_oauth_provider' do + group1.update!(step_up_auth_required_oauth_provider: 'openid_connect') + + get api("/groups/#{group1.id}", user1) + + expect(response).to have_gitlab_http_status(:ok) + expect(json_response).to include('step_up_auth_required_oauth_provider' => 'openid_connect') + end + + it 'returns nil when not configured' do + get api("/groups/#{group1.id}", user1) + + expect(response).to have_gitlab_http_status(:ok) + expect(json_response).to include('step_up_auth_required_oauth_provider' => nil) + end + end + + context 'when user lacks admin_group permission' do + let(:guest) { create(:user, guest_of: group1) } + + it 'excludes step_up_auth_required_oauth_provider' do + group1.update!(step_up_auth_required_oauth_provider: 'openid_connect') + + get api("/groups/#{group1.id}", guest) + + expect(response).to have_gitlab_http_status(:ok) + expect(json_response).not_to include('step_up_auth_required_oauth_provider') + end + end + + context 'when feature flag is disabled' do + before do + stub_feature_flags(omniauth_step_up_auth_for_namespace: false) + end + + it 'excludes step_up_auth_required_oauth_provider' do + group1.update!(step_up_auth_required_oauth_provider: 'openid_connect') + + get api("/groups/#{group1.id}", user1) + + expect(response).to have_gitlab_http_status(:ok) + expect(json_response).not_to include('step_up_auth_required_oauth_provider') + end + end + end end describe 'PUT /groups/:id' do @@ -1153,6 +1220,7 @@ def make_upload_request expect(json_response['avatar_url']).to end_with('dk.png') expect(json_response['math_rendering_limits_enabled']).to eq(false) expect(json_response['lock_math_rendering_limits_enabled']).to eq(true) + expect(json_response['step_up_auth_required_oauth_provider']).to be_nil end context 'when updating :emails_disabled' do @@ -1303,6 +1371,82 @@ def make_upload_request end end + context 'updating the `step_up_auth_required_oauth_provider` attribute' do + let(:ommiauth_provider_config) do + GitlabSettings::Options.new( + name: "openid_connect", + step_up_auth: { + namespace: { + id_token: { + required: { + acr: 'gold' + } + } + } + } + ) + end + + before do + stub_omniauth_setting(enabled: true, providers: [ommiauth_provider_config]) + end + + context 'when user has admin_group permission' do + it 'updates step_up_auth_required_oauth_provider' do + put api("/groups/#{group1.id}", user1), params: { + step_up_auth_required_oauth_provider: 'openid_connect' + } + + expect(response).to have_gitlab_http_status(:ok) + expect(json_response).to include('step_up_auth_required_oauth_provider' => 'openid_connect') + expect(group1.reload.step_up_auth_required_oauth_provider).to eq('openid_connect') + end + + it 'returns validation error for invalid provider' do + put api("/groups/#{group1.id}", user1), params: { + step_up_auth_required_oauth_provider: 'invalid_provider' + } + + expect(response).to have_gitlab_http_status(:bad_request) + expect(json_response['message']['namespace_settings.step_up_auth_required_oauth_provider']) + .to include('is not included in the list') + end + end + + context 'when user lacks admin_group permission' do + let(:developer) { create(:user, developer_of: group1) } + + before do + group1.add_developer(developer) + end + + it 'returns forbidden' do + put api("/groups/#{group1.id}", developer), params: { + step_up_auth_required_oauth_provider: 'openid_connect' + } + + expect(response).to have_gitlab_http_status(:forbidden) + end + end + + context 'when feature flag is disabled' do + before do + stub_feature_flags(omniauth_step_up_auth_for_namespace: false) + end + + it 'ignores the parameter' do + put api("/groups/#{group1.id}", user1), params: { + step_up_auth_required_oauth_provider: 'openid_connect', + description: 'Updated description' + } + + expect(response).to have_gitlab_http_status(:ok) + expect(group1.reload.step_up_auth_required_oauth_provider).to be_nil + expect(group1.description).to eq('Updated description') + end + end + end + context 'malicious group name' do subject { put api("/groups/#{group1.id}", user1), params: { name: "" } }