Skip to content

Commit 7ae8fec

Browse files
committed
Merge pull request #45 from github/membership-validators
Membership Validators
2 parents 5def1be + 084cdf2 commit 7ae8fec

File tree

12 files changed

+505
-11
lines changed

12 files changed

+505
-11
lines changed

lib/github/ldap.rb

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,7 @@ class Ldap
99
require 'github/ldap/virtual_group'
1010
require 'github/ldap/virtual_attributes'
1111
require 'github/ldap/instrumentation'
12+
require 'github/ldap/membership_validators'
1213

1314
include Instrumentation
1415

lib/github/ldap/filter.rb

Lines changed: 15 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -20,16 +20,18 @@ def group_filter(group_names)
2020

2121
# Filter to check group membership.
2222
#
23-
# entry: finds groups this Net::LDAP::Entry is a member of (optional)
23+
# entry: finds groups this entry is a member of (optional)
24+
# Expects a Net::LDAP::Entry or String DN.
2425
#
2526
# Returns a Net::LDAP::Filter.
2627
def member_filter(entry = nil)
2728
if entry
29+
entry = entry.dn if entry.respond_to?(:dn)
2830
MEMBERSHIP_NAMES.
29-
map {|n| Net::LDAP::Filter.eq(n, entry.dn) }.reduce(:|)
31+
map {|n| Net::LDAP::Filter.eq(n, entry) }.reduce(:|)
3032
else
3133
MEMBERSHIP_NAMES.
32-
map {|n| Net::LDAP::Filter.pres(n) }. reduce(:|)
34+
map {|n| Net::LDAP::Filter.pres(n) }. reduce(:|)
3335
end
3436
end
3537

@@ -41,10 +43,16 @@ def member_filter(entry = nil)
4143
# uid_attr: specifies the memberUid attribute to match with
4244
#
4345
# Returns a Net::LDAP::Filter or nil if no entry has no UID set.
44-
def posix_member_filter(entry, uid_attr)
45-
if !entry[uid_attr].empty?
46-
entry[uid_attr].map { |uid| Net::LDAP::Filter.eq("memberUid", uid) }.
47-
reduce(:|)
46+
def posix_member_filter(entry_or_uid, uid_attr = nil)
47+
case entry_or_uid
48+
when Net::LDAP::Entry
49+
entry = entry_or_uid
50+
if !entry[uid_attr].empty?
51+
entry[uid_attr].map { |uid| Net::LDAP::Filter.eq("memberUid", uid) }.
52+
reduce(:|)
53+
end
54+
when String
55+
Net::LDAP::Filter.eq("memberUid", entry_or_uid)
4856
end
4957
end
5058

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,17 @@
1+
module GitHub
2+
class Ldap
3+
# Provides various strategies for validating membership.
4+
#
5+
# For example:
6+
#
7+
# groups = domain.groups(%w(Engineering))
8+
# validator = GitHub::Ldap::MembershipValidators::Classic.new(ldap, groups)
9+
# validator.perform(entry) #=> true
10+
#
11+
module MembershipValidators
12+
autoload :Base, 'github/ldap/membership_validators/base'
13+
autoload :Classic, 'github/ldap/membership_validators/classic'
14+
autoload :Recursive, 'github/ldap/membership_validators/recursive'
15+
end
16+
end
17+
end
Lines changed: 37 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,37 @@
1+
module GitHub
2+
class Ldap
3+
module MembershipValidators
4+
class Base
5+
6+
# Internal: The GitHub::Ldap object to search domains with.
7+
attr_reader :ldap
8+
9+
# Internal: an Array of Net::LDAP::Entry group objects to validate with.
10+
attr_reader :groups
11+
12+
# Public: Instantiate new validator.
13+
#
14+
# - ldap: GitHub::Ldap object
15+
# - groups: Array of Net::LDAP::Entry group objects
16+
def initialize(ldap, groups)
17+
@ldap = ldap
18+
@groups = groups
19+
end
20+
21+
# Abstract: Performs the membership validation check.
22+
#
23+
# Returns Boolean whether the entry's membership is validated or not.
24+
# def perform(entry)
25+
# end
26+
27+
# Internal: Domains to search through.
28+
#
29+
# Returns an Array of GitHub::Ldap::Domain objects.
30+
def domains
31+
@domains ||= ldap.search_domains.map { |base| ldap.domain(base) }
32+
end
33+
private :domains
34+
end
35+
end
36+
end
37+
end
Lines changed: 33 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,33 @@
1+
module GitHub
2+
class Ldap
3+
module MembershipValidators
4+
# Validates membership using `GitHub::Ldap::Domain#membership`.
5+
#
6+
# This is a simple wrapper for existing functionality in order to expose
7+
# it consistently with the new approach.
8+
class Classic < Base
9+
def perform(entry)
10+
return true if groups.empty?
11+
12+
domains.each do |domain|
13+
membership = domain.membership(entry, group_names)
14+
15+
if !membership.empty?
16+
entry[:groups] = membership
17+
return true
18+
end
19+
end
20+
21+
false
22+
end
23+
24+
# Internal: the group names to look up membership for.
25+
#
26+
# Returns an Array of String group names (CNs).
27+
def group_names
28+
@group_names ||= groups.map { |g| g[:cn].first }
29+
end
30+
end
31+
end
32+
end
33+
end
Lines changed: 90 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,90 @@
1+
module GitHub
2+
class Ldap
3+
module MembershipValidators
4+
# Validates membership recursively.
5+
#
6+
# The first step checks whether the entry is a direct member of the given
7+
# groups. If they are, then we've validated membership successfully.
8+
#
9+
# If not, query for all of the groups that have our groups as members,
10+
# then we check if the entry is a member of any of those.
11+
#
12+
# This is repeated until the entry is found, recursing and requesting
13+
# groups in bulk each iteration until we hit the maximum depth allowed
14+
# and have to give up.
15+
#
16+
# This results in a maximum of `depth` queries (per domain) to validate
17+
# membership in a list of groups.
18+
class Recursive < Base
19+
include Filter
20+
21+
DEFAULT_MAX_DEPTH = 9
22+
ATTRS = %w(dn cn)
23+
24+
def perform(entry, depth = DEFAULT_MAX_DEPTH)
25+
domains.each do |domain|
26+
# find groups entry is an immediate member of
27+
membership = domain.search(filter: member_filter(entry), attributes: ATTRS)
28+
29+
# success if any of these groups match the restricted auth groups
30+
return true if membership.any? { |entry| group_dns.include?(entry.dn) }
31+
32+
# give up if the entry has no memberships to recurse
33+
next if membership.empty?
34+
35+
# recurse to at most `depth`
36+
depth.times do |n|
37+
# find groups whose members include membership groups
38+
membership = domain.search(filter: membership_filter(membership), attributes: ATTRS)
39+
40+
# success if any of these groups match the restricted auth groups
41+
return true if membership.any? { |entry| group_dns.include?(entry.dn) }
42+
43+
# give up if there are no more membersips to recurse
44+
break if membership.empty?
45+
end
46+
47+
# give up on this base if there are no memberships to test
48+
next if membership.empty?
49+
end
50+
51+
false
52+
end
53+
54+
# Internal: Construct a filter to find groups this entry is a direct
55+
# member of.
56+
#
57+
# Overloads the included `GitHub::Ldap::Filters#member_filter` method
58+
# to inject `posixGroup` handling.
59+
#
60+
# Returns a Net::LDAP::Filter object.
61+
def member_filter(entry_or_uid, uid = ldap.uid)
62+
filter = super(entry_or_uid)
63+
64+
if ldap.posix_support_enabled?
65+
if posix_filter = posix_member_filter(entry_or_uid, uid)
66+
filter |= posix_filter
67+
end
68+
end
69+
70+
filter
71+
end
72+
73+
# Internal: Construct a filter to find groups whose members are the
74+
# Array of String group DNs passed in.
75+
#
76+
# Returns a String filter.
77+
def membership_filter(groups)
78+
groups.map { |entry| member_filter(entry, :cn) }.reduce(:|)
79+
end
80+
81+
# Internal: the group DNs to check against.
82+
#
83+
# Returns an Array of String DNs.
84+
def group_dns
85+
@group_dns ||= groups.map(&:dn)
86+
end
87+
end
88+
end
89+
end
90+
end

test/filter_test.rb

Lines changed: 12 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -20,7 +20,8 @@ def setup
2020
@subject = Subject.new(@ldap)
2121
@me = 'uid=calavera,dc=github,dc=com'
2222
@uid = "calavera"
23-
@entry = Entry.new(@me, @uid)
23+
@entry = Net::LDAP::Entry.new(@me)
24+
@entry[:uid] = @uid
2425
end
2526

2627
def test_member_present
@@ -32,6 +33,11 @@ def test_member_equal
3233
@subject.member_filter(@entry).to_s
3334
end
3435

36+
def test_member_equal_with_string
37+
assert_equal "(|(member=#{@me})(uniqueMember=#{@me}))",
38+
@subject.member_filter(@entry.dn).to_s
39+
end
40+
3541
def test_posix_member_without_uid
3642
@entry.uid = nil
3743
assert_nil @subject.posix_member_filter(@entry, @ldap.uid)
@@ -42,6 +48,11 @@ def test_posix_member_equal
4248
@subject.posix_member_filter(@entry, @ldap.uid).to_s
4349
end
4450

51+
def test_posix_member_equal_string
52+
assert_equal "(memberUid=#{@uid})",
53+
@subject.posix_member_filter(@uid).to_s
54+
end
55+
4556
def test_groups_reduced
4657
assert_equal "(|(cn=Enterprise)(cn=People))",
4758
@subject.group_filter(%w(Enterprise People)).to_s

test/fixtures/github-with-posixGroups.ldif

Lines changed: 55 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -27,6 +27,29 @@ objectClass: posixGroup
2727
memberUid: benburkert
2828
memberUid: mtodd
2929

30+
dn: cn=group1,ou=groups,dc=github,dc=com
31+
cn: group1
32+
objectClass: posixGroup
33+
memberUid: group1.1
34+
memberUid: user1
35+
36+
dn: cn=group1.1,ou=groups,dc=github,dc=com
37+
cn: group1.1
38+
objectClass: posixGroup
39+
memberUid: group1.1.1
40+
memberUid: user1.1
41+
42+
dn: cn=group1.1.1,ou=groups,dc=github,dc=com
43+
cn: group1.1.1
44+
objectClass: posixGroup
45+
memberUid: group1.1.1.1
46+
memberUid: user1.1.1
47+
48+
dn: cn=group1.1.1.1,ou=groups,dc=github,dc=com
49+
cn: group1.1.1.1
50+
objectClass: posixGroup
51+
memberUid: user1.1.1.1
52+
3053
# Users
3154

3255
dn: ou=users,dc=github,dc=com
@@ -48,3 +71,35 @@ uid: mtodd
4871
userPassword: passworD1
4972
mail: mtodd@github.com
5073
objectClass: inetOrgPerson
74+
75+
dn: uid=user1,ou=users,dc=github,dc=com
76+
cn: user1
77+
sn: user1
78+
uid: user1
79+
userPassword: passworD1
80+
mail: user1@github.com
81+
objectClass: inetOrgPerson
82+
83+
dn: uid=user1.1,ou=users,dc=github,dc=com
84+
cn: user1.1
85+
sn: user1.1
86+
uid: user1.1
87+
userPassword: passworD1
88+
mail: user1.1@github.com
89+
objectClass: inetOrgPerson
90+
91+
dn: uid=user1.1.1,ou=users,dc=github,dc=com
92+
cn: user1.1.1
93+
sn: user1.1.1
94+
uid: user1.1.1
95+
userPassword: passworD1
96+
mail: user1.1.1@github.com
97+
objectClass: inetOrgPerson
98+
99+
dn: uid=user1.1.1.1,ou=users,dc=github,dc=com
100+
cn: user1.1.1.1
101+
sn: user1.1.1.1
102+
uid: user1.1.1.1
103+
userPassword: passworD1
104+
mail: user1.1.1.1@github.com
105+
objectClass: inetOrgPerson

test/fixtures/github-with-subgroups.ldif

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -50,19 +50,19 @@ member: uid=user1,ou=users,dc=github,dc=com
5050
member: cn=group1.1,ou=groups,dc=github,dc=com
5151

5252
dn: cn=group1.1,ou=groups,dc=github,dc=com
53-
cn: group1
53+
cn: group1.1
5454
objectClass: groupOfNames
5555
member: uid=user1.1,ou=users,dc=github,dc=com
5656
member: cn=group1.1.1,ou=groups,dc=github,dc=com
5757

5858
dn: cn=group1.1.1,ou=groups,dc=github,dc=com
59-
cn: group1
59+
cn: group1.1.1
6060
objectClass: groupOfNames
6161
member: uid=user1.1.1,ou=users,dc=github,dc=com
6262
member: cn=group1.1.1.1,ou=groups,dc=github,dc=com
6363

6464
dn: cn=group1.1.1.1,ou=groups,dc=github,dc=com
65-
cn: group1
65+
cn: group1.1.1.1
6666
objectClass: groupOfNames
6767
member: uid=user1.1.1.1,ou=users,dc=github,dc=com
6868

0 commit comments

Comments
 (0)