-
Notifications
You must be signed in to change notification settings - Fork 27
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
Changes from all commits
0dd969d
3892f4e
19b60bd
9f48809
15b4771
7129554
b01e0ea
3822f4b
9418bd0
7654682
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
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 |
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 |
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 | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe 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 | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Working with |
||
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"] | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe 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 |
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 |
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 |
There was a problem hiding this comment.
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 issearch
?There was a problem hiding this comment.
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).