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/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.rb b/lib/github/ldap/members.rb new file mode 100644 index 0000000..85b7f37 --- /dev/null +++ b/lib/github/ldap/members.rb @@ -0,0 +1,22 @@ +require 'github/ldap/members/classic' +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 = { + :classic => GitHub::Ldap::Members::Classic, + :recursive => GitHub::Ldap::Members::Recursive + } + end + end +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 diff --git a/lib/github/ldap/members/recursive.rb b/lib/github/ldap/members/recursive.rb new file mode 100644 index 0000000..0e1bef9 --- /dev/null +++ b/lib/github/ldap/members/recursive.rb @@ -0,0 +1,139 @@ +module GitHub + class Ldap + module Members + # Look up group members recursively. + # + # 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 member uniqueMember memberUid) + + # 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 + # - options: Hash of options + def initialize(ldap, options = {}) + @ldap = ldap + @options = options + @depth = options[:depth] || DEFAULT_MAX_DEPTH + end + + # Public: Performs search for group members, including groups and + # members of subgroups recursively. + # + # Returns Array of Net::LDAP::Entry objects. + def perform(group) + found = Hash.new + + # find members (N queries) + entries = member_entries(group) + return [] if entries.empty? + + # track found entries + entries.each do |entry| + found[entry.dn] = entry + 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"] + + # skip any members we've already found + submembers.reject! { |dn| found.key?(dn) } + + # find members of subgroup, including subgroups (N queries) + subentries = member_entries(entry) + next if subentries.empty? + + # track found subentries + subentries.each { |entry| found[entry.dn] = entry } + + # collect all entries for this depth + depth_entries.concat subentries + end + + # stop if there are no more subgroups to search + break if depth_subentries.empty? + + # go one level deeper + entries = depth_subentries + end + + # return all found entries + 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) + entries = [] + dns = member_dns(entry) + uids = member_uids(entry) + + entries.concat entries_by_uid(uids) unless uids.empty? + entries.concat entries_by_dn(dns) unless dns.empty? + + entries + end + private :member_entries + + # Internal: Bind a list of DNs to their respective entries. + # + # 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 + 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. + def member_dns(entry) + MEMBERSHIP_NAMES.each_with_object([]) do |attr_name, members| + 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. + # + # 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 +end 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 diff --git a/test/members/recursive_test.rb b/test/members/recursive_test.rb new file mode 100644 index 0000000..e743ca8 --- /dev/null +++ b/test/members/recursive_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::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 + + 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_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