-
Notifications
You must be signed in to change notification settings - Fork 27
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
Changes from all commits
ee14d5b
72a543a
c2dcbba
f412a05
4017f92
4dbf604
00e8a03
8d87584
65dbcb9
97ba49b
90fb34a
a1d9d79
7f8bf86
8bb1586
e97af8d
26d6bfd
3efbdc1
0b98dc5
76cefb8
d31baf5
45bf499
5d7d6f4
084cdf2
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 |
---|---|---|
|
@@ -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) | ||
MEMBERSHIP_NAMES. | ||
map {|n| Net::LDAP::Filter.eq(n, entry.dn) }.reduce(:|) | ||
map {|n| Net::LDAP::Filter.eq(n, entry) }.reduce(:|) | ||
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. Reducing over the |
||
else | ||
MEMBERSHIP_NAMES. | ||
map {|n| Net::LDAP::Filter.pres(n) }. reduce(:|) | ||
map {|n| Net::LDAP::Filter.pres(n) }. reduce(:|) | ||
end | ||
end | ||
|
||
|
@@ -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 | ||
|
||
|
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 | ||
# | ||
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. This interface makes sense to me, but I'm not familiar with how it'll typically be called. If users are typically working with 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. 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. @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 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. 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 |
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 | ||
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. 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. 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. I stopped doing that as much when I found out that I'm happy with 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. TIL. Do you add a custom message along with the raise NoMethodError.new "method #x required" 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. 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 | ||
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. Make this method 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. 👌 |
||
private :domains | ||
end | ||
end | ||
end | ||
end |
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? | ||
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. 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. 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. Unless I'm mistaken, 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. |
||
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 | ||
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. Will this be used by other adaptors? No need to push it up to the Base class just yet, I'm just curious. 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. 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 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. 👍 |
||
end | ||
end | ||
end | ||
end |
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) | ||
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. Should 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. Validators should be reused. I kept 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. 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 | ||
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. 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 |
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.
A better variable name here might be
dn
since you extract it from an entry. Also, what do you think of making the parameter nameentry_or_dn
to reflect it accepting two types?