From 0dd969d6386bfd4f9889d39acb193306a344b6cf Mon Sep 17 00:00:00 2001 From: Matt Todd Date: Mon, 24 Nov 2014 18:29:40 -0800 Subject: [PATCH 01/10] Add WIP recursive member search strategy --- lib/github/ldap.rb | 1 + lib/github/ldap/members.rb | 20 ++++++ lib/github/ldap/members/recursive.rb | 94 ++++++++++++++++++++++++++++ test/members/recursive_test.rb | 24 +++++++ 4 files changed, 139 insertions(+) create mode 100644 lib/github/ldap/members.rb create mode 100644 lib/github/ldap/members/recursive.rb create mode 100644 test/members/recursive_test.rb diff --git a/lib/github/ldap.rb b/lib/github/ldap.rb index 3258ac4..43c3f3b 100644 --- a/lib/github/ldap.rb +++ b/lib/github/ldap.rb @@ -9,6 +9,7 @@ class Ldap require 'github/ldap/virtual_group' require 'github/ldap/virtual_attributes' require 'github/ldap/instrumentation' + require 'github/ldap/members' require 'github/ldap/membership_validators' include Instrumentation diff --git a/lib/github/ldap/members.rb b/lib/github/ldap/members.rb new file mode 100644 index 0000000..65a2539 --- /dev/null +++ b/lib/github/ldap/members.rb @@ -0,0 +1,20 @@ +require 'github/ldap/members/recursive' + +module GitHub + class Ldap + # Provides various strategies for member lookup. + # + # For example: + # + # group = domain.groups(%w(Engineering)).first + # strategy = GitHub::Ldap::Members::Recursive.new(ldap) + # strategy.perform(group) #=> [#] + # + module Members + # Internal: Mapping of strategy name to class. + STRATEGIES = { + :recursive => GitHub::Ldap::Members::Recursive + } + end + end +end diff --git a/lib/github/ldap/members/recursive.rb b/lib/github/ldap/members/recursive.rb new file mode 100644 index 0000000..7de20c9 --- /dev/null +++ b/lib/github/ldap/members/recursive.rb @@ -0,0 +1,94 @@ +module GitHub + class Ldap + module Members + # Look up group members recursively. + # + # In this case, we're returning User Net::LDAP::Entry objects, not entries + # for LDAP Groups. + # + # This results in a maximum of `depth` queries (per domain) to look up + # members of a group and its subgroups. + class Recursive + include Filter + + DEFAULT_MAX_DEPTH = 9 + ATTRS = %w(dn cn) + + # Internal: The GitHub::Ldap object to search domains with. + attr_reader :ldap + + # Public: Instantiate new search strategy. + # + # - ldap: GitHub::Ldap object + # - options: Hash of options + def initialize(ldap, options = {}) + @ldap = ldap + @options = options + end + + # Internal: Domains to search through. + # + # Returns an Array of GitHub::Ldap::Domain objects. + def domains + @domains ||= ldap.search_domains.map { |base| ldap.domain(base) } + end + private :domains + + # Public: Performs search for group members, including members of + # subgroups recursively. + # + # Returns Array of Net::LDAP::Entry objects. + def perform(group, depth = DEFAULT_MAX_DEPTH) + members = Hash.new + + member_dns = group["member"] + + domains.each do |domain| + # find members + entries = domain.search(filter: membership_filter(member_dns), attributes: ATTRS) + + next if entries.empty? + + return entries + end + + [] + end + + # Internal: Construct a filter to find groups this entry is a direct + # member of. + # + # Overloads the included `GitHub::Ldap::Filters#member_filter` method + # to inject `posixGroup` handling. + # + # Returns a Net::LDAP::Filter object. + def member_filter(entry_or_uid, uid = ldap.uid) + filter = super(entry_or_uid) + + if ldap.posix_support_enabled? + if posix_filter = posix_member_filter(entry_or_uid, uid) + filter |= posix_filter + end + end + + filter + end + + # Internal: Construct a filter to find groups whose members are the + # Array of String group DNs passed in. + # + # Returns a String filter. + def membership_filter(groups) + groups.map { |entry| member_filter(entry, :cn) }.reduce(:|) + end + + # Internal: the group DNs to check against. + # + # Returns an Array of String DNs. + def group_dns + @group_dns ||= groups.map(&:dn) + end + end + end + end +end diff --git a/test/members/recursive_test.rb b/test/members/recursive_test.rb new file mode 100644 index 0000000..ddcf866 --- /dev/null +++ b/test/members/recursive_test.rb @@ -0,0 +1,24 @@ +require_relative '../test_helper' + +class GitHubLdapRecursiveMembersTest < GitHub::Ldap::Test + def setup + @ldap = GitHub::Ldap.new(options.merge(search_domains: %w(dc=github,dc=com))) + @domain = @ldap.domain("dc=github,dc=com") + @entry = @domain.user?('user1') + @strategy = GitHub::Ldap::Members::Recursive.new(@ldap) + end + + def find_group(cn) + @domain.groups([cn]).first + end + + def test_finds_group_members + members = @strategy.perform(find_group("nested-group1")).map(&:dn) + assert_includes members, @entry.dn + end + + def test_finds_nested_group_members + members = @strategy.perform(find_group("n-depth-nested-group1")).map(&:dn) + assert_includes members, @entry.dn + end +end From 3892f4e75011a3ba4b78f3d3cd10816c090f0489 Mon Sep 17 00:00:00 2001 From: Matt Todd Date: Mon, 24 Nov 2014 19:47:18 -0800 Subject: [PATCH 02/10] Implement recursive group member search --- lib/github/ldap/domain.rb | 7 ++- lib/github/ldap/members/recursive.rb | 87 +++++++++++++++------------- 2 files changed, 53 insertions(+), 41 deletions(-) diff --git a/lib/github/ldap/domain.rb b/lib/github/ldap/domain.rb index aa2066b..8fd904f 100644 --- a/lib/github/ldap/domain.rb +++ b/lib/github/ldap/domain.rb @@ -163,8 +163,11 @@ def search(options, &block) # Get the entry for this domain. # # Returns a Net::LDAP::Entry - def bind - search(size: 1, scope: Net::LDAP::SearchScope_BaseObject).first + def bind(options = {}) + options[:size] = 1 + options[:scope] = Net::LDAP::SearchScope_BaseObject + options[:attributes] ||= [] + search(options).first end end end diff --git a/lib/github/ldap/members/recursive.rb b/lib/github/ldap/members/recursive.rb index 7de20c9..15183c8 100644 --- a/lib/github/ldap/members/recursive.rb +++ b/lib/github/ldap/members/recursive.rb @@ -12,11 +12,14 @@ class Recursive include Filter DEFAULT_MAX_DEPTH = 9 - ATTRS = %w(dn cn) + ATTRS = %w(dn cn member) # Internal: The GitHub::Ldap object to search domains with. attr_reader :ldap + # Internal: The maximum depth to search for members. + attr_reader :depth + # Public: Instantiate new search strategy. # # - ldap: GitHub::Ldap object @@ -24,6 +27,7 @@ class Recursive def initialize(ldap, options = {}) @ldap = ldap @options = options + @depth = options[:depth] || DEFAULT_MAX_DEPTH end # Internal: Domains to search through. @@ -34,59 +38,64 @@ def domains end private :domains - # Public: Performs search for group members, including members of - # subgroups recursively. + # Public: Performs search for group members, including groups and + # members of subgroups recursively. # # Returns Array of Net::LDAP::Entry objects. - def perform(group, depth = DEFAULT_MAX_DEPTH) - members = Hash.new - - member_dns = group["member"] + def perform(group) + found = Hash.new - domains.each do |domain| - # find members - entries = domain.search(filter: membership_filter(member_dns), attributes: ATTRS) + members = group["member"] + return [] if members.empty? - next if entries.empty? + # find members (N queries) + entries = entries_by_dn(members) + return [] if entries.empty? - return entries + # track found entries + entries.each do |entry| + found[entry.dn] = entry end - [] - end + # descend to `depth` levels, at most + depth.times do |n| + # find every (new, unique) member entry + depth_subentries = entries.each_with_object([]) do |entry, depth_entries| + submembers = entry["member"] - # Internal: Construct a filter to find groups this entry is a direct - # member of. - # - # Overloads the included `GitHub::Ldap::Filters#member_filter` method - # to inject `posixGroup` handling. - # - # Returns a Net::LDAP::Filter object. - def member_filter(entry_or_uid, uid = ldap.uid) - filter = super(entry_or_uid) + # skip any members we've already found + submembers.reject! { |dn| found.key?(dn) } + + next if submembers.empty? + + # find members of subgroup, including subgroups (N queries) + subentries = entries_by_dn(submembers) - if ldap.posix_support_enabled? - if posix_filter = posix_member_filter(entry_or_uid, uid) - filter |= posix_filter + # track found subentries + subentries.each { |entry| found[entry.dn] = entry } + + # collect all entries for this depth + depth_entries.concat subentries end - end - filter - end + # stop if there are no more subgroups to search + break if depth_subentries.empty? - # Internal: Construct a filter to find groups whose members are the - # Array of String group DNs passed in. - # - # Returns a String filter. - def membership_filter(groups) - groups.map { |entry| member_filter(entry, :cn) }.reduce(:|) + # go one level deeper + entries = depth_subentries + end + + # return all found entries + found.values end - # Internal: the group DNs to check against. + # Internal: Bind a list of DNs to their respective entries. # - # Returns an Array of String DNs. - def group_dns - @group_dns ||= groups.map(&:dn) + # Returns an Array of Net::LDAP::Entry objects. + def entries_by_dn(members) + members.map do |dn| + ldap.domain(dn).bind(attributes: ATTRS) + end.compact end end end From 19b60bdf84c8c5bdc184d75cd29c86f0ba95d0ad Mon Sep 17 00:00:00 2001 From: Matt Todd Date: Mon, 24 Nov 2014 22:13:05 -0800 Subject: [PATCH 03/10] Clean up unneeded bits --- lib/github/ldap/members/recursive.rb | 15 ++------------- 1 file changed, 2 insertions(+), 13 deletions(-) diff --git a/lib/github/ldap/members/recursive.rb b/lib/github/ldap/members/recursive.rb index 15183c8..7e496f4 100644 --- a/lib/github/ldap/members/recursive.rb +++ b/lib/github/ldap/members/recursive.rb @@ -3,16 +3,13 @@ class Ldap module Members # Look up group members recursively. # - # In this case, we're returning User Net::LDAP::Entry objects, not entries - # for LDAP Groups. - # - # This results in a maximum of `depth` queries (per domain) to look up + # This results in a maximum of `depth` iterations/recursions to look up # members of a group and its subgroups. class Recursive include Filter DEFAULT_MAX_DEPTH = 9 - ATTRS = %w(dn cn member) + ATTRS = %w(dn member) # Internal: The GitHub::Ldap object to search domains with. attr_reader :ldap @@ -30,14 +27,6 @@ def initialize(ldap, options = {}) @depth = options[:depth] || DEFAULT_MAX_DEPTH end - # Internal: Domains to search through. - # - # Returns an Array of GitHub::Ldap::Domain objects. - def domains - @domains ||= ldap.search_domains.map { |base| ldap.domain(base) } - end - private :domains - # Public: Performs search for group members, including groups and # members of subgroups recursively. # From 9f48809ca3ae23a766d0589f015e1384bc4a0ccd Mon Sep 17 00:00:00 2001 From: Matt Todd Date: Mon, 24 Nov 2014 22:13:20 -0800 Subject: [PATCH 04/10] Add Classic members search strategy --- lib/github/ldap/members.rb | 2 ++ lib/github/ldap/members/classic.rb | 30 ++++++++++++++++++++++++++++++ 2 files changed, 32 insertions(+) create mode 100644 lib/github/ldap/members/classic.rb diff --git a/lib/github/ldap/members.rb b/lib/github/ldap/members.rb index 65a2539..85b7f37 100644 --- a/lib/github/ldap/members.rb +++ b/lib/github/ldap/members.rb @@ -1,3 +1,4 @@ +require 'github/ldap/members/classic' require 'github/ldap/members/recursive' module GitHub @@ -13,6 +14,7 @@ class Ldap module Members # Internal: Mapping of strategy name to class. STRATEGIES = { + :classic => GitHub::Ldap::Members::Classic, :recursive => GitHub::Ldap::Members::Recursive } end diff --git a/lib/github/ldap/members/classic.rb b/lib/github/ldap/members/classic.rb new file mode 100644 index 0000000..81a5601 --- /dev/null +++ b/lib/github/ldap/members/classic.rb @@ -0,0 +1,30 @@ +module GitHub + class Ldap + module Members + # Look up group members using the existing `Group#members` and + # `Group#subgroups` API. + class Classic + # Internal: The GitHub::Ldap object to search domains with. + attr_reader :ldap + + # Public: Instantiate new search strategy. + # + # - ldap: GitHub::Ldap object + # - options: Hash of options (unused) + def initialize(ldap, options = {}) + @ldap = ldap + @options = options + end + + # Public: Performs search for group members, including groups and + # members of subgroups recursively. + # + # Returns Array of Net::LDAP::Entry objects. + def perform(group_entry) + group = ldap.load_group(group_entry) + group.members + group.subgroups + end + end + end + end +end From 15b47712784455de4baee7d970e7bb8065e4e586 Mon Sep 17 00:00:00 2001 From: Matt Todd Date: Tue, 25 Nov 2014 16:13:13 -0800 Subject: [PATCH 05/10] Test deep recursion, configurable depth limiting --- test/members/recursive_test.rb | 11 +++++++++++ 1 file changed, 11 insertions(+) diff --git a/test/members/recursive_test.rb b/test/members/recursive_test.rb index ddcf866..d34f3bb 100644 --- a/test/members/recursive_test.rb +++ b/test/members/recursive_test.rb @@ -21,4 +21,15 @@ def test_finds_nested_group_members members = @strategy.perform(find_group("n-depth-nested-group1")).map(&:dn) assert_includes members, @entry.dn end + + def test_finds_deeply_nested_group_members + members = @strategy.perform(find_group("n-depth-nested-group9")).map(&:dn) + assert_includes members, @entry.dn + end + + def test_respects_configured_depth_limit + strategy = GitHub::Ldap::Members::Recursive.new(@ldap, depth: 2) + members = strategy.perform(find_group("n-depth-nested-group9")).map(&:dn) + refute_includes members, @entry.dn + end end From 7129554127a752aa18507cf914effc8caee4a6e6 Mon Sep 17 00:00:00 2001 From: Matt Todd Date: Tue, 25 Nov 2014 16:32:30 -0800 Subject: [PATCH 06/10] Include uniqueMember, memberUid in attrs to fetch --- lib/github/ldap/members/recursive.rb | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/lib/github/ldap/members/recursive.rb b/lib/github/ldap/members/recursive.rb index 7e496f4..ab915af 100644 --- a/lib/github/ldap/members/recursive.rb +++ b/lib/github/ldap/members/recursive.rb @@ -9,7 +9,7 @@ class Recursive include Filter DEFAULT_MAX_DEPTH = 9 - ATTRS = %w(dn member) + ATTRS = %w(dn member uniqueMember memberUid) # Internal: The GitHub::Ldap object to search domains with. attr_reader :ldap From b01e0ea87fb5a8d84a21989a7ea939f6d69a03c1 Mon Sep 17 00:00:00 2001 From: Matt Todd Date: Tue, 25 Nov 2014 16:33:46 -0800 Subject: [PATCH 07/10] Refactor member entry search, add memberUid search, test --- lib/github/ldap/members/recursive.rb | 49 ++++++++++++++++++++++++---- test/members/recursive_test.rb | 5 +++ 2 files changed, 47 insertions(+), 7 deletions(-) diff --git a/lib/github/ldap/members/recursive.rb b/lib/github/ldap/members/recursive.rb index ab915af..683bec5 100644 --- a/lib/github/ldap/members/recursive.rb +++ b/lib/github/ldap/members/recursive.rb @@ -34,11 +34,8 @@ def initialize(ldap, options = {}) def perform(group) found = Hash.new - members = group["member"] - return [] if members.empty? - # find members (N queries) - entries = entries_by_dn(members) + entries = member_entries(group) return [] if entries.empty? # track found entries @@ -55,10 +52,9 @@ def perform(group) # skip any members we've already found submembers.reject! { |dn| found.key?(dn) } - next if submembers.empty? - # find members of subgroup, including subgroups (N queries) - subentries = entries_by_dn(submembers) + subentries = member_entries(entry) + next if subentries.empty? # track found subentries subentries.each { |entry| found[entry.dn] = entry } @@ -78,6 +74,17 @@ def perform(group) found.values end + # Internal: Fetch member entries, including subgroups, for the given + # entry. + # + # Returns an Array of Net::LDAP::Entry objects. + def member_entries(entry) + dns = member_dns(entry) + return [] if dns.empty? + + entries_by_dn(dns) + end + # Internal: Bind a list of DNs to their respective entries. # # Returns an Array of Net::LDAP::Entry objects. @@ -86,6 +93,34 @@ def entries_by_dn(members) ldap.domain(dn).bind(attributes: ATTRS) end.compact end + + def entries_by_uid(members) + filter = members.map { |uid| Net::LDAP::Filter.eq(ldap.uid, uid) }.reduce(:|) + domains.each_with_object([]) do |domain, entries| + entries.concat domain.search(filter: filter, attributes: ATTRS) + end.compact + end + + # Internal: Returns an Array of String DNs for `groupOfNames` and + # `uniqueGroupOfNames` members. + def member_dns(entry) + MEMBERSHIP_NAMES.each_with_object([]) do |attr_name, members| + members.concat entry[attr_name] + end + end + + # Internal: Returns an Array of String UIDs for PosixGroups members. + def member_uids(entry) + entry["memberUid"] + end + + # Internal: Domains to search through. + # + # Returns an Array of GitHub::Ldap::Domain objects. + def domains + @domains ||= ldap.search_domains.map { |base| ldap.domain(base) } + end + private :domains end end end diff --git a/test/members/recursive_test.rb b/test/members/recursive_test.rb index d34f3bb..e743ca8 100644 --- a/test/members/recursive_test.rb +++ b/test/members/recursive_test.rb @@ -27,6 +27,11 @@ def test_finds_deeply_nested_group_members assert_includes members, @entry.dn end + def test_finds_posix_group_members + members = @strategy.perform(find_group("posix-group1")).map(&:dn) + assert_includes members, @entry.dn + end + def test_respects_configured_depth_limit strategy = GitHub::Ldap::Members::Recursive.new(@ldap, depth: 2) members = strategy.perform(find_group("n-depth-nested-group9")).map(&:dn) From 3822f4bb1739d163354ee6c6a5de5c8c14b4b762 Mon Sep 17 00:00:00 2001 From: Matt Todd Date: Tue, 25 Nov 2014 16:36:54 -0800 Subject: [PATCH 08/10] Fetch member entries by DN, UID --- lib/github/ldap/members/recursive.rb | 10 +++++++--- 1 file changed, 7 insertions(+), 3 deletions(-) diff --git a/lib/github/ldap/members/recursive.rb b/lib/github/ldap/members/recursive.rb index 683bec5..2ca10bf 100644 --- a/lib/github/ldap/members/recursive.rb +++ b/lib/github/ldap/members/recursive.rb @@ -79,10 +79,14 @@ def perform(group) # # Returns an Array of Net::LDAP::Entry objects. def member_entries(entry) - dns = member_dns(entry) - return [] if dns.empty? + entries = [] + dns = member_dns(entry) + uids = member_uids(entry) - entries_by_dn(dns) + entries.concat entries_by_uid(uids) unless uids.empty? + entries.concat entries_by_dn(dns) unless dns.empty? + + entries end # Internal: Bind a list of DNs to their respective entries. From 9418bd06ba399392e5f8077520bc9455f0b91bee Mon Sep 17 00:00:00 2001 From: Matt Todd Date: Tue, 25 Nov 2014 16:39:11 -0800 Subject: [PATCH 09/10] Doc entries_by_uid method, make internal methods private --- lib/github/ldap/members/recursive.rb | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/lib/github/ldap/members/recursive.rb b/lib/github/ldap/members/recursive.rb index 2ca10bf..0e1bef9 100644 --- a/lib/github/ldap/members/recursive.rb +++ b/lib/github/ldap/members/recursive.rb @@ -88,6 +88,7 @@ def member_entries(entry) entries end + private :member_entries # Internal: Bind a list of DNs to their respective entries. # @@ -97,13 +98,18 @@ def entries_by_dn(members) ldap.domain(dn).bind(attributes: ATTRS) end.compact end + private :entries_by_dn + # Internal: Fetch entries by UID. + # + # Returns an Array of Net::LDAP::Entry objects. def entries_by_uid(members) filter = members.map { |uid| Net::LDAP::Filter.eq(ldap.uid, uid) }.reduce(:|) domains.each_with_object([]) do |domain, entries| entries.concat domain.search(filter: filter, attributes: ATTRS) end.compact end + private :entries_by_uid # Internal: Returns an Array of String DNs for `groupOfNames` and # `uniqueGroupOfNames` members. @@ -112,11 +118,13 @@ def member_dns(entry) members.concat entry[attr_name] end end + private :member_dns # Internal: Returns an Array of String UIDs for PosixGroups members. def member_uids(entry) entry["memberUid"] end + private :member_uids # Internal: Domains to search through. # From 76546821dd458009bcbda22024f9e6ab5fa101ca Mon Sep 17 00:00:00 2001 From: Matt Todd Date: Tue, 25 Nov 2014 16:41:44 -0800 Subject: [PATCH 10/10] Test classic member search strategy --- test/members/classic_test.rb | 40 ++++++++++++++++++++++++++++++++++++ 1 file changed, 40 insertions(+) create mode 100644 test/members/classic_test.rb diff --git a/test/members/classic_test.rb b/test/members/classic_test.rb new file mode 100644 index 0000000..6c36a3f --- /dev/null +++ b/test/members/classic_test.rb @@ -0,0 +1,40 @@ +require_relative '../test_helper' + +class GitHubLdapRecursiveMembersTest < GitHub::Ldap::Test + def setup + @ldap = GitHub::Ldap.new(options.merge(search_domains: %w(dc=github,dc=com))) + @domain = @ldap.domain("dc=github,dc=com") + @entry = @domain.user?('user1') + @strategy = GitHub::Ldap::Members::Classic.new(@ldap) + end + + def find_group(cn) + @domain.groups([cn]).first + end + + def test_finds_group_members + members = @strategy.perform(find_group("nested-group1")).map(&:dn) + assert_includes members, @entry.dn + end + + def test_finds_nested_group_members + members = @strategy.perform(find_group("n-depth-nested-group1")).map(&:dn) + assert_includes members, @entry.dn + end + + def test_finds_deeply_nested_group_members + members = @strategy.perform(find_group("n-depth-nested-group9")).map(&:dn) + assert_includes members, @entry.dn + end + + def test_finds_posix_group_members + members = @strategy.perform(find_group("posix-group1")).map(&:dn) + assert_includes members, @entry.dn + end + + def test_does_not_respect_configured_depth_limit + strategy = GitHub::Ldap::Members::Classic.new(@ldap, depth: 2) + members = strategy.perform(find_group("n-depth-nested-group9")).map(&:dn) + assert_includes members, @entry.dn + end +end