diff --git a/app/graphql/mutations/groups/update.rb b/app/graphql/mutations/groups/update.rb index 8f18e007109f11896555c25e9949c793cfca2c78..8bdddf040a63e1af5e2f7b5ea3e7242173f3117d 100644 --- a/app/graphql/mutations/groups/update.rb +++ b/app/graphql/mutations/groups/update.rb @@ -32,6 +32,9 @@ class Update < Mutations::BaseMutation argument :shared_runners_setting, Types::Namespace::SharedRunnersSettingEnum, required: false, description: copy_field_description(Types::GroupType, :shared_runners_setting) + argument :step_up_auth_required_oauth_provider, GraphQL::Types::String, + required: false, + description: 'OAuth provider required for step-up authentication.' argument :visibility, Types::VisibilityLevelsEnum, required: false, description: copy_field_description(Types::GroupType, :visibility) diff --git a/app/graphql/types/namespace_type.rb b/app/graphql/types/namespace_type.rb index f0984b3b2071b64b41e8a9f8d901c584fdda76fa..e6136a0513831de7c7ba77add404369408115258 100644 --- a/app/graphql/types/namespace_type.rb +++ b/app/graphql/types/namespace_type.rb @@ -198,6 +198,11 @@ def self.authorization_scopes method: :itself, experiment: { milestone: '18.3' } + field :namespace_settings, + Types::Namespaces::NamespaceSettingsType, + null: true, + description: 'Namespace settings for the namespace.' + field :web_url, GraphQL::Types::String, null: true, diff --git a/app/graphql/types/namespaces/namespace_settings_type.rb b/app/graphql/types/namespaces/namespace_settings_type.rb new file mode 100644 index 0000000000000000000000000000000000000000..504ce947109fcd586127bdaad5ad97cd7402096b --- /dev/null +++ b/app/graphql/types/namespaces/namespace_settings_type.rb @@ -0,0 +1,23 @@ +# frozen_string_literal: true + +module Types + module Namespaces + class NamespaceSettingsType < BaseObject + graphql_name 'NamespaceSettings' + description 'Settings for the namespace' + + authorize :admin_group + + field :step_up_auth_required_oauth_provider, + GraphQL::Types::String, + null: true, + description: 'OAuth provider required for step-up authentication.' + + def step_up_auth_required_oauth_provider + return unless Feature.enabled?(:omniauth_step_up_auth_for_namespace, object.namespace) + + object.step_up_auth_required_oauth_provider + end + end + end +end diff --git a/doc/api/graphql/reference/_index.md b/doc/api/graphql/reference/_index.md index 52aa14e05f775779c561fb38c2fe5f1456b4f942..2a9711377a5f63a21fae4bc35c646ccc61dc6af5 100644 --- a/doc/api/graphql/reference/_index.md +++ b/doc/api/graphql/reference/_index.md @@ -7494,6 +7494,7 @@ Input type: `GroupUpdateInput` | `name` | [`String`](#string) | Name of the group. | | `path` | [`String`](#string) | Path of the namespace. | | `sharedRunnersSetting` | [`SharedRunnersSetting`](#sharedrunnerssetting) | Shared runners availability for the namespace and its descendants. | +| `stepUpAuthRequiredOauthProvider` | [`String`](#string) | OAuth provider required for step-up authentication. | | `visibility` | [`VisibilityLevelsEnum`](#visibilitylevelsenum) | Visibility of the namespace. | | `webBasedCommitSigningEnabled` {{< icon name="warning-solid" >}} | [`Boolean`](#boolean) | **Deprecated**: **Status**: Experiment. Introduced in GitLab 18.2. | @@ -30686,6 +30687,7 @@ GPG signature for a signed commit. | `mentionsDisabled` | [`Boolean`](#boolean) | Indicates if a group is disabled from getting mentioned. | | `mergeRequestsEnabled` {{< icon name="warning-solid" >}} | [`Boolean!`](#boolean) | **Introduced** in GitLab 18.3. **Status**: Experiment. Indicates if merge requests are enabled for the namespace. | | `name` | [`String`](#string) | Name of the group. | +| `namespaceSettings` | [`NamespaceSettings`](#namespacesettings) | Namespace settings for the namespace. | | `organizationEditPath` {{< icon name="warning-solid" >}} | [`String`](#string) | **Introduced** in GitLab 17.1. **Status**: Experiment. Path for editing group at the organization level. | | `packageSettings` | [`PackageSettings`](#packagesettings) | Package settings for the namespace. | | `parent` | [`Group`](#group) | Parent group. | @@ -36426,6 +36428,7 @@ Product analytics events for a specific month and year. | `markdownPaths` {{< icon name="warning-solid" >}} | [`MarkdownPaths`](#markdownpaths) | **Introduced** in GitLab 18.1. **Status**: Experiment. Namespace relevant paths to create markdown links on the UI. | | `mergeRequestsEnabled` {{< icon name="warning-solid" >}} | [`Boolean!`](#boolean) | **Introduced** in GitLab 18.3. **Status**: Experiment. Indicates if merge requests are enabled for the namespace. | | `name` | [`String!`](#string) | Name of the namespace. | +| `namespaceSettings` | [`NamespaceSettings`](#namespacesettings) | Namespace settings for the namespace. | | `packageSettings` | [`PackageSettings`](#packagesettings) | Package settings for the namespace. | | `path` | [`String!`](#string) | Path of the namespace. | | `plan` {{< icon name="warning-solid" >}} | [`NamespacePlan`](#namespaceplan) | **Introduced** in GitLab 18.2. **Status**: Experiment. Subscription plan associated with the namespace. | @@ -37116,6 +37119,16 @@ Represents a subscription plan. | `uploadsSize` | [`Float`](#float) | Uploads size of the project in bytes. | | `wikiSize` | [`Float`](#float) | Wiki size of the project in bytes. | +### `NamespaceSettings` + +Settings for the namespace. + +#### Fields + +| Name | Type | Description | +| ---- | ---- | ----------- | +| `stepUpAuthRequiredOauthProvider` | [`String`](#string) | OAuth provider required for step-up authentication. | + ### `NamespaceSidebar` #### Fields diff --git a/spec/requests/api/graphql/group_query_spec.rb b/spec/requests/api/graphql/group_query_spec.rb index 28320457326115dba90a9db97b5a12a62032a96d..85496c8c6cbc1ed901e48640af4a950fc5d7f312 100644 --- a/spec/requests/api/graphql/group_query_spec.rb +++ b/spec/requests/api/graphql/group_query_spec.rb @@ -297,5 +297,69 @@ def group_query(group) }) end end + + context 'with general settings' do + let(:omniauth_provider_config_oidc) do + GitlabSettings::Options.new( + name: 'openid_connect', + label: 'OpenID Connect', + step_up_auth: { + namespace: { + id_token: { + required: { + acr: 'gold' + } + } + } + } + ) + end + + before do + stub_omniauth_setting(enabled: true, providers: [omniauth_provider_config_oidc]) + end + + context 'when user has admin permission' do + before do + private_group.add_owner(user2) + private_group.update!(step_up_auth_required_oauth_provider: 'openid_connect') + end + + it 'includes namespaceSettings with stepUpAuthRequiredOauthProvider' do + query = graphql_query_for( + 'group', + { 'fullPath' => private_group.full_path }, + <<~FIELDS + namespaceSettings { + stepUpAuthRequiredOauthProvider + } + FIELDS + ) + + post_graphql(query, current_user: user2) + + expect(graphql_data_at(:group, :namespaceSettings, :stepUpAuthRequiredOauthProvider)) + .to eq('openid_connect') + end + end + + context 'when user lacks admin permission' do + before do + private_group.add_developer(user2) + end + + it 'returns nil for namespaceSettings' do + query = graphql_query_for( + 'group', + { 'fullPath' => private_group.full_path }, + 'namespaceSettings { stepUpAuthRequiredOauthProvider }' + ) + + post_graphql(query, current_user: user2) + + expect(graphql_data_at(:group, :namespaceSettings)).to be_nil + end + end + end end end diff --git a/spec/requests/api/graphql/mutations/groups/update_spec.rb b/spec/requests/api/graphql/mutations/groups/update_spec.rb index b444a1155ed721638a6f683646b20fce1598de61..d24a408aabf1bc88384e447b39fcb12199dd4a8a 100644 --- a/spec/requests/api/graphql/mutations/groups/update_spec.rb +++ b/spec/requests/api/graphql/mutations/groups/update_spec.rb @@ -93,5 +93,173 @@ expect(group.reload.shared_runners_setting).to eq('enabled') end end + + context 'with step-up authentication provider' do + let(:omniauth_provider_config_oidc) do + GitlabSettings::Options.new( + name: 'openid_connect', + label: 'OpenID Connect', + step_up_auth: { + namespace: { + id_token: { + required: { + acr: 'gold' + } + } + } + } + ) + end + + before do + stub_omniauth_setting(enabled: true, providers: [omniauth_provider_config_oidc]) + end + + context 'when feature flag is enabled' do + it 'updates step_up_auth_required_oauth_provider' do + variables = { + full_path: group.full_path, + step_up_auth_required_oauth_provider: 'openid_connect' + } + + mutation = graphql_mutation(:group_update, variables) do + <<~QL + group { + namespaceSettings { + stepUpAuthRequiredOauthProvider + } + } + errors + QL + end + + expect { post_graphql_mutation(mutation, current_user: user) } + .to change { group.reload.step_up_auth_required_oauth_provider } + .from(nil).to('openid_connect') + + expect(graphql_data_at(:group_update, :group, :namespaceSettings, :stepUpAuthRequiredOauthProvider)) + .to eq('openid_connect') + expect(graphql_data_at(:group_update, :errors)).to be_empty + end + + it 'clears setting with null value' do + group.update!(step_up_auth_required_oauth_provider: 'openid_connect') + + variables = { + full_path: group.full_path, + step_up_auth_required_oauth_provider: nil + } + + mutation = graphql_mutation(:group_update, variables) do + <<~QL + group { + namespaceSettings { + stepUpAuthRequiredOauthProvider + } + } + QL + end + + expect { post_graphql_mutation(mutation, current_user: user) } + .to change { group.reload.step_up_auth_required_oauth_provider } + .from('openid_connect').to(nil) + end + + it 'validates provider is in allowed list' do + stub_omniauth_setting(enabled: true, providers: []) + + variables = { + full_path: group.full_path, + step_up_auth_required_oauth_provider: 'openid_connect' + } + + mutation = graphql_mutation(:group_update, variables) do + 'errors' + end + + post_graphql_mutation(mutation, current_user: user) + + expect(graphql_data_at(:group_update, :errors)).not_to be_empty + expect(group.reload.step_up_auth_required_oauth_provider).to be_nil + end + + it 'works for subgroups' do + subgroup = create(:group, parent: group) + subgroup.add_owner(user) + + variables = { + full_path: subgroup.full_path, + step_up_auth_required_oauth_provider: 'openid_connect' + } + + mutation = graphql_mutation(:group_update, variables) do + <<~QL + group { + namespaceSettings { + stepUpAuthRequiredOauthProvider + } + } + QL + end + + expect { post_graphql_mutation(mutation, current_user: user) } + .to change { subgroup.reload.step_up_auth_required_oauth_provider } + .from(nil).to('openid_connect') + 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 argument' do + variables = { + full_path: group.full_path, + step_up_auth_required_oauth_provider: 'openid_connect' + } + + mutation = graphql_mutation(:group_update, variables) + + expect { post_graphql_mutation(mutation, current_user: user) } + .not_to change { group.reload.step_up_auth_required_oauth_provider } + end + end + end + end + + context 'when only updating step-up auth as non-admin' do + let(:variables) do + { + full_path: group.full_path, + step_up_auth_required_oauth_provider: 'openid_connect' + } + end + + before do + group.add_maintainer(user) + stub_omniauth_setting(enabled: true, providers: [ + GitlabSettings::Options.new( + name: 'openid_connect', + label: 'OpenID Connect', + step_up_auth: { + namespace: { + id_token: { + required: { + acr: 'gold' + } + } + } + } + ) + ]) + end + + it 'returns an error' do + post_graphql_mutation(mutation, current_user: user) + + expect(graphql_errors).not_to be_empty + expect(group.reload.step_up_auth_required_oauth_provider).to be_nil + end end end diff --git a/spec/requests/api/graphql/namespaces/general_settings_spec.rb b/spec/requests/api/graphql/namespaces/general_settings_spec.rb new file mode 100644 index 0000000000000000000000000000000000000000..a46e5d361b3114698f5df80ea4b6e2cbb60202fa --- /dev/null +++ b/spec/requests/api/graphql/namespaces/general_settings_spec.rb @@ -0,0 +1,132 @@ +# frozen_string_literal: true + +require 'spec_helper' + +RSpec.describe 'querying namespace settings', feature_category: :api do + include GraphqlHelpers + + let_it_be(:group) { create(:group) } + let_it_be(:subgroup) { create(:group, parent: group) } + let_it_be(:admin) { create(:admin) } + let_it_be(:user) { create(:user) } + + let(:omniauth_provider_config_oidc) do + GitlabSettings::Options.new( + name: 'openid_connect', + label: 'OpenID Connect', + step_up_auth: { + namespace: { + id_token: { + required: { + acr: 'gold' + } + } + } + } + ) + end + + let(:fields) do + <<~QUERY + namespaceSettings { + stepUpAuthRequiredOauthProvider + } + QUERY + end + + let(:query) do + graphql_query_for( + 'group', + { 'fullPath' => group.full_path }, + fields + ) + end + + before do + stub_omniauth_setting(enabled: true, providers: [omniauth_provider_config_oidc]) + allow(Devise).to receive(:omniauth_providers).and_return(['openid_connect']) + end + + context 'when user has admin permission' do + before_all do + group.add_owner(user) + end + + it 'returns general settings with step-up auth provider' do + # Ensure the user has owner permissions (in case previous tests changed it) + group.add_owner(user) + group.update!(step_up_auth_required_oauth_provider: 'openid_connect') + + post_graphql(query, current_user: user) + + expect(graphql_data_at(:group, :namespaceSettings, :stepUpAuthRequiredOauthProvider)) + .to eq('openid_connect') + end + + it 'returns nil when not configured' do + post_graphql(query, current_user: user) + + expect(graphql_data_at(:group, :namespaceSettings, :stepUpAuthRequiredOauthProvider)) + .to be_nil + end + + it 'works for subgroups' do + # Ensure the user has owner permissions for both group and subgroup + group.add_owner(user) + subgroup.add_owner(user) + subgroup.update!(step_up_auth_required_oauth_provider: 'openid_connect') + + subgroup_query = graphql_query_for( + 'group', + { 'fullPath' => subgroup.full_path }, + fields + ) + + post_graphql(subgroup_query, current_user: user) + + expect(graphql_data_at(:group, :namespaceSettings, :stepUpAuthRequiredOauthProvider)) + .to eq('openid_connect') + end + end + + context 'when user lacks admin permission' do + before_all do + group.add_developer(user) + end + + it 'returns nil for generalSettings' do + # Don't try to update the group - this test is about authorization, not the setting itself + # Just test that the GraphQL field returns nil when user doesn't have permission + post_graphql(query, current_user: user) + + expect(graphql_data_at(:group, :namespaceSettings)).to be_nil + end + end + + context 'when feature flag is disabled' do + let(:test_group) { create(:group) } + let(:test_user) { create(:user) } + + let(:test_query) do + graphql_query_for( + 'group', + { 'fullPath' => test_group.full_path }, + fields + ) + end + + before do + test_group.add_owner(test_user) + stub_feature_flags(omniauth_step_up_auth_for_namespace: false) + # Set the provider directly to namespace_settings to bypass validation when feature flag is off + test_group.namespace_settings.update_column(:step_up_auth_required_oauth_provider, 'openid_connect') + end + + it 'returns nil for stepUpAuthRequiredOauthProvider' do + post_graphql(test_query, current_user: test_user) + + expect(graphql_data_at(:group, :namespaceSettings, :stepUpAuthRequiredOauthProvider)) + .to be_nil + end + end +end