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