Skip to content

Membership Validators #45

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 23 commits into from
Sep 27, 2014
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
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/membership_validators'

include Instrumentation

Expand Down
22 changes: 15 additions & 7 deletions lib/github/ldap/filter.rb
Original file line number Diff line number Diff line change
Expand Up @@ -20,16 +20,18 @@ def group_filter(group_names)

# Filter to check group membership.
#
# entry: finds groups this Net::LDAP::Entry is a member of (optional)
# entry: finds groups this entry is a member of (optional)
# Expects a Net::LDAP::Entry or String DN.
#
# Returns a Net::LDAP::Filter.
def member_filter(entry = nil)
if entry
entry = entry.dn if entry.respond_to?(:dn)
Copy link
Contributor

Choose a reason for hiding this comment

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

A better variable name here might be dn since you extract it from an entry. Also, what do you think of making the parameter name entry_or_dn to reflect it accepting two types?

MEMBERSHIP_NAMES.
map {|n| Net::LDAP::Filter.eq(n, entry.dn) }.reduce(:|)
map {|n| Net::LDAP::Filter.eq(n, entry) }.reduce(:|)
Copy link
Contributor

Choose a reason for hiding this comment

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

Reducing over the | operator looks like a disappointed smiley :| No change requested here 😁

else
MEMBERSHIP_NAMES.
map {|n| Net::LDAP::Filter.pres(n) }. reduce(:|)
map {|n| Net::LDAP::Filter.pres(n) }. reduce(:|)
end
end

Expand All @@ -41,10 +43,16 @@ def member_filter(entry = nil)
# uid_attr: specifies the memberUid attribute to match with
#
# Returns a Net::LDAP::Filter or nil if no entry has no UID set.
def posix_member_filter(entry, uid_attr)
if !entry[uid_attr].empty?
entry[uid_attr].map { |uid| Net::LDAP::Filter.eq("memberUid", uid) }.
reduce(:|)
def posix_member_filter(entry_or_uid, uid_attr = nil)
case entry_or_uid
when Net::LDAP::Entry
entry = entry_or_uid
if !entry[uid_attr].empty?
entry[uid_attr].map { |uid| Net::LDAP::Filter.eq("memberUid", uid) }.
reduce(:|)
end
when String
Net::LDAP::Filter.eq("memberUid", entry_or_uid)
end
end

Expand Down
17 changes: 17 additions & 0 deletions lib/github/ldap/membership_validators.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,17 @@
module GitHub
class Ldap
# Provides various strategies for validating membership.
#
# For example:
#
# groups = domain.groups(%w(Engineering))
# validator = GitHub::Ldap::MembershipValidators::Classic.new(ldap, groups)
# validator.perform(entry) #=> true
#
Copy link
Contributor

Choose a reason for hiding this comment

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

This interface makes sense to me, but I'm not familiar with how it'll typically be called. If users are typically working with entrys, the following interface feels more natural to me:

ldap.search(...) do |entry|
  entry.member_of?("Engineering")  #=> true
end

The above isn't actually possible because an Entry does not keep a reference to the connection object that it came from (as far as I know). This is all syntactic sugar in any case and doesn't affect your validators interface, but I wanted to brainstorm out loud in case it leads us to a more intuitive interface.

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 I'm trying to move away from sugar like this for various reasons, but the biggest reason by far is that abstractions like this hide the actual work behind the scenes, which in this case means network IO.

I also don't want to try to inject methods like member_of? into Net::LDAP::Entry classes, keeping a clear separation between that library and these higher level concerns.

Copy link
Contributor

Choose a reason for hiding this comment

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

Dig it. I think it's a bad idea to add sugar before trying out real life access patterns anyhow. Just wanted to get a feel for it.

module MembershipValidators
autoload :Base, 'github/ldap/membership_validators/base'
autoload :Classic, 'github/ldap/membership_validators/classic'
autoload :Recursive, 'github/ldap/membership_validators/recursive'
end
end
end
37 changes: 37 additions & 0 deletions lib/github/ldap/membership_validators/base.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,37 @@
module GitHub
class Ldap
module MembershipValidators
class Base

# Internal: The GitHub::Ldap object to search domains with.
attr_reader :ldap

# Internal: an Array of Net::LDAP::Entry group objects to validate with.
attr_reader :groups

# Public: Instantiate new validator.
#
# - ldap: GitHub::Ldap object
# - groups: Array of Net::LDAP::Entry group objects
def initialize(ldap, groups)
@ldap = ldap
@groups = groups
end

# Abstract: Performs the membership validation check.
#
# Returns Boolean whether the entry's membership is validated or not.
# def perform(entry)
# end
Copy link
Contributor

Choose a reason for hiding this comment

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

I would actually implement this in the base class as follows:

def perform(entry)
  raise NotImplementedError.new
end

With a note in the doc that says this method is required by subclasses.

Copy link
Member Author

Choose a reason for hiding this comment

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

I stopped doing that as much when I found out that NotImplementedError is reserved for platform to indicate unavailable features like fork. See: http://www.ruby-doc.org/core-2.1.3/NotImplementedError.html for details.

I'm happy with NoMethodError in this case.

Copy link
Contributor

Choose a reason for hiding this comment

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

TIL. Do you add a custom message along with the NoMethodError?

raise NoMethodError.new "method #x required"

Copy link
Member Author

Choose a reason for hiding this comment

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

I'm literally letting Ruby do all the work. ^_^


# 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
Copy link
Contributor

Choose a reason for hiding this comment

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

Make this method private or protected

Copy link
Member Author

Choose a reason for hiding this comment

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

👌

private :domains
end
end
end
end
33 changes: 33 additions & 0 deletions lib/github/ldap/membership_validators/classic.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,33 @@
module GitHub
class Ldap
module MembershipValidators
# Validates membership using `GitHub::Ldap::Domain#membership`.
#
# This is a simple wrapper for existing functionality in order to expose
# it consistently with the new approach.
class Classic < Base
def perform(entry)
return true if groups.empty?

domains.each do |domain|
membership = domain.membership(entry, group_names)

if !membership.empty?
Copy link
Contributor

Choose a reason for hiding this comment

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

This reads fine to me and I'm not familiar with existing style conventions yet, but it can also be written in the positive:

if membership.present?

edit also out of scope for this PR because you're just moving existing behavior over. Just wanted to note it.

Copy link
Member Author

Choose a reason for hiding this comment

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

Unless I'm mistaken, present? is an ActiveSupport extension and not part of standard Ruby library.

Copy link
Contributor

Choose a reason for hiding this comment

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

entry[:groups] = membership
return true
end
end

false
end

# Internal: the group names to look up membership for.
#
# Returns an Array of String group names (CNs).
def group_names
@group_names ||= groups.map { |g| g[:cn].first }
end
Copy link
Contributor

Choose a reason for hiding this comment

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

Will this be used by other adaptors? No need to push it up to the Base class just yet, I'm just curious.

Copy link
Member Author

Choose a reason for hiding this comment

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

Not worried about answering that until it's actually needed, and the method/class signature might change enough to make this unnecessary. This is needed because this class uses CNs but the other uses DNs to compare, and I wanted to take Net::LDAP::Entry objects as input to make the input consistent between the two (drop in replacement being desired).

Copy link
Contributor

Choose a reason for hiding this comment

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

👍

end
end
end
end
90 changes: 90 additions & 0 deletions lib/github/ldap/membership_validators/recursive.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,90 @@
module GitHub
class Ldap
module MembershipValidators
# Validates membership recursively.
#
# The first step checks whether the entry is a direct member of the given
# groups. If they are, then we've validated membership successfully.
#
# If not, query for all of the groups that have our groups as members,
# then we check if the entry is a member of any of those.
#
# This is repeated until the entry is found, recursing and requesting
# groups in bulk each iteration until we hit the maximum depth allowed
# and have to give up.
#
# This results in a maximum of `depth` queries (per domain) to validate
# membership in a list of groups.
class Recursive < Base
include Filter

DEFAULT_MAX_DEPTH = 9
ATTRS = %w(dn cn)

def perform(entry, depth = DEFAULT_MAX_DEPTH)
Copy link
Contributor

Choose a reason for hiding this comment

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

Should depth be a configurable attribute? Is there any downside to reusing validators, or are you trying to encourage people to create a new validator each time?

Copy link
Member Author

Choose a reason for hiding this comment

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

Validators should be reused.

I kept depth here instead of on initialize to make initialize consistent but I guess this adds inconsistency for the caller anyways. Will think about this a little more.

Copy link
Contributor

Choose a reason for hiding this comment

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

Perhaps this signature?

def perform(entry, options = {})
end

Might be premature as I don't know whether it'll be common for validators to accept options. I'm fine with leaving it as is and fine tuning later.

domains.each do |domain|
# find groups entry is an immediate member of
membership = domain.search(filter: member_filter(entry), attributes: ATTRS)

# success if any of these groups match the restricted auth groups
return true if membership.any? { |entry| group_dns.include?(entry.dn) }

# give up if the entry has no memberships to recurse
next if membership.empty?

# recurse to at most `depth`
depth.times do |n|
# find groups whose members include membership groups
membership = domain.search(filter: membership_filter(membership), attributes: ATTRS)

# success if any of these groups match the restricted auth groups
return true if membership.any? { |entry| group_dns.include?(entry.dn) }

# give up if there are no more membersips to recurse
break if membership.empty?
end

# give up on this base if there are no memberships to test
next if membership.empty?
end

false
end
Copy link
Contributor

Choose a reason for hiding this comment

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

Going to reason about this a bit more. Haven't convinced myself how to this works yet.


# Internal: Construct a filter to find groups this entry is a direct
# member of.
#
# Overloads the included `GitHub::Ldap::Filters#member_filter` method
# to inject `posixGroup` handling.
#
# Returns a Net::LDAP::Filter object.
def member_filter(entry_or_uid, uid = ldap.uid)
filter = super(entry_or_uid)

if ldap.posix_support_enabled?
if posix_filter = posix_member_filter(entry_or_uid, uid)
filter |= posix_filter
end
end

filter
end

# Internal: Construct a filter to find groups whose members are the
# Array of String group DNs passed in.
#
# Returns a String filter.
def membership_filter(groups)
groups.map { |entry| member_filter(entry, :cn) }.reduce(:|)
end

# Internal: the group DNs to check against.
#
# Returns an Array of String DNs.
def group_dns
@group_dns ||= groups.map(&:dn)
end
end
end
end
end
13 changes: 12 additions & 1 deletion test/filter_test.rb
Original file line number Diff line number Diff line change
Expand Up @@ -20,7 +20,8 @@ def setup
@subject = Subject.new(@ldap)
@me = 'uid=calavera,dc=github,dc=com'
@uid = "calavera"
@entry = Entry.new(@me, @uid)
@entry = Net::LDAP::Entry.new(@me)
@entry[:uid] = @uid
end

def test_member_present
Expand All @@ -32,6 +33,11 @@ def test_member_equal
@subject.member_filter(@entry).to_s
end

def test_member_equal_with_string
assert_equal "(|(member=#{@me})(uniqueMember=#{@me}))",
@subject.member_filter(@entry.dn).to_s
end

def test_posix_member_without_uid
@entry.uid = nil
assert_nil @subject.posix_member_filter(@entry, @ldap.uid)
Expand All @@ -42,6 +48,11 @@ def test_posix_member_equal
@subject.posix_member_filter(@entry, @ldap.uid).to_s
end

def test_posix_member_equal_string
assert_equal "(memberUid=#{@uid})",
@subject.posix_member_filter(@uid).to_s
end

def test_groups_reduced
assert_equal "(|(cn=Enterprise)(cn=People))",
@subject.group_filter(%w(Enterprise People)).to_s
Expand Down
55 changes: 55 additions & 0 deletions test/fixtures/github-with-posixGroups.ldif
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,29 @@ objectClass: posixGroup
memberUid: benburkert
memberUid: mtodd

dn: cn=group1,ou=groups,dc=github,dc=com
cn: group1
objectClass: posixGroup
memberUid: group1.1
memberUid: user1

dn: cn=group1.1,ou=groups,dc=github,dc=com
cn: group1.1
objectClass: posixGroup
memberUid: group1.1.1
memberUid: user1.1

dn: cn=group1.1.1,ou=groups,dc=github,dc=com
cn: group1.1.1
objectClass: posixGroup
memberUid: group1.1.1.1
memberUid: user1.1.1

dn: cn=group1.1.1.1,ou=groups,dc=github,dc=com
cn: group1.1.1.1
objectClass: posixGroup
memberUid: user1.1.1.1

# Users

dn: ou=users,dc=github,dc=com
Expand All @@ -48,3 +71,35 @@ uid: mtodd
userPassword: passworD1
mail: mtodd@github.com
objectClass: inetOrgPerson

dn: uid=user1,ou=users,dc=github,dc=com
cn: user1
sn: user1
uid: user1
userPassword: passworD1
mail: user1@github.com
objectClass: inetOrgPerson

dn: uid=user1.1,ou=users,dc=github,dc=com
cn: user1.1
sn: user1.1
uid: user1.1
userPassword: passworD1
mail: user1.1@github.com
objectClass: inetOrgPerson

dn: uid=user1.1.1,ou=users,dc=github,dc=com
cn: user1.1.1
sn: user1.1.1
uid: user1.1.1
userPassword: passworD1
mail: user1.1.1@github.com
objectClass: inetOrgPerson

dn: uid=user1.1.1.1,ou=users,dc=github,dc=com
cn: user1.1.1.1
sn: user1.1.1.1
uid: user1.1.1.1
userPassword: passworD1
mail: user1.1.1.1@github.com
objectClass: inetOrgPerson
6 changes: 3 additions & 3 deletions test/fixtures/github-with-subgroups.ldif
Original file line number Diff line number Diff line change
Expand Up @@ -50,19 +50,19 @@ member: uid=user1,ou=users,dc=github,dc=com
member: cn=group1.1,ou=groups,dc=github,dc=com

dn: cn=group1.1,ou=groups,dc=github,dc=com
cn: group1
cn: group1.1
objectClass: groupOfNames
member: uid=user1.1,ou=users,dc=github,dc=com
member: cn=group1.1.1,ou=groups,dc=github,dc=com

dn: cn=group1.1.1,ou=groups,dc=github,dc=com
cn: group1
cn: group1.1.1
objectClass: groupOfNames
member: uid=user1.1.1,ou=users,dc=github,dc=com
member: cn=group1.1.1.1,ou=groups,dc=github,dc=com

dn: cn=group1.1.1.1,ou=groups,dc=github,dc=com
cn: group1
cn: group1.1.1.1
objectClass: groupOfNames
member: uid=user1.1.1.1,ou=users,dc=github,dc=com

Expand Down
Loading
pFad - Phonifier reborn

Pfad - The Proxy pFad of © 2024 Garber Painting. All rights reserved.

Note: This service is not intended for secure transactions such as banking, social media, email, or purchasing. Use at your own risk. We assume no liability whatsoever for broken pages.


Alternative Proxies:

Alternative Proxy

pFad Proxy

pFad v3 Proxy

pFad v4 Proxy