Commit fa7f145c authored by Drew Blessing's avatar Drew Blessing Committed by Drew Blessing

Allow SAML Group Sync to remove old memberships

In addition to adding new members, SAML Group Sync should also
remove existing memberships where the SAML response indicates the
user is no longer a member of the group in the IdP. Old memberships
will only be removed for groups with any group links.
parent 88cf7733
...@@ -9,4 +9,6 @@ class SamlGroupLink < ApplicationRecord ...@@ -9,4 +9,6 @@ class SamlGroupLink < ApplicationRecord
validates :saml_group_name, presence: true, uniqueness: { scope: [:group_id] }, length: { maximum: 255 } validates :saml_group_name, presence: true, uniqueness: { scope: [:group_id] }, length: { maximum: 255 }
scope :by_id_and_group_id, ->(id, group_id) { where(id: id, group_id: group_id) } scope :by_id_and_group_id, ->(id, group_id) { where(id: id, group_id: group_id) }
scope :by_group_id, ->(group_id) { where(group_id: group_id) }
scope :preload_group, -> { preload(group: :route) }
end end
...@@ -3,38 +3,139 @@ ...@@ -3,38 +3,139 @@
# #
# Usage example: # Usage example:
# #
# Groups::SyncService.new(nil, user, group_links: array_of_group_links).execute # Groups::SyncService.new(
# top_level_group, user,
# group_links: array_of_group_links,
# manage_group_ids: array_of_group_ids
# ).execute
# #
# Given group links must respond to `group_id` and `access_level`. # Given group links must respond to `group_id` and `access_level`.
# #
# This is a generic group sync service, reusable by many IdP-specific # This is a generic group sync service, reusable by many IdP-specific
# implementations. The worker (caller) is responsible for providing the # implementations. The worker (caller) is responsible for providing the
# specific group links, which this service then iterates over # specific group links, which this service then iterates over
# and adds users to respective groups. See `SamlGroupSyncWorker` for an # and adds/removes users from respective groups.
# example. #
# When `manage_group_ids` is present, users will only be removed from these
# groups if they should no longer be a member. When not present, users are
# removed from all groups where they should no longer be a member. This is
# useful when it's desired to only manage groups with group links and
# allow other groups to manage members manually.
#
# See `GroupSamlGroupSyncWorker` for an example.
# #
module Groups module Groups
class SyncService < Groups::BaseService class SyncService < Groups::BaseService
include Gitlab::Utils::StrongMemoize
extend Gitlab::Utils::Override
attr_reader :updated_membership
override :initialize
def initialize(group, user, params = {})
@updated_membership = {
added: 0,
updated: 0,
removed: 0
}
super
end
def execute def execute
group_links_by_group.each do |group_id, group_links| return unless group
remove_old_memberships
update_current_memberships
ServiceResponse.success(payload: updated_membership)
end
private
def remove_old_memberships
members_to_remove.each do |member|
Members::DestroyService.new(current_user).execute(member, skip_authorization: true)
next unless member.destroyed?
log_membership_update(
group_id: member.source_id,
action: :removed,
prior_access_level: member.access_level,
access_level: nil
)
end
end
def update_current_memberships
group_links_by_group.each do |group, group_links|
access_level = max_access_level(group_links) access_level = max_access_level(group_links)
Group.find_by_id(group_id)&.add_user(current_user, access_level) existing_member = existing_member_by_group(group)
next if correct_access_level?(existing_member, access_level) || group.last_owner?(current_user)
add_member(group, access_level, existing_member)
end end
end end
private def add_member(group, access_level, existing_member)
member = group.add_user(current_user, access_level)
return member unless member.persisted? && member.access_level == access_level
log_membership_update(
group_id: group.id,
action: (existing_member ? :updated : :added),
prior_access_level: existing_member&.access_level,
access_level: access_level
)
end
def correct_access_level?(member, access_level)
member && member.access_level == access_level
end
def members_to_remove
existing_members.select do |member|
group_id = member.source_id
!member_in_groups_to_be_updated?(group_id) && manage_group?(group_id)
end
end
def member_in_groups_to_be_updated?(group_id)
group_links_by_group.keys.map(&:id).include?(group_id)
end
def manage_group?(group_id)
params[:manage_group_ids].blank? || params[:manage_group_ids].include?(group_id)
end
def existing_member_by_group(group)
existing_members.find { |member| member.source_id == group.id }
end
def existing_members
strong_memoize(:existing_members) do
group.members_with_descendants.with_user(current_user).to_a
end
end
def group_links_by_group def group_links_by_group
params[:group_links].group_by(&:group_id) strong_memoize(:group_links_by_group) do
params[:group_links].group_by(&:group)
end
end end
def max_access_level(group_links) def max_access_level(group_links)
human_access_level = group_links.map(&:access_level) group_links.map(&:access_level_before_type_cast).max
human_access_level.map { |level| integer_access_level(level) }.max
end end
def integer_access_level(human_access_level) def log_membership_update(group_id:, action:, prior_access_level:, access_level:)
::Gitlab::Access.options_with_owner[human_access_level] @updated_membership[action] += 1
Gitlab::AppLogger.debug(message: "#{self.class.name} User: #{current_user.username} (#{current_user.id}), Action: #{action}, Group: #{group_id}, Prior Access: #{prior_access_level}, New Access: #{access_level}")
end end
end end
end end
...@@ -2,19 +2,25 @@ ...@@ -2,19 +2,25 @@
class GroupSamlGroupSyncWorker class GroupSamlGroupSyncWorker
include ApplicationWorker include ApplicationWorker
include Gitlab::Utils::StrongMemoize
feature_category :authentication_and_authorization feature_category :authentication_and_authorization
idempotent! idempotent!
loggable_arguments 2
attr_reader :top_level_group, :group_link_ids, :user
def perform(user_id, top_level_group_id, group_link_ids) def perform(user_id, top_level_group_id, group_link_ids)
top_level_group = Group.find_by_id(top_level_group_id) @top_level_group = Group.find_by_id(top_level_group_id)
user = User.find_by_id(user_id) @group_link_ids = group_link_ids
@user = User.find_by_id(user_id)
return unless user && feature_available?(top_level_group) return unless user && feature_available?(top_level_group) && groups_to_sync?
group_links = find_group_links(group_link_ids, top_level_group) response = sync_groups
Groups::SyncService.new(nil, user, group_links: group_links).execute log_extra_metadata_on_done(:stats, response.payload)
end end
private private
...@@ -23,7 +29,32 @@ class GroupSamlGroupSyncWorker ...@@ -23,7 +29,32 @@ class GroupSamlGroupSyncWorker
group && group.saml_group_sync_available? group && group.saml_group_sync_available?
end end
def find_group_links(group_link_ids, top_level_group) def groups_to_sync?
SamlGroupLink.by_id_and_group_id(group_link_ids, top_level_group.self_and_descendants.select(:id)) group_links.any? || group_ids_with_any_links.any?
end
def sync_groups
Groups::SyncService.new(
top_level_group, user,
group_links: group_links, manage_group_ids: group_ids_with_any_links
).execute
end
def group_links
strong_memoize(:group_links) do
SamlGroupLink.by_id_and_group_id(group_link_ids, group_ids_in_hierarchy).preload_group
end
end
# rubocop: disable CodeReuse/ActiveRecord
def group_ids_with_any_links
strong_memoize(:group_ids_with_any_links) do
SamlGroupLink.by_group_id(group_ids_in_hierarchy).pluck(:group_id).uniq
end
end
def group_ids_in_hierarchy
top_level_group.self_and_descendants.pluck(:id)
end end
# rubocop: enable CodeReuse/ActiveRecord
end end
...@@ -2,14 +2,13 @@ ...@@ -2,14 +2,13 @@
require 'spec_helper' require 'spec_helper'
RSpec.describe Groups::SyncService, '#execute' do RSpec.describe Groups::SyncService do
let(:user) { create(:user) } let(:user) { create(:user) }
describe '#execute' do describe '#execute' do
subject(:sync) { described_class.new(nil, user, group_links: group_links).execute }
let_it_be(:top_level_group) { create(:group) } let_it_be(:top_level_group) { create(:group) }
let_it_be(:group1) { create(:group, parent: top_level_group) } let_it_be(:group1) { create(:group, parent: top_level_group) }
let_it_be(:group2) { create(:group, parent: top_level_group) }
let_it_be(:group_links) do let_it_be(:group_links) do
[ [
...@@ -19,6 +18,15 @@ RSpec.describe Groups::SyncService, '#execute' do ...@@ -19,6 +18,15 @@ RSpec.describe Groups::SyncService, '#execute' do
] ]
end end
let_it_be(:manage_group_ids) { [top_level_group.id, group1.id, group2.id] }
subject(:sync) do
described_class.new(
top_level_group, user,
group_links: group_links, manage_group_ids: manage_group_ids
).execute
end
it 'adds two new group member records' do it 'adds two new group member records' do
expect { sync }.to change { GroupMember.count }.by(2) expect { sync }.to change { GroupMember.count }.by(2)
end end
...@@ -37,6 +45,14 @@ RSpec.describe Groups::SyncService, '#execute' do ...@@ -37,6 +45,14 @@ RSpec.describe Groups::SyncService, '#execute' do
.to eq(::Gitlab::Access::DEVELOPER) .to eq(::Gitlab::Access::DEVELOPER)
end end
it 'returns a success response' do
expect(sync.success?).to eq(true)
end
it 'returns sync stats as payload' do
expect(sync.payload).to include({ added: 2, removed: 0, updated: 0 })
end
context 'when the user is already a member' do context 'when the user is already a member' do
context 'with the correct access level' do context 'with the correct access level' do
before do before do
...@@ -53,9 +69,16 @@ RSpec.describe Groups::SyncService, '#execute' do ...@@ -53,9 +69,16 @@ RSpec.describe Groups::SyncService, '#execute' do
expect(group1.members.find_by(user_id: user.id).access_level) expect(group1.members.find_by(user_id: user.id).access_level)
.to eq(::Gitlab::Access::DEVELOPER) .to eq(::Gitlab::Access::DEVELOPER)
end end
it 'does not call Group find_by_id' do
expect(Group).not_to receive(:find_by_id).with(group1.id)
sync
end
end end
context 'with a different access level' do context 'with a different access level' do
context 'when the user is not the last owner' do
before do before do
top_level_group.add_user(user, ::Gitlab::Access::MAINTAINER) top_level_group.add_user(user, ::Gitlab::Access::MAINTAINER)
end end
...@@ -70,6 +93,91 @@ RSpec.describe Groups::SyncService, '#execute' do ...@@ -70,6 +93,91 @@ RSpec.describe Groups::SyncService, '#execute' do
expect(top_level_group.members.find_by(user_id: user.id).access_level) expect(top_level_group.members.find_by(user_id: user.id).access_level)
.to eq(::Gitlab::Access::GUEST) .to eq(::Gitlab::Access::GUEST)
end end
it 'returns sync stats as payload' do
expect(sync.payload).to include({ added: 1, removed: 0, updated: 1 })
end
end
context 'when the user is the last owner' do
before do
top_level_group.add_user(user, ::Gitlab::Access::OWNER)
end
it 'does not change the group member count' do
expect { sync }.not_to change { top_level_group.members.count }
end
it 'does not update the access_level' do
sync
expect(top_level_group.members.find_by(user_id: user.id).access_level)
.to eq(::Gitlab::Access::OWNER)
end
it 'returns sync stats as payload' do
expect(sync.payload).to include({ added: 0, removed: 0, updated: 0 })
end
end
end
context 'but should no longer be a member' do
shared_examples 'removes the member' do
before do
group2.add_user(user, ::Gitlab::Access::DEVELOPER)
end
it 'reduces group member count by 1' do
expect { sync }.to change { group2.members.count }.by(-1)
end
it 'removes the matching user' do
sync
expect(group2.members).not_to include(user)
end
it 'returns sync stats as payload' do
expect(sync.payload).to include({ added: 2, removed: 1, updated: 0 })
end
end
context 'when manage_group_ids is present' do
let_it_be(:manage_group_ids) { [group2.id] }
include_examples 'removes the member'
end
context 'when manage_group_ids is empty' do
let_it_be(:manage_group_ids) { [] }
include_examples 'removes the member'
end
context 'when manage_groups_ids is nil' do
let_it_be(:manage_group_ids) { nil }
include_examples 'removes the member'
end
end
context 'in a group that is not managed' do
let_it_be(:manage_group_ids) { [top_level_group.id, group1.id] }
before do
group2.add_user(user, ::Gitlab::Access::REPORTER)
end
it 'does not change the group member count' do
expect { sync }.not_to change { group2.members.count }
end
it 'retains the correct access level' do
sync
expect(group2.members.find_by(user_id: user.id).access_level)
.to eq(::Gitlab::Access::REPORTER)
end
end end
end end
end end
......
...@@ -12,6 +12,8 @@ RSpec.describe GroupSamlGroupSyncWorker do ...@@ -12,6 +12,8 @@ RSpec.describe GroupSamlGroupSyncWorker do
let_it_be(:group) { create(:group, parent: top_level_group) } let_it_be(:group) { create(:group, parent: top_level_group) }
let_it_be(:group_link) { create(:saml_group_link, group: group) } let_it_be(:group_link) { create(:saml_group_link, group: group) }
let(:worker) { described_class.new }
context 'when the group does not have group_saml_group_sync feature licensed' do context 'when the group does not have group_saml_group_sync feature licensed' do
before do before do
create(:saml_provider, group: top_level_group, enabled: true) create(:saml_provider, group: top_level_group, enabled: true)
...@@ -44,7 +46,8 @@ RSpec.describe GroupSamlGroupSyncWorker do ...@@ -44,7 +46,8 @@ RSpec.describe GroupSamlGroupSyncWorker do
end end
it 'calls the sync service with the group links' do it 'calls the sync service with the group links' do
stub_sync_service_expectation([top_level_group_link, group_link]) expect_sync_service_call(group_links: [top_level_group_link, group_link])
expect_metadata_logging_call({ added: 2, updated: 0, removed: 0 })
perform([top_level_group_link.id, group_link.id]) perform([top_level_group_link.id, group_link.id])
end end
...@@ -55,24 +58,53 @@ RSpec.describe GroupSamlGroupSyncWorker do ...@@ -55,24 +58,53 @@ RSpec.describe GroupSamlGroupSyncWorker do
described_class.new.perform(non_existing_record_id, top_level_group.id, [group_link]) described_class.new.perform(non_existing_record_id, top_level_group.id, [group_link])
end end
it 'includes groups with links in manage_group_ids' do
expect_sync_service_call(
group_links: [top_level_group_link],
manage_group_ids: [top_level_group.id, group.id]
)
perform([top_level_group_link.id])
end
context 'when a group link falls outside the top-level group' do context 'when a group link falls outside the top-level group' do
let(:outside_group_link) { create(:saml_group_link, group: create(:group)) } let(:outside_group_link) { create(:saml_group_link, group: create(:group)) }
it 'drops group links outside the top level group' do it 'drops group links outside the top level group' do
stub_sync_service_expectation([group_link]) expect_sync_service_call(group_links: [group_link])
expect_metadata_logging_call({ added: 1, updated: 0, removed: 0 })
perform([outside_group_link.id, group_link]) perform([outside_group_link.id, group_link])
end end
end end
context 'with a group in the hierarchy that has no group links' do
let(:group_without_links) { create(:group, parent: group) }
it 'is not included in manage_group_ids' do
expect_sync_service_call(group_links: [top_level_group_link, group_link])
expect_metadata_logging_call({ added: 2, updated: 0, removed: 0 })
perform([top_level_group_link.id, group_link.id])
end
end
end end
end end
def stub_sync_service_expectation(group_links) def expect_sync_service_call(group_links:, manage_group_ids: nil)
expect(Groups::SyncService).to receive(:new).with(nil, user, group_links: group_links).and_call_original manage_group_ids = [top_level_group.id, group.id] if manage_group_ids.nil?
expect(Groups::SyncService).to receive(:new).with(
top_level_group, user, group_links: group_links, manage_group_ids: manage_group_ids
).and_call_original
end
def expect_metadata_logging_call(stats)
expect(worker).to receive(:log_extra_metadata_on_done).with(:stats, stats)
end end
def perform(group_links) def perform(group_links)
described_class.new.perform(user.id, top_level_group.id, group_links) worker.perform(user.id, top_level_group.id, group_links)
end end
end end
end end
Markdown is supported
0%
or
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment