Commit 7e2674d1 authored by Drew Blessing's avatar Drew Blessing

Ranged members

parent 7a67ed66
...@@ -7,6 +7,7 @@ v 8.12.0 (Unreleased) ...@@ -7,6 +7,7 @@ v 8.12.0 (Unreleased)
- Add 'Sync now' to group members page !704 - Add 'Sync now' to group members page !704
- [ES] Instrument other Gitlab::Elastic classes - [ES] Instrument other Gitlab::Elastic classes
- [ES] Fix: Elasticsearch does not find partial matches in project names - [ES] Fix: Elasticsearch does not find partial matches in project names
- Faster Active Directory group membership resolution !719
- [ES] Global code search - [ES] Global code search
- [ES] Improve logging - [ES] Improve logging
- Fix projects with remote mirrors asynchronously destruction - Fix projects with remote mirrors asynchronously destruction
......
...@@ -31,13 +31,26 @@ module EE ...@@ -31,13 +31,26 @@ module EE
groups(*args).first groups(*args).first
end end
def dns_for_filter(filter) def group_members_in_range(dn, range_start)
ldap_search( ldap_search(
base: config.base, base: dn,
filter: filter, scope: Net::LDAP::SearchScope_BaseObject,
scope: Net::LDAP::SearchScope_WholeSubtree, attributes: ["member;range=#{range_start}-*"],
attributes: %w{dn} ).first
).map(&:dn) end
def nested_groups(parent_dn)
options = {
base: config.group_base,
filter: Net::LDAP::Filter.join(
Net::LDAP::Filter.eq('objectClass', 'group'),
Net::LDAP::Filter.eq('memberOf', parent_dn)
)
}
ldap_search(options).map do |entry|
LDAP::Group.new(entry, self)
end
end end
end end
end end
......
...@@ -39,14 +39,15 @@ module EE ...@@ -39,14 +39,15 @@ module EE
entry.memberuid entry.memberuid
end end
def member_dns def dn
entry.dn
end
def member_dns(nested_groups_to_skip = [])
dns = [] dns = []
# There's an edge-case with AD where sometimes a recursive search if active_directory? && adapter
# doesn't return all users at the top-level. Concat recursive results dns.concat(active_directory_members(entry, nested_groups_to_skip))
# with regular results to be safe. See gitlab-ee#484
if active_directory?
dns = adapter.dns_for_filter(active_directory_recursive_memberof_filter)
end end
if (entry.respond_to? :member) && (entry.respond_to? :submember) if (entry.respond_to? :member) && (entry.respond_to? :submember)
...@@ -60,20 +61,91 @@ module EE ...@@ -60,20 +61,91 @@ module EE
else else
Rails.logger.warn("Could not find member DNs for LDAP group #{entry.inspect}") Rails.logger.warn("Could not find member DNs for LDAP group #{entry.inspect}")
end end
dns.uniq dns.uniq
end end
private private
# We use the ActiveDirectory LDAP_MATCHING_RULE_IN_CHAIN matching rule; see
# http://msdn.microsoft.com/en-us/library/aa746475%28VS.85%29.aspx#code-snippet-5
def active_directory_recursive_memberof_filter
Net::LDAP::Filter.ex("memberOf:1.2.840.113556.1.4.1941", entry.dn)
end
def entry def entry
@entry @entry
end end
# Active Directory range member methods
def has_member_range?(entry)
member_range_attribute(entry).present?
end
def member_range_attribute(entry)
entry.attribute_names.find { |a| a.to_s.start_with?("member;range=")}.to_s
end
def active_directory_members(entry, nested_groups_to_skip)
require 'net/ldap/dn'
members = []
# Retrieve all member pages/ranges
members.concat(ranged_members(entry)) if has_member_range?(entry)
# Process nested group members
members.concat(nested_members(nested_groups_to_skip))
# Clean dns of groups and users outside the base
members.reject! { |dn| nested_groups_to_skip.include?(dn) }
base = Net::LDAP::DN.new(adapter.config.base.downcase).to_a
members.select! { |dn| Net::LDAP::DN.new(dn.downcase).to_a.last(base.length) == base }
members
end
# AD requires use of range retrieval for groups with more than 1500 members
# cf. https://msdn.microsoft.com/en-us/library/aa367017(v=vs.85).aspx
def ranged_members(entry)
members = []
# Concatenate the members in the current range
members.concat(entry[member_range_attribute(entry)])
# Recursively concatenate members until end of ranges
if has_more_member_ranges?(entry)
next_entry = adapter.group_members_in_range(dn, next_member_range_start(entry))
members.concat(ranged_members(next_entry))
end
members
end
# Process any AD nested groups. Use a manual process because
# the AD recursive member of filter is too slow and uses too
# much CPU on the AD server.
def nested_members(nested_groups_to_skip)
# Ignore this group if we see it again in a nested group.
# Prevents infinite loops.
nested_groups_to_skip << dn
members = []
nested_groups = adapter.nested_groups(dn)
nested_groups.each do |nested_group|
next if nested_groups_to_skip.include?(nested_group.dn)
members.concat(nested_group.member_dns(nested_groups_to_skip))
end
members
end
def has_more_member_ranges?(entry)
next_member_range_start(entry).present?
end
def next_member_range_start(entry)
match = member_range_attribute(entry).match /^member;range=\d+-(\d+|\*)$/
match[1].to_i + 1 if match.present? && match[1] != '*'
end
end end
end end
end end
......
...@@ -3,47 +3,125 @@ require 'spec_helper' ...@@ -3,47 +3,125 @@ require 'spec_helper'
describe EE::Gitlab::LDAP::Group, lib: true do describe EE::Gitlab::LDAP::Group, lib: true do
include LdapHelpers include LdapHelpers
let(:adapter) { ldap_adapter } before do
stub_ldap_config(active_directory: true)
end
describe '#member_dns' do describe '#member_dns' do
def ldif let(:adapter) { ldap_adapter }
Net::LDAP::Entry.from_single_ldif_string(
<<-EOS.strip_heredoc it 'resolves the correct member_dns when member has a range' do
dn: cn=ldap_group1,ou=groups,dc=example,dc=com group_entry_page1 = ldap_group_entry_with_member_range(
cn: ldap_group1 [user_dn('user1'), user_dn('user2'), user_dn('user3')],
description: LDAP Group 1 range_start: '0',
member: uid=user1,ou=users,dc=example,dc=com range_end: '2'
member: uid=user2,ou=users,dc=example,dc=com
member: uid=user3,ou=users,dc=example,dc=com
objectclass: top
objectclass: groupOfNames
EOS
) )
end group_entry_page2 = ldap_group_entry_with_member_range(
[user_dn('user4'), user_dn('user5'), user_dn('user6')],
range_start: '3',
range_end: '*'
)
group = EE::Gitlab::LDAP::Group.new(group_entry_page1, adapter)
stub_ldap_adapter_group_members_in_range(group_entry_page2, adapter, range_start: '3')
stub_ldap_adapter_nested_groups(group.dn, [], adapter)
let(:group) { described_class.new(ldif, adapter) } expect(group.member_dns).to match_array(
let(:recursive_dns) do %w(
%w( uid=user1,ou=users,dc=example,dc=com
uid=user3,ou=users,dc=example,dc=com uid=user2,ou=users,dc=example,dc=com
uid=user4,ou=users,dc=example,dc=com uid=user3,ou=users,dc=example,dc=com
uid=user5,ou=users,dc=example,dc=com uid=user4,ou=users,dc=example,dc=com
uid=user5,ou=users,dc=example,dc=com
uid=user6,ou=users,dc=example,dc=com
)
) )
end end
it 'concatenates recursive and regular results and returns uniq' do context 'when there are nested groups' do
allow(group).to receive(:active_directory?).and_return(true) let(:group1_entry) do
allow(adapter).to receive(:dns_for_filter).and_return(recursive_dns) ldap_group_entry(
[user_dn('user1'), user_dn('user2')],
objectclass: 'group',
member_attr: 'member'
)
end
let(:group2_entry) do
ldap_group_entry(
[user_dn('user3'), user_dn('user4')],
cn: 'ldap_group2',
objectclass: 'group',
member_attr: 'member',
member_of: group1_entry.dn
)
end
let(:group) { EE::Gitlab::LDAP::Group.new(group1_entry, adapter) }
it 'resolves the correct member_dns when there are nested groups' do
group3_entry = ldap_group_entry(
[user_dn('user5'), user_dn('user6')],
cn: 'ldap_group3',
objectclass: 'group',
member_attr: 'member',
member_of: group1_entry.dn
)
nested_groups = [group2_entry, group3_entry]
stub_ldap_adapter_nested_groups(group.dn, nested_groups, adapter)
stub_ldap_adapter_nested_groups(group2_entry.dn, [], adapter)
stub_ldap_adapter_nested_groups(group3_entry.dn, [], adapter)
expect(group.member_dns) expect(group.member_dns).to match_array(
.to match_array(
%w( %w(
uid=user1,ou=users,dc=example,dc=com uid=user1,ou=users,dc=example,dc=com
uid=user2,ou=users,dc=example,dc=com uid=user2,ou=users,dc=example,dc=com
uid=user3,ou=users,dc=example,dc=com uid=user3,ou=users,dc=example,dc=com
uid=user4,ou=users,dc=example,dc=com uid=user4,ou=users,dc=example,dc=com
uid=user5,ou=users,dc=example,dc=com uid=user5,ou=users,dc=example,dc=com
uid=user6,ou=users,dc=example,dc=com
) )
) )
end
it 'skips duplicate nested groups' do
group3_entry = ldap_group_entry(
[user_dn('user5'), user_dn('user6')],
cn: 'ldap_group3',
objectclass: 'group',
member_attr: 'member',
member_of: [group1_entry.dn, group2_entry.dn]
)
nested_groups = [group2_entry, group3_entry]
stub_ldap_adapter_nested_groups(group.dn, nested_groups, adapter)
stub_ldap_adapter_nested_groups(group2_entry.dn, [group3_entry], adapter)
stub_ldap_adapter_nested_groups(group3_entry.dn, [], adapter)
expect(adapter).to receive(:nested_groups).with(group3_entry.dn).once
group.member_dns
end
it 'does not include group dns or users outside of the base' do
# Spaces in the 3rd DN below are intentional to ensure we're sanitizing
# DNs before comparing and not just doing a string compare.
group3_entry = ldap_group_entry(
[
'cn=ldap_group2,ou=groups,dc=example,dc=com',
'uid=foo,ou=users,dc=other,dc=com',
'uid=bar,ou=users,dc=example , dc=com'
],
cn: 'ldap_group3',
objectclass: 'group',
member_attr: 'member',
member_of: group1_entry.dn
)
nested_groups = [group2_entry, group3_entry]
stub_ldap_adapter_nested_groups(group.dn, nested_groups, adapter)
stub_ldap_adapter_nested_groups(group2_entry.dn, [], adapter)
stub_ldap_adapter_nested_groups(group3_entry.dn, [], adapter)
expect(group.member_dns).not_to include('cn=ldap_group1,ou=groups,dc=example,dc=com')
expect(group.member_dns).not_to include('uid=foo,ou=users,dc=other,dc=com')
expect(group.member_dns).to include('uid=bar,ou=users,dc=example , dc=com')
end
end end
end end
end end
...@@ -46,7 +46,8 @@ module EE ...@@ -46,7 +46,8 @@ module EE
members, members,
cn: 'ldap_group1', cn: 'ldap_group1',
objectclass: 'groupOfNames', objectclass: 'groupOfNames',
member_attr: 'uniqueMember' member_attr: 'uniqueMember',
member_of: nil
) )
entry = Net::LDAP::Entry.from_single_ldif_string(<<-EOS.strip_heredoc) entry = Net::LDAP::Entry.from_single_ldif_string(<<-EOS.strip_heredoc)
dn: cn=#{cn},ou=groups,dc=example,dc=com dn: cn=#{cn},ou=groups,dc=example,dc=com
...@@ -56,9 +57,70 @@ module EE ...@@ -56,9 +57,70 @@ module EE
objectclass: #{objectclass} objectclass: #{objectclass}
EOS EOS
entry['memberOf'] = member_of if member_of
members = [members].flatten members = [members].flatten
entry[member_attr] = members if members.any? entry[member_attr] = members if members.any?
entry entry
end end
# To simulate Active Directory ranged member retrieval. Create an LDAP
# group entry with any number of members in a given range. A '*' signifies
# the end of the 'pages' has been reached.
#
# Example:
# ldap_group_entry_with_member_range(
# [ 'user1', 'user2' ],
# cn: 'my_group',
# range_start: '0',
# range_end: '*'
# )
def ldap_group_entry_with_member_range(
members_in_range,
cn: 'ldap_group1',
range_start: '0',
range_end: '*'
)
entry = Net::LDAP::Entry.from_single_ldif_string(<<-EOS.strip_heredoc)
dn: cn=#{cn},ou=groups,dc=example,dc=com
cn: #{cn}
description: LDAP Group #{cn}
EOS
members_in_range = [members_in_range].flatten
entry["member;range=#{range_start}-#{range_end}"] = members_in_range
entry
end
# Stub Active Directory range member retrieval.
#
# Example:
# adapter = ::Gitlab::LDAP::Adapter.new('ldapmain', double(:ldap))
# group_entry_page1 = ldap_group_entry_with_member_range(
# [user_dn('user1'), user_dn('user2'), user_dn('user3')],
# range_start: '0',
# range_end: '2'
# )
# group_entry_page2 = ldap_group_entry_with_member_range(
# [user_dn('user4'), user_dn('user5'), user_dn('user6')],
# range_start: '3',
# range_end: '*'
# )
# group = EE::Gitlab::LDAP::Group.new(group_entry_page1, adapter)
#
# stub_ldap_adapter_group_members_in_range(group_entry_page2, adapter, range_start: '3')
def stub_ldap_adapter_group_members_in_range(
entry,
adapter = ldap_adapter,
range_start: '0'
)
allow(adapter).to receive(:group_members_in_range)
.with(entry.dn, range_start.to_i).and_return(entry)
end
def stub_ldap_adapter_nested_groups(parent_dn, entries = [], adapter = ldap_adapter)
groups = entries.map { |entry| EE::Gitlab::LDAP::Group.new(entry, adapter) }
allow(adapter).to receive(:nested_groups).with(parent_dn).and_return(groups)
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