Skip to content

Recursive group member search strategy #64

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Merged
merged 10 commits into from
Nov 26, 2014
1 change: 1 addition & 0 deletions lib/github/ldap.rb
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
7 changes: 5 additions & 2 deletions lib/github/ldap/domain.rb
Original file line number Diff line number Diff line change
Expand Up @@ -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
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Not a blocker since this was here previously, but does it feel misleading that the method is named bind and what it actually does is search?

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@jch yeah, it's definitely confusing. I think I'm going to make some changes to the API for membership validators in this release which will eventually be breaking so it might be a good time to fix this (in a follow up PR).

end
end
end
Expand Down
22 changes: 22 additions & 0 deletions lib/github/ldap/members.rb
Original file line number Diff line number Diff line change
@@ -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) #=> [#<Net::LDAP::Entry>]
#
module Members
# Internal: Mapping of strategy name to class.
STRATEGIES = {
:classic => GitHub::Ldap::Members::Classic,
:recursive => GitHub::Ldap::Members::Recursive
}
end
end
end
30 changes: 30 additions & 0 deletions lib/github/ldap/members/classic.rb
Original file line number Diff line number Diff line change
@@ -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
139 changes: 139 additions & 0 deletions lib/github/ldap/members/recursive.rb
Original file line number Diff line number Diff line change
@@ -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
Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Added this method to handle handle the various member attributes better.

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
Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Working with memberUid is nice since we can query for all members with a single query.

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"]
Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Easy enough.

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
40 changes: 40 additions & 0 deletions test/members/classic_test.rb
Original file line number Diff line number Diff line change
@@ -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
40 changes: 40 additions & 0 deletions test/members/recursive_test.rb
Original file line number Diff line number Diff line change
@@ -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