diff --git a/lib/github/ldap.rb b/lib/github/ldap.rb index 43c3f3b..e6ab060 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/member_of' require 'github/ldap/members' require 'github/ldap/membership_validators' diff --git a/lib/github/ldap/member_of.rb b/lib/github/ldap/member_of.rb new file mode 100644 index 0000000..05fb1d8 --- /dev/null +++ b/lib/github/ldap/member_of.rb @@ -0,0 +1,22 @@ +require 'github/ldap/member_of/classic' +require 'github/ldap/member_of/recursive' + +module GitHub + class Ldap + # Provides various strategies to search for groups a user is a member of. + # + # For example: + # + # group = domain.groups(%w(Engineering)).first + # strategy = GitHub::Ldap::MemberOf::Recursive.new(ldap) + # strategy.perform(user) #=> [#] + # + module MemberOf + # Internal: Mapping of strategy name to class. + STRATEGIES = { + :classic => GitHub::Ldap::MemberOf::Classic, + :recursive => GitHub::Ldap::MemberOf::Recursive + } + end + end +end diff --git a/lib/github/ldap/member_of/classic.rb b/lib/github/ldap/member_of/classic.rb new file mode 100644 index 0000000..60dc88c --- /dev/null +++ b/lib/github/ldap/member_of/classic.rb @@ -0,0 +1,46 @@ +module GitHub + class Ldap + module MemberOf + # Look up the groups an entry is a member of. + class Classic + include Filter + + # 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 groups an entry is a member of, including + # subgroups. + # + # Returns Array of Net::LDAP::Entry objects. + def perform(entry) + filter = member_filter(entry) + + if ldap.posix_support_enabled? && !entry[ldap.uid].empty? + filter |= posix_member_filter(entry, ldap.uid) + end + + domains.each_with_object([]) do |domain, entries| + entries.concat domain.search(filter: filter) + end + 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 +end diff --git a/lib/github/ldap/member_of/recursive.rb b/lib/github/ldap/member_of/recursive.rb new file mode 100644 index 0000000..0bb6a80 --- /dev/null +++ b/lib/github/ldap/member_of/recursive.rb @@ -0,0 +1,70 @@ +module GitHub + class Ldap + module MemberOf + # Look up the groups an entry is a member of, including nested subgroups. + # + # NOTE: this strategy is network and performance intensive. + class Recursive + include Filter + + # Internal: The GitHub::Ldap object to search domains with. + attr_reader :ldap + + # Internal: The maximum depth to search for subgroups. + 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] + end + + # Public: Performs search for groups an entry is a member of, including + # subgroups. + # + # Returns Array of Net::LDAP::Entry objects. + def perform(entry) + filter = member_filter(entry) + + if ldap.posix_support_enabled? && !entry[ldap.uid].empty? + filter |= posix_member_filter(entry, ldap.uid) + end + + entries = domains.each_with_object([]) do |domain, entries| + entries.concat domain.search(filter: filter) + end + + entries.each_with_object(entries.dup) do |entry, entries| + entries.concat search_strategy.perform(entry) + end.select { |entry| group?(entry) } + 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 + + # Internal: The search strategy to recursively search for nested + # subgroups with. + def search_strategy + @search_strategy ||= + GitHub::Ldap::Members::Recursive.new ldap, + depth: depth, + attrs: %w(objectClass) + end + + # Internal: Returns true if the entry is a group. + def group?(entry) + GitHub::Ldap::Group.group?(entry[:objectclass]) + end + end + end + end +end diff --git a/test/member_of/classic_test.rb b/test/member_of/classic_test.rb new file mode 100644 index 0000000..c42becb --- /dev/null +++ b/test/member_of/classic_test.rb @@ -0,0 +1,31 @@ +require_relative '../test_helper' + +class GitHubLdapClassicMemberOfTest < 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::MemberOf::Classic.new(@ldap) + end + + def find_group(cn) + @domain.groups([cn]).first + end + + def test_finds_groups_entry_is_a_direct_member_of + member_of = @strategy.perform(@entry) + assert_includes member_of.map(&:dn), find_group("nested-group1").dn + end + + def test_finds_subgroups_entry_is_a_member_of + skip "Classic strategy does not support nested subgroups" + member_of = @strategy.perform(@entry) + assert_includes member_of.map(&:dn), find_group("head-group").dn + assert_includes member_of.map(&:dn), find_group("tail-group").dn + end + + def test_excludes_groups_entry_is_not_a_member_of + member_of = @strategy.perform(@entry) + refute_includes member_of.map(&:dn), find_group("ghe-admins").dn + end +end diff --git a/test/member_of/recursive_test.rb b/test/member_of/recursive_test.rb new file mode 100644 index 0000000..3d37ee1 --- /dev/null +++ b/test/member_of/recursive_test.rb @@ -0,0 +1,30 @@ +require_relative '../test_helper' + +class GitHubLdapRecursiveMemberOfTest < 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::MemberOf::Recursive.new(@ldap) + end + + def find_group(cn) + @domain.groups([cn]).first + end + + def test_finds_groups_entry_is_a_direct_member_of + member_of = @strategy.perform(@entry) + assert_includes member_of.map(&:dn), find_group("nested-group1").dn + end + + def test_finds_subgroups_entry_is_a_member_of + member_of = @strategy.perform(@entry) + assert_includes member_of.map(&:dn), find_group("head-group").dn + assert_includes member_of.map(&:dn), find_group("tail-group").dn + end + + def test_excludes_groups_entry_is_not_a_member_of + member_of = @strategy.perform(@entry) + refute_includes member_of.map(&:dn), find_group("ghe-admins").dn + end +end