diff --git a/ee/app/models/saml_provider.rb b/ee/app/models/saml_provider.rb index fe0b1083df727e48eb945d5c0b0368f17ef3b91d..fa13d356f5165e57b607c8767ca7e69e2a197e4d 100644 --- a/ee/app/models/saml_provider.rb +++ b/ee/app/models/saml_provider.rb @@ -41,6 +41,13 @@ def prohibited_outer_forks? enforced_group_managed_accounts? && super end + def last_linked_owner?(user) + return false unless group.owned_by?(user) + return false unless identities.for_user(user).exists? + + identities.for_user(group.owners).count == 1 + end + class DefaultOptions include Gitlab::Routing diff --git a/ee/app/policies/ee/identity_provider_policy.rb b/ee/app/policies/ee/identity_provider_policy.rb index ae9c173abb88644faec2b6c55079e7e24b7a9942..ceb230105c09f4b31c9ad37a71db15a958690ffd 100644 --- a/ee/app/policies/ee/identity_provider_policy.rb +++ b/ee/app/policies/ee/identity_provider_policy.rb @@ -8,7 +8,25 @@ module IdentityProviderPolicy desc "User account is managed by group SAML" condition(:group_managed_account, scope: :user) { @user.group_managed_account? } + desc "Group Managed Accounts is enforced" + condition(:managed_group, scope: :subject) { @subject.is_a?(SamlProvider) && @subject.enforced_group_managed_accounts? } + + desc "No other Group owners have SSO for this SAML provider" + condition(:last_group_saml_owner) do + @subject.is_a?(SamlProvider) && @subject.last_linked_owner?(@user) + end + rule { group_managed_account }.prevent_all + + # User is last SSO owner of a managed group + # + # Owners without SSO won't have access, this ensures + # that we don't remove the last owner with access + # + # Unlike plain SSO Enforcment, it won't be possible to re-join + # with SSO if the owner leaves, as they will need to create a + # new account as a guest with a different email. + rule { managed_group && last_group_saml_owner }.prevent(:unlink) end end end diff --git a/ee/changelogs/unreleased/jej-prevent-last-gma-owner-with-sso-unlinking.yml b/ee/changelogs/unreleased/jej-prevent-last-gma-owner-with-sso-unlinking.yml new file mode 100644 index 0000000000000000000000000000000000000000..c8501924831ef2829ea0f3b86ea708a969de1c99 --- /dev/null +++ b/ee/changelogs/unreleased/jej-prevent-last-gma-owner-with-sso-unlinking.yml @@ -0,0 +1,5 @@ +--- +title: Prevent last Group Managed Account owner with access from accidental unlinking +merge_request: 32473 +author: +type: fixed diff --git a/ee/spec/factories/group_saml_identities.rb b/ee/spec/factories/group_saml_identities.rb index 10b7d0abc3962b683df941f32263c14855c8eb6c..5062e6387a4a5511227511401e422ed89f45e3be 100644 --- a/ee/spec/factories/group_saml_identities.rb +++ b/ee/spec/factories/group_saml_identities.rb @@ -6,4 +6,10 @@ saml_provider user end + + trait :group_owner do + after(:create) do |identity, evaluator| + identity.saml_provider.group.add_owner(identity.user) + end + end end diff --git a/ee/spec/models/saml_provider_spec.rb b/ee/spec/models/saml_provider_spec.rb index ad250dd565316139b1404be1f605635b842bfadd..43cd0b467822b70970c80b1fabdccaf78821e823 100644 --- a/ee/spec/models/saml_provider_spec.rb +++ b/ee/spec/models/saml_provider_spec.rb @@ -213,4 +213,46 @@ end end end + + describe '#last_linked_owner?' do + let_it_be(:user) { create(:user) } + + context 'for a non-owner' do + it { is_expected.not_to be_last_linked_owner(user) } + end + + context 'for a group owner' do + before do + group.add_owner(user) + end + + context 'with saml linked' do + before do + create(:group_saml_identity, user: user, saml_provider: subject) + end + + it { is_expected.to be_last_linked_owner(user) } + + context 'another owner has SSO linked' do + before do + create(:group_saml_identity, :group_owner, saml_provider: subject) + end + + it { is_expected.not_to be_last_linked_owner(user) } + end + end + + context 'without saml linked' do + it { is_expected.not_to be_last_linked_owner(user) } + + context 'another owner has SSO linked' do + before do + create(:group_saml_identity, :group_owner, saml_provider: subject) + end + + it { is_expected.not_to be_last_linked_owner(user) } + end + end + end + end end diff --git a/ee/spec/policies/identity_provider_policy_spec.rb b/ee/spec/policies/identity_provider_policy_spec.rb index a4ecdc6c1ff9c9244f1b0e705faf1bcafcb8d1aa..540ef5b6c011081c047125e3bc04de002b59ec35 100644 --- a/ee/spec/policies/identity_provider_policy_spec.rb +++ b/ee/spec/policies/identity_provider_policy_spec.rb @@ -10,6 +10,49 @@ it { is_expected.not_to be_allowed(:link) } it { is_expected.not_to be_allowed(:unlink) } + + context 'owner is not yet group managed' do + let_it_be(:identity) { create(:group_saml_identity) } + let_it_be(:saml_provider) { identity.saml_provider } + let_it_be(:group) { saml_provider.group } + let_it_be(:user) { identity.user } + + subject(:policy) { described_class.new(user, saml_provider) } + + before do + group.add_owner(user) + end + + context 'no other owners exist' do + it { is_expected.not_to be_allowed(:unlink) } + end + + context 'another group owner exists' do + let_it_be(:second_owner) { create(:user) } + + before do + group.add_owner(second_owner) + end + + context 'without sso linked' do + it { is_expected.not_to be_allowed(:unlink) } + end + + context 'with sso linked' do + before do + create(:group_saml_identity, saml_provider: saml_provider, user: second_owner) + end + + it { is_expected.to be_allowed(:unlink) } + end + + context 'managed by the group' do + let(:second_owner) { create(:user, :group_managed, managing_group: group) } + + it { is_expected.to be_allowed(:unlink) } + end + end + end end end end