diff --git a/.travis.yml b/.travis.yml
index 8f17123..d9fd02b 100644
--- a/.travis.yml
+++ b/.travis.yml
@@ -1,7 +1,30 @@
language: ruby
rvm:
- - 1.9.3
- - 2.1.0
+ - 2.0.0
+ - 2.1.0
+env:
+ - TESTENV=openldap
+ - TESTENV=apacheds
+
+# https://docs.travis-ci.com/user/hosts/
+addons:
+ hosts:
+ - ad1.ghe.dev
+ - ad2.ghe.dev
+
+before_install:
+ - echo "deb http://ftp.br.debian.org/debian stable main" | sudo tee -a /etc/apt/sources.list
+ - sudo apt-get update
+
+install:
+ - if [ "$TESTENV" = "openldap" ]; then ./script/install-openldap; fi
+ - bundle install
+
+script:
+ - ./script/cibuild-$TESTENV
+
+matrix:
+ fast_finish: true
notifications:
email: false
diff --git a/CHANGELOG.md b/CHANGELOG.md
new file mode 100644
index 0000000..e082762
--- /dev/null
+++ b/CHANGELOG.md
@@ -0,0 +1,58 @@
+# CHANGELOG
+
+# v1.10.1
+
+* Bump net-ldap to 0.16.0
+
+# v1.10.0
+
+* Bump net-ldap to 0.15.0 [#92](https://github.com/github/github-ldap/pull/92)
+
+# v1.9.0
+
+* Update net-ldap dependency to `~> 0.11.0` [#84](https://github.com/github/github-ldap/pull/84)
+
+# v1.8.2
+
+* Ignore case when comparing ActiveDirectory DNs [#82](https://github.com/github/github-ldap/pull/82)
+
+# v1.8.1
+
+* Expand supported ActiveDirectory capabilities to include Windows Server 2003 [#80](https://github.com/github/github-ldap/pull/80)
+
+# v1.8.0
+
+* Optimize Recursive *Member Search* strategy [#78](https://github.com/github/github-ldap/pull/78)
+
+# v1.7.1
+
+* Add Active Directory group filter [#75](https://github.com/github/github-ldap/pull/75)
+
+## v1.7.0
+
+* Accept `:depth` option for Recursive membership validator strategy instance [#73](https://github.com/github/github-ldap/pull/73)
+* Deprecate `depth` argument to `Recursive` membership validator `perform` method
+* Bump net-ldap dependency to 0.10.0 at minimum [#72](https://github.com/github/github-ldap/pull/72)
+
+## v1.6.0
+
+* Expose `GitHub::Ldap::Group.group?` for testing if entry is a group [#67](https://github.com/github/github-ldap/pull/67)
+* Add *Member Search* strategies [#64](https://github.com/github/github-ldap/pull/64) [#68](https://github.com/github/github-ldap/pull/68) [#69](https://github.com/github/github-ldap/pull/69)
+* Simplify *Member Search* and *Membership Validation* search strategy configuration, detection, and default behavior [#70](https://github.com/github/github-ldap/pull/70)
+
+## v1.5.0
+
+* Automatically detect membership validator strategy by default [#58](https://github.com/github/github-ldap/pull/58) [#62](https://github.com/github/github-ldap/pull/62)
+* Document local integration testing with Active Directory [#61](https://github.com/github/github-ldap/pull/61)
+
+## v1.4.0
+
+* Document constructor options [#57](https://github.com/github/github-ldap/pull/57)
+* [CI] Add Vagrant box for running tests against OpenLDAP locally [#55](https://github.com/github/github-ldap/pull/55)
+* Run all tests, including those in subdirectories [#54](https://github.com/github/github-ldap/pull/54)
+* Add ActiveDirectory membership validator [#52](https://github.com/github/github-ldap/pull/52)
+* Merge dev-v2 branch into master [#50](https://github.com/github/github-ldap/pull/50)
+* Pass through search options for GitHub::Ldap::Domain#user? [#51](https://github.com/github/github-ldap/pull/51)
+* Fix membership validation tests [#49](https://github.com/github/github-ldap/pull/49)
+* Add CI build for OpenLDAP integration [#48](https://github.com/github/github-ldap/pull/48)
+* Membership Validators [#45](https://github.com/github/github-ldap/pull/45)
diff --git a/Gemfile b/Gemfile
index ab76291..a409814 100644
--- a/Gemfile
+++ b/Gemfile
@@ -2,3 +2,11 @@ source 'https://rubygems.org'
# Specify your gem's dependencies in github-ldap.gemspec
gemspec
+
+group :test, :development do
+ gem "byebug", :platforms => [:mri_20, :mri_21]
+end
+
+group :test do
+ gem "mocha"
+end
diff --git a/README.md b/README.md
index 66f7073..eb5fb01 100644
--- a/README.md
+++ b/README.md
@@ -1,4 +1,4 @@
-
+
# Github::Ldap
@@ -28,6 +28,7 @@ There are a few configuration options required to use this adapter:
* host: is the host address where the ldap server lives.
* port: is the port where the ldap server lives.
+* hosts: (optional) an enumerable of pairs of hosts and corresponding ports with which to attempt opening connections (default [[host, port]]). Overrides host and port if set.
* encryption: is the encryption protocol, disabled by default. The valid options are `ssl` and `tls`.
* uid: is the field name in the ldap server used to authenticate your users, in ActiveDirectory this is `sAMAccountName`.
@@ -42,6 +43,8 @@ Initialize a new adapter using those required options:
ldap = GitHub::Ldap.new options
```
+See GitHub::Ldap#initialize for additional options.
+
### Querying
Searches are performed against an individual domain base, so the first step is to get a new `GitHub::Ldap::Domain` object for the connection:
@@ -128,3 +131,15 @@ end
3. Commit your changes (`git commit -am 'Add some feature'`)
4. Push to the branch (`git push origin my-new-feature`)
5. Create new Pull Request
+
+## Releasing
+
+This section is for gem maintainers to cut a new version of the gem. See
+[jch/release-scripts](https://github.com/jch/release-scripts) for original
+source of release scripts.
+
+* Create a new branch from `master` named `release-x.y.z`, where `x.y.z` is the version to be released
+* Update `github-ldap.gemspec` to x.y.z following [semver](http://semver.org)
+* Run `script/changelog` and paste the draft into `CHANGELOG.md`. Edit as needed
+* Create pull request to solict feedback
+* After merging the pull request, on the master branch, run `script/release`
diff --git a/Rakefile b/Rakefile
index 940d70f..5b19c5a 100644
--- a/Rakefile
+++ b/Rakefile
@@ -3,7 +3,7 @@ require 'rake/testtask'
Rake::TestTask.new do |t|
t.libs << "test"
- t.pattern = "test/*_test.rb"
+ t.pattern = "test/**/*_test.rb"
end
task :default => :test
diff --git a/github-ldap.gemspec b/github-ldap.gemspec
index 65c7c70..a2dad47 100644
--- a/github-ldap.gemspec
+++ b/github-ldap.gemspec
@@ -2,11 +2,11 @@
Gem::Specification.new do |spec|
spec.name = "github-ldap"
- spec.version = "1.3.6"
- spec.authors = ["David Calavera"]
- spec.email = ["david.calavera@gmail.com"]
- spec.description = %q{Ldap authentication for humans}
- spec.summary = %q{Ldap client authentication wrapper without all the boilerplate}
+ spec.version = "1.10.1"
+ spec.authors = ["David Calavera", "Matt Todd"]
+ spec.email = ["david.calavera@gmail.com", "chiology@gmail.com"]
+ spec.description = %q{LDAP authentication for humans}
+ spec.summary = %q{LDAP client authentication wrapper without all the boilerplate}
spec.homepage = "https://github.com/github/github-ldap"
spec.license = "MIT"
@@ -15,7 +15,7 @@ Gem::Specification.new do |spec|
spec.test_files = spec.files.grep(%r{^(test|spec|features)/})
spec.require_paths = ["lib"]
- spec.add_dependency 'net-ldap', '~> 0.8.0'
+ spec.add_dependency 'net-ldap', '> 0.16.0'
spec.add_development_dependency "bundler", "~> 1.3"
spec.add_development_dependency 'ladle'
diff --git a/lib/github/ldap.rb b/lib/github/ldap.rb
index 9844ceb..33e6627 100644
--- a/lib/github/ldap.rb
+++ b/lib/github/ldap.rb
@@ -1,19 +1,31 @@
+require 'net/ldap'
+require 'forwardable'
+
+require 'github/ldap/filter'
+require 'github/ldap/domain'
+require 'github/ldap/group'
+require 'github/ldap/posix_group'
+require 'github/ldap/virtual_group'
+require 'github/ldap/virtual_attributes'
+require 'github/ldap/instrumentation'
+require 'github/ldap/member_search'
+require 'github/ldap/membership_validators'
+require 'github/ldap/user_search/default'
+require 'github/ldap/user_search/active_directory'
+require 'github/ldap/connection_cache'
+require 'github/ldap/referral_chaser'
+require 'github/ldap/url'
+
module GitHub
class Ldap
- require 'net/ldap'
- require 'forwardable'
- require 'github/ldap/filter'
- require 'github/ldap/domain'
- require 'github/ldap/group'
- require 'github/ldap/posix_group'
- require 'github/ldap/virtual_group'
- require 'github/ldap/virtual_attributes'
- require 'github/ldap/instrumentation'
-
include Instrumentation
extend Forwardable
+ # Internal: The capability required to use ActiveDirectory features.
+ # See: http://msdn.microsoft.com/en-us/library/cc223359.aspx.
+ ACTIVE_DIRECTORY_V51_OID = "1.2.840.113556.1.4.1670".freeze
+
# Utility method to get the last operation result with a human friendly message.
#
# Returns an OpenStruct with `code` and `message`.
@@ -31,16 +43,58 @@ class Ldap
#
# Returns the return value of the block.
def_delegator :@connection, :open
+ def_delegator :@connection, :host
attr_reader :uid, :search_domains, :virtual_attributes,
- :instrumentation_service
+ :membership_validator,
+ :member_search_strategy,
+ :instrumentation_service,
+ :user_search_strategy,
+ :connection,
+ :admin_user,
+ :admin_password,
+ :port
+ # Build a new GitHub::Ldap instance
+ #
+ # ## Connection
+ #
+ # host: required string ldap server host address
+ # port: required string or number ldap server port
+ # hosts: an enumerable of pairs of hosts and corresponding ports with
+ # which to attempt opening connections (default [[host, port]]). Overrides
+ # host and port if set.
+ # encryption: optional string. `ssl` or `tls`. nil by default
+ # tls_options: optional hash with TLS options for encrypted connections.
+ # Empty by default. See http://ruby-doc.org/stdlib/libdoc/openssl/rdoc/OpenSSL/SSL/SSLContext.html
+ # for available values
+ # admin_user: optional string ldap administrator user dn for authentication
+ # admin_password: optional string ldap administrator user password
+ #
+ # ## Behavior
+ #
+ # uid: optional field name used to authenticate users. Defaults to `sAMAccountName` (what ActiveDirectory uses)
+ # virtual_attributes: optional. boolean true to use server's virtual attributes. Hash to specify custom mapping. Default false.
+ # recursive_group_search_fallback: optional boolean whether membership checks should recurse into nested groups when virtual attributes aren't enabled. Default false.
+ # posix_support: optional boolean `posixGroup` support. Default true.
+ # search_domains: optional array of string bases to search through
+ #
+ # ## Diagnostics
+ #
+ # instrumentation_service: optional ActiveSupport::Notifications compatible object
+ #
def initialize(options = {})
@uid = options[:uid] || "sAMAccountName"
+ # Keep a reference to these as default auth for a Global Catalog if needed
+ @admin_user = options[:admin_user]
+ @admin_password = options[:admin_password]
+ @port = options[:port]
+
@connection = Net::LDAP.new({
host: options[:host],
port: options[:port],
+ hosts: options[:hosts],
instrumentation_service: options[:instrumentation_service]
})
@@ -48,7 +102,7 @@ def initialize(options = {})
@connection.authenticate(options[:admin_user], options[:admin_password])
end
- if encryption = check_encryption(options[:encryption])
+ if encryption = check_encryption(options[:encryption], options[:tls_options])
@connection.encryption(encryption)
end
@@ -64,6 +118,12 @@ def initialize(options = {})
# when a base is not explicitly provided.
@search_domains = Array(options[:search_domains])
+ # configure both the membership validator and the member search strategies
+ configure_search_strategy(options[:search_strategy])
+
+ # configure the strategy used by Domain#user? to look up a user entry for login
+ configure_user_search_strategy(options[:user_search_strategy])
+
# enables instrumenting queries
@instrumentation_service = options[:instrumentation_service]
end
@@ -159,19 +219,38 @@ def search(options, &block)
end
end
+ # Internal: Searches the host LDAP server's Root DSE for capabilities and
+ # extensions.
+ #
+ # Returns a Net::LDAP::Entry object.
+ def capabilities
+ @capabilities ||=
+ instrument "capabilities.github_ldap" do |payload|
+ begin
+ @connection.search_root_dse
+ rescue Net::LDAP::Error => error
+ payload[:error] = error
+ # stubbed result
+ Net::LDAP::Entry.new
+ end
+ end
+ end
+
# Internal - Determine whether to use encryption or not.
#
# encryption: is the encryption method, either 'ssl', 'tls', 'simple_tls' or 'start_tls'.
+ # tls_options: is the options hash for tls encryption method
#
# Returns the real encryption type.
- def check_encryption(encryption)
+ def check_encryption(encryption, tls_options = {})
return unless encryption
+ tls_options ||= {}
case encryption.downcase.to_sym
when :ssl, :simple_tls
- :simple_tls
+ { method: :simple_tls, tls_options: tls_options }
when :tls, :start_tls
- :start_tls
+ { method: :start_tls, tls_options: tls_options }
end
end
@@ -191,5 +270,101 @@ def configure_virtual_attributes(attributes)
VirtualAttributes.new(false)
end
end
+
+ # Internal: Configure the member search and membership validation strategies.
+ #
+ # TODO: Inline the logic in these two methods here.
+ #
+ # Returns nothing.
+ def configure_search_strategy(strategy = nil)
+ # configure which strategy should be used to validate user membership
+ configure_membership_validation_strategy(strategy)
+
+ # configure which strategy should be used for member search
+ configure_member_search_strategy(strategy)
+ end
+
+ # Internal: Configure the membership validation strategy.
+ #
+ # If no known strategy is provided, detects ActiveDirectory capabilities or
+ # falls back to the Recursive strategy by default.
+ #
+ # Returns the membership validator strategy Class.
+ def configure_membership_validation_strategy(strategy = nil)
+ @membership_validator =
+ case strategy.to_s
+ when "classic"
+ GitHub::Ldap::MembershipValidators::Classic
+ when "recursive"
+ GitHub::Ldap::MembershipValidators::Recursive
+ when "active_directory"
+ GitHub::Ldap::MembershipValidators::ActiveDirectory
+ else
+ # fallback to detection, defaulting to recursive strategy
+ if active_directory_capability?
+ GitHub::Ldap::MembershipValidators::ActiveDirectory
+ else
+ GitHub::Ldap::MembershipValidators::Recursive
+ end
+ end
+ end
+
+ # Internal: Set the user search strategy that will be used by
+ # Domain#user?.
+ #
+ # strategy - Can be either 'default' or 'global_catalog'.
+ # 'default' strategy will search the configured
+ # domain controller with a search base relative
+ # to the controller's domain context.
+ # 'global_catalog' will search the entire forest
+ # using Active Directory's Global Catalog
+ # functionality.
+ def configure_user_search_strategy(strategy)
+ @user_search_strategy =
+ case strategy.to_s
+ when "default"
+ GitHub::Ldap::UserSearch::Default.new(self)
+ when "global_catalog"
+ GitHub::Ldap::UserSearch::ActiveDirectory.new(self)
+ else
+ GitHub::Ldap::UserSearch::Default.new(self)
+ end
+ end
+
+ # Internal: Configure the member search strategy.
+ #
+ #
+ # If no known strategy is provided, detects ActiveDirectory capabilities or
+ # falls back to the Recursive strategy by default.
+ #
+ # Returns the selected strategy Class.
+ def configure_member_search_strategy(strategy = nil)
+ @member_search_strategy =
+ case strategy.to_s
+ when "classic"
+ GitHub::Ldap::MemberSearch::Classic
+ when "recursive"
+ GitHub::Ldap::MemberSearch::Recursive
+ when "active_directory"
+ GitHub::Ldap::MemberSearch::ActiveDirectory
+ else
+ # fallback to detection, defaulting to recursive strategy
+ if active_directory_capability?
+ GitHub::Ldap::MemberSearch::ActiveDirectory
+ else
+ GitHub::Ldap::MemberSearch::Recursive
+ end
+ end
+ end
+
+ # Internal: Detect whether the LDAP host is an ActiveDirectory server.
+ #
+ # See: http://msdn.microsoft.com/en-us/library/cc223359.aspx.
+ #
+ # Returns true if the host is an ActiveDirectory server, false otherwise.
+ def active_directory_capability?
+ capabilities[:supportedcapabilities].include?(ACTIVE_DIRECTORY_V51_OID)
+ end
+ private :active_directory_capability?
end
end
diff --git a/lib/github/ldap/connection_cache.rb b/lib/github/ldap/connection_cache.rb
new file mode 100644
index 0000000..d2feab9
--- /dev/null
+++ b/lib/github/ldap/connection_cache.rb
@@ -0,0 +1,26 @@
+module GitHub
+ class Ldap
+
+ # A simple cache of GitHub::Ldap objects to prevent creating multiple
+ # instances of connections that point to the same URI/host.
+ class ConnectionCache
+
+ # Public - Create or return cached instance of GitHub::Ldap created with options,
+ # where the cache key is the value of options[:host].
+ #
+ # options - Initialization attributes suitable for creating a new connection with
+ # GitHub::Ldap.new(options)
+ #
+ # Returns true or false.
+ def self.get_connection(options={})
+ @cache ||= self.new
+ @cache.get_connection(options)
+ end
+
+ def get_connection(options)
+ @connections ||= {}
+ @connections[options[:host]] ||= GitHub::Ldap.new(options)
+ end
+ end
+ end
+end
diff --git a/lib/github/ldap/domain.rb b/lib/github/ldap/domain.rb
index 8085c44..07af950 100644
--- a/lib/github/ldap/domain.rb
+++ b/lib/github/ldap/domain.rb
@@ -110,11 +110,12 @@ def valid_login?(login, password)
# Check if a user exists based in the `uid`.
#
# login: is the user's login
+ # search_options: Net::LDAP#search compatible options to pass through
#
# Returns the user if the login matches any `uid`.
# Returns nil if there are no matches.
- def user?(login)
- search(filter: login_filter(@uid, login), size: 1).first
+ def user?(login, search_options = {})
+ @ldap.user_search_strategy.perform(login, @base_name, @uid, search_options).first
end
# Check if a user can be bound with a password.
@@ -159,8 +160,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
end
end
end
diff --git a/lib/github/ldap/filter.rb b/lib/github/ldap/filter.rb
index a238642..6f62af3 100644
--- a/lib/github/ldap/filter.rb
+++ b/lib/github/ldap/filter.rb
@@ -3,7 +3,8 @@ class Ldap
module Filter
ALL_GROUPS_FILTER = Net::LDAP::Filter.eq("objectClass", "groupOfNames") |
Net::LDAP::Filter.eq("objectClass", "groupOfUniqueNames") |
- Net::LDAP::Filter.eq("objectClass", "posixGroup")
+ Net::LDAP::Filter.eq("objectClass", "posixGroup") |
+ Net::LDAP::Filter.eq("objectClass", "group")
MEMBERSHIP_NAMES = %w(member uniqueMember)
@@ -20,16 +21,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(:|)
else
MEMBERSHIP_NAMES.
- map {|n| Net::LDAP::Filter.pres(n) }. reduce(:|)
+ map {|n| Net::LDAP::Filter.pres(n) }. reduce(:|)
end
end
@@ -41,10 +44,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
diff --git a/lib/github/ldap/group.rb b/lib/github/ldap/group.rb
index 25066a0..633e034 100644
--- a/lib/github/ldap/group.rb
+++ b/lib/github/ldap/group.rb
@@ -69,6 +69,11 @@ def member_names
end
end
+ # Internal: Returns true if the object class(es) provided match a group's.
+ def group?(object_class)
+ self.class.group?(object_class)
+ end
+
# Internal - Check if an object class includes the member names
# Use `&` rathen than `include?` because both are arrays.
#
@@ -76,7 +81,7 @@ def member_names
# will fail to match correctly unless we also downcase our group classes.
#
# Returns true if the object class includes one of the group class names.
- def group?(object_class)
+ def self.group?(object_class)
!(GROUP_CLASS_NAMES.map(&:downcase) & object_class.map(&:downcase)).empty?
end
diff --git a/lib/github/ldap/member_search.rb b/lib/github/ldap/member_search.rb
new file mode 100644
index 0000000..d051268
--- /dev/null
+++ b/lib/github/ldap/member_search.rb
@@ -0,0 +1,4 @@
+require 'github/ldap/member_search/base'
+require 'github/ldap/member_search/classic'
+require 'github/ldap/member_search/recursive'
+require 'github/ldap/member_search/active_directory'
diff --git a/lib/github/ldap/member_search/active_directory.rb b/lib/github/ldap/member_search/active_directory.rb
new file mode 100644
index 0000000..f78085a
--- /dev/null
+++ b/lib/github/ldap/member_search/active_directory.rb
@@ -0,0 +1,60 @@
+module GitHub
+ class Ldap
+ module MemberSearch
+ # Look up group members using the ActiveDirectory "in chain" matching rule.
+ #
+ # The 1.2.840.113556.1.4.1941 matching rule (LDAP_MATCHING_RULE_IN_CHAIN)
+ # "walks the chain of ancestry in objects all the way to the root until
+ # it finds a match".
+ # Source: http://msdn.microsoft.com/en-us/library/aa746475(v=vs.85).aspx
+ #
+ # This means we have an efficient method of searching for group members,
+ # even in nested groups, performed on the server side.
+ class ActiveDirectory < Base
+ OID = "1.2.840.113556.1.4.1941"
+
+ # Internal: The default attributes to query for.
+ # NOTE: We technically don't need any by default, but if we left this
+ # empty, we'd be querying for *all* attributes which is less ideal.
+ DEFAULT_ATTRS = %w(objectClass)
+
+ # Internal: The attributes to search for.
+ attr_reader :attrs
+
+ # Public: Instantiate new search strategy.
+ #
+ # - ldap: GitHub::Ldap object
+ # - options: Hash of options
+ #
+ # NOTE: This overrides default behavior to configure attrs`.
+ def initialize(ldap, options = {})
+ super
+ @attrs = Array(options[:attrs]).concat DEFAULT_ATTRS
+ end
+
+ # Public: Performs search for group members, including groups and
+ # members of subgroups, using ActiveDirectory's "in chain" matching
+ # rule.
+ #
+ # Returns Array of Net::LDAP::Entry objects.
+ def perform(group)
+ filter = member_of_in_chain_filter(group)
+
+ # search for all members of the group, including subgroups, by
+ # searching "in chain".
+ domains.each_with_object([]) do |domain, members|
+ members.concat domain.search(filter: filter, attributes: attrs)
+ end
+ end
+
+ # Internal: Constructs a member filter using the "in chain"
+ # extended matching rule afforded by ActiveDirectory.
+ #
+ # Returns a Net::LDAP::Filter object.
+ def member_of_in_chain_filter(entry)
+ Net::LDAP::Filter.ex("memberOf:#{OID}", entry.dn)
+ end
+ end
+ end
+ end
+end
diff --git a/lib/github/ldap/member_search/base.rb b/lib/github/ldap/member_search/base.rb
new file mode 100644
index 0000000..e3491f1
--- /dev/null
+++ b/lib/github/ldap/member_search/base.rb
@@ -0,0 +1,34 @@
+module GitHub
+ class Ldap
+ module MemberSearch
+ class Base
+
+ # 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
+ def initialize(ldap, options = {})
+ @ldap = ldap
+ @options = options
+ end
+
+ # Public: Abstract: Performs search for group members.
+ #
+ # Returns Array of Net::LDAP::Entry objects.
+ # def perform(entry)
+ # end
+
+ # 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
diff --git a/lib/github/ldap/member_search/classic.rb b/lib/github/ldap/member_search/classic.rb
new file mode 100644
index 0000000..47bb7a1
--- /dev/null
+++ b/lib/github/ldap/member_search/classic.rb
@@ -0,0 +1,18 @@
+module GitHub
+ class Ldap
+ module MemberSearch
+ # Look up group members using the existing `Group#members` and
+ # `Group#subgroups` API.
+ class Classic < Base
+ # 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
diff --git a/lib/github/ldap/member_search/recursive.rb b/lib/github/ldap/member_search/recursive.rb
new file mode 100644
index 0000000..a36aa4d
--- /dev/null
+++ b/lib/github/ldap/member_search/recursive.rb
@@ -0,0 +1,158 @@
+module GitHub
+ class Ldap
+ module MemberSearch
+ # 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 < Base
+ include Filter
+
+ DEFAULT_MAX_DEPTH = 9
+ DEFAULT_ATTRS = %w(member uniqueMember memberUid)
+
+ # Internal: The maximum depth to search for members.
+ attr_reader :depth
+
+ # Internal: The attributes to search for.
+ attr_reader :attrs
+
+ # Public: Instantiate new search strategy.
+ #
+ # - ldap: GitHub::Ldap object
+ # - options: Hash of options
+ #
+ # NOTE: This overrides default behavior to configure `depth` and `attrs`.
+ def initialize(ldap, options = {})
+ super
+ @depth = options[:depth] || DEFAULT_MAX_DEPTH
+ @attrs = Array(options[:attrs]).concat DEFAULT_ATTRS
+ end
+
+ # Public: Performs search for group members, including groups and
+ # members of subgroups recursively.
+ #
+ # Returns Array of Net::LDAP::Entry objects.
+ def perform(group)
+ # track groups found
+ found = Hash.new
+
+ # track all DNs searched for (so we don't repeat searches)
+ searched = Set.new
+
+ # if this is a posixGroup, return members immediately (no nesting)
+ uids = member_uids(group)
+ return entries_by_uid(uids) if uids.any?
+
+ # track group
+ searched << group.dn
+ found[group.dn] = group
+
+ # pull out base group's member DNs
+ dns = member_dns(group)
+
+ # search for base group's subgroups
+ groups = dns.each_with_object([]) do |dn, groups|
+ groups.concat find_groups_by_dn(dn)
+ searched << dn
+ end
+
+ # track found groups
+ groups.each { |g| found[g.dn] = g }
+
+ # recursively find subgroups
+ unless groups.empty?
+ depth.times do |n|
+ # pull out subgroups' member DNs to search through
+ sub_dns = groups.each_with_object([]) do |subgroup, sub_dns|
+ sub_dns.concat member_dns(subgroup)
+ end
+
+ # filter out if already searched for
+ sub_dns.reject! { |dn| searched.include?(dn) }
+
+ # give up if there's nothing else to search for
+ break if sub_dns.empty?
+
+ # search for subgroups
+ subgroups = sub_dns.each_with_object([]) do |dn, subgroups|
+ subgroups.concat find_groups_by_dn(dn)
+ searched << dn
+ end
+
+ # give up if there were no subgroups found
+ break if subgroups.empty?
+
+ # track found subgroups
+ subgroups.each { |g| found[g.dn] = g }
+
+ # descend another level
+ groups = subgroups
+ end
+ end
+
+ # entries to return
+ entries = []
+
+ # collect all member DNs, discarding dupes and subgroup DNs
+ members = found.values.each_with_object([]) do |group, dns|
+ entries << group
+ dns.concat member_dns(group)
+ end.uniq.reject { |dn| found.key?(dn) }
+
+ # wrap member DNs in Net::LDAP::Entry objects
+ entries.concat members.map! { |dn| Net::LDAP::Entry.new(dn) }
+
+ entries
+ end
+
+ # Internal: Search for Groups by DN.
+ #
+ # Given a Distinguished Name (DN) String value, find the Group entry
+ # that matches it. The DN may map to a `person` entry, but we want to
+ # filter those out.
+ #
+ # This will find zero or one entry most of the time, but it's not
+ # guaranteed so we account for the possibility of more.
+ #
+ # This method is intended to be used with `Array#concat` by the caller.
+ #
+ # Returns an Array of zero or more Net::LDAP::Entry objects.
+ def find_groups_by_dn(dn)
+ ldap.search \
+ base: dn,
+ scope: Net::LDAP::SearchScope_BaseObject,
+ attributes: attrs,
+ filter: ALL_GROUPS_FILTER
+ end
+ private :find_groups_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
+ 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"]
+ end
+ private :member_uids
+ end
+ end
+ end
+end
diff --git a/lib/github/ldap/membership_validators.rb b/lib/github/ldap/membership_validators.rb
new file mode 100644
index 0000000..c629a37
--- /dev/null
+++ b/lib/github/ldap/membership_validators.rb
@@ -0,0 +1,4 @@
+require 'github/ldap/membership_validators/base'
+require 'github/ldap/membership_validators/classic'
+require 'github/ldap/membership_validators/recursive'
+require 'github/ldap/membership_validators/active_directory'
diff --git a/lib/github/ldap/membership_validators/active_directory.rb b/lib/github/ldap/membership_validators/active_directory.rb
new file mode 100644
index 0000000..ff4e4fc
--- /dev/null
+++ b/lib/github/ldap/membership_validators/active_directory.rb
@@ -0,0 +1,65 @@
+module GitHub
+ class Ldap
+ module MembershipValidators
+ ATTRS = %w(dn)
+ OID = "1.2.840.113556.1.4.1941"
+
+ # Validates membership using the ActiveDirectory "in chain" matching rule.
+ #
+ # The 1.2.840.113556.1.4.1941 matching rule (LDAP_MATCHING_RULE_IN_CHAIN)
+ # "walks the chain of ancestry in objects all the way to the root until
+ # it finds a match".
+ # Source: http://msdn.microsoft.com/en-us/library/aa746475(v=vs.85).aspx
+ #
+ # This means we have an efficient method of searching membership even in
+ # nested groups, performed on the server side.
+ class ActiveDirectory < Base
+ def perform(entry)
+ # short circuit validation if there are no groups to check against
+ return true if groups.empty?
+
+ # search for the entry on the condition that the entry is a member
+ # of one of the groups or their subgroups.
+ #
+ # Sets the entry to the base and scopes the search to the base,
+ # according to the source documentation, found here:
+ # http://msdn.microsoft.com/en-us/library/aa746475(v=vs.85).aspx
+ #
+ # Use ReferralChaser to chase any potential referrals for an entry that may be owned by a different
+ # domain controller.
+ matched = referral_chaser.search \
+ filter: membership_in_chain_filter(entry),
+ base: entry.dn,
+ scope: Net::LDAP::SearchScope_BaseObject,
+ return_referrals: true,
+ attributes: ATTRS
+
+ # membership validated if entry was matched and returned as a result
+ # Active Directory DNs are case-insensitive
+ Array(matched).map { |m| m.dn.downcase }.include?(entry.dn.downcase)
+ end
+
+ def referral_chaser
+ @referral_chaser ||= GitHub::Ldap::ReferralChaser.new(@ldap)
+ end
+
+ # Internal: Constructs a membership filter using the "in chain"
+ # extended matching rule afforded by ActiveDirectory.
+ #
+ # Returns a Net::LDAP::Filter object.
+ def membership_in_chain_filter(entry)
+ group_dns.map do |dn|
+ Net::LDAP::Filter.ex("memberOf:#{OID}", dn)
+ end.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
diff --git a/lib/github/ldap/membership_validators/base.rb b/lib/github/ldap/membership_validators/base.rb
new file mode 100644
index 0000000..be378d1
--- /dev/null
+++ b/lib/github/ldap/membership_validators/base.rb
@@ -0,0 +1,39 @@
+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
+ # - options: Hash of options
+ def initialize(ldap, groups, options = {})
+ @ldap = ldap
+ @groups = groups
+ @options = options
+ end
+
+ # Abstract: Performs the membership validation check.
+ #
+ # Returns Boolean whether the entry's membership is validated or not.
+ # def perform(entry)
+ # end
+
+ # 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
diff --git a/lib/github/ldap/membership_validators/classic.rb b/lib/github/ldap/membership_validators/classic.rb
new file mode 100644
index 0000000..7fafb3d
--- /dev/null
+++ b/lib/github/ldap/membership_validators/classic.rb
@@ -0,0 +1,34 @@
+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)
+ # short circuit validation if there are no groups to check against
+ return true if groups.empty?
+
+ domains.each do |domain|
+ membership = domain.membership(entry, group_names)
+
+ if !membership.empty?
+ 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
+ end
+ end
+ end
+end
diff --git a/lib/github/ldap/membership_validators/recursive.rb b/lib/github/ldap/membership_validators/recursive.rb
new file mode 100644
index 0000000..3b78545
--- /dev/null
+++ b/lib/github/ldap/membership_validators/recursive.rb
@@ -0,0 +1,117 @@
+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)
+
+ # Internal: The maximum depth to search for membership.
+ attr_reader :depth
+
+ # Public: Instantiate new search strategy.
+ #
+ # - ldap: GitHub::Ldap object
+ # - groups: Array of Net::LDAP::Entry group objects
+ # - options: Hash of options
+ # depth: Integer limit of recursion
+ #
+ # NOTE: This overrides default behavior to configure `depth`.
+ def initialize(ldap, groups, options = {})
+ super
+ @depth = options[:depth] || DEFAULT_MAX_DEPTH
+ end
+
+ def perform(entry, depth_override = nil)
+ if depth_override
+ warn "DEPRECATION WARNING: Calling Recursive#perform with a second argument is deprecated."
+ warn "Usage:"
+ warn " strategy = GitHub::Ldap::MembershipValidators::Recursive.new \\"
+ warn " ldap, depth: 5"
+ warn " strategy#perform(entry)"
+ end
+
+ # short circuit validation if there are no groups to check against
+ return true if groups.empty?
+
+ 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_override || 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
+
+ # 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
diff --git a/lib/github/ldap/referral_chaser.rb b/lib/github/ldap/referral_chaser.rb
new file mode 100644
index 0000000..4811c51
--- /dev/null
+++ b/lib/github/ldap/referral_chaser.rb
@@ -0,0 +1,98 @@
+module GitHub
+ class Ldap
+
+ # This class adds referral chasing capability to a GitHub::Ldap connection.
+ #
+ # See: https://technet.microsoft.com/en-us/library/cc978014.aspx
+ # http://www.umich.edu/~dirsvcs/ldap/doc/other/ldap-ref.html
+ #
+ class ReferralChaser
+
+ # Public - Creates a ReferralChaser that decorates an instance of GitHub::Ldap
+ # with additional functionality to the #search method, allowing it to chase
+ # any referral entries and aggregate the results into a single response.
+ #
+ # connection - The instance of GitHub::Ldap to use for searching. Will use
+ # the connection's authentication, (admin_user and admin_password) as credentials
+ # for connecting to referred domain controllers.
+ def initialize(connection)
+ @connection = connection
+ @admin_user = connection.admin_user
+ @admin_password = connection.admin_password
+ @port = connection.port
+ end
+
+ # Public - Search the domain controller represented by this instance's connection.
+ # If a referral is returned, search only one of the domain controllers indicated
+ # by the referral entries, per RFC 4511 (https://tools.ietf.org/html/rfc4511):
+ #
+ # "If the client wishes to progress the operation, it contacts one of
+ # the supported services found in the referral. If multiple URIs are
+ # present, the client assumes that any supported URI may be used to
+ # progress the operation."
+ #
+ # options - is a hash with the same options that Net::LDAP::Connection#search supports.
+ # Referral searches will use the given options, but will replace options[:base]
+ # with the referral URL's base search dn.
+ #
+ # Does not take a block argument as GitHub::Ldap and Net::LDAP::Connection#search do.
+ #
+ # Will not recursively follow any subsequent referrals.
+ #
+ # Returns an Array of Net::LDAP::Entry.
+ def search(options)
+ search_results = []
+ referral_entries = []
+
+ search_results = connection.search(options) do |entry|
+ if entry && entry[:search_referrals]
+ referral_entries << entry
+ end
+ end
+
+ unless referral_entries.empty?
+ entry = referral_entries.first
+ referral_string = entry[:search_referrals].first
+ if GitHub::Ldap::URL.valid?(referral_string)
+ referral = Referral.new(referral_string, admin_user, admin_password, port)
+ search_results = referral.search(options)
+ end
+ end
+
+ Array(search_results)
+ end
+
+ private
+
+ attr_reader :connection, :admin_user, :admin_password, :port
+
+ # Represents a referral entry from an LDAP search result. Constructs a corresponding
+ # GitHub::Ldap object from the paramaters on the referral_url and provides a #search
+ # method to continue the search on the referred domain.
+ class Referral
+ def initialize(referral_url, admin_user, admin_password, port=nil)
+ url = GitHub::Ldap::URL.new(referral_url)
+ @search_base = url.dn
+
+ connection_options = {
+ host: url.host,
+ port: port || url.port,
+ scope: url.scope,
+ admin_user: admin_user,
+ admin_password: admin_password
+ }
+
+ @connection = GitHub::Ldap::ConnectionCache.get_connection(connection_options)
+ end
+
+ # Search the referred domain controller with options, merging in the referred search
+ # base DN onto options[:base].
+ def search(options)
+ connection.search(options.merge(base: search_base))
+ end
+
+ attr_reader :search_base, :connection
+ end
+ end
+ end
+end
diff --git a/lib/github/ldap/server.rb b/lib/github/ldap/server.rb
index c2cf10c..c7f624a 100644
--- a/lib/github/ldap/server.rb
+++ b/lib/github/ldap/server.rb
@@ -38,6 +38,8 @@ def self.start_server(options = {})
@server_options[:domain] = @server_options[:user_domain]
@server_options[:tmpdir] ||= server_tmp
+ @server_options[:quiet] = false if @server_options[:verbose]
+
@ldap_server = Ladle::Server.new(@server_options)
@ldap_server.start
end
diff --git a/lib/github/ldap/url.rb b/lib/github/ldap/url.rb
new file mode 100644
index 0000000..5c733a7
--- /dev/null
+++ b/lib/github/ldap/url.rb
@@ -0,0 +1,87 @@
+module GitHub
+ class Ldap
+
+ # This class represents an LDAP URL
+ #
+ # See: https://tools.ietf.org/html/rfc4516#section-2
+ # https://docs.oracle.com/cd/E19957-01/817-6707/urls.html
+ #
+ class URL
+ extend Forwardable
+ SCOPES = {
+ "base" => Net::LDAP::SearchScope_BaseObject,
+ "one" => Net::LDAP::SearchScope_SingleLevel,
+ "sub" => Net::LDAP::SearchScope_WholeSubtree
+ }
+ SCOPES.default = Net::LDAP::SearchScope_BaseObject
+
+ attr_reader :dn, :attributes, :scope, :filter
+
+ def_delegators :@uri, :port, :host, :scheme
+
+ # Public - Creates a new GitHub::Ldap::URL object with :port, :host and :scheme
+ # delegated to a URI object parsed from url_string, and then parses the
+ # query params according to the LDAP specification.
+ #
+ # url_string - An LDAP URL string.
+ # returns - a GitHub::Ldap::URL with the following attributes:
+ # host - Name or IP of the LDAP server.
+ # port - The given port, defaults to 389.
+ # dn - The base search DN.
+ # attributes - The comma-delimited list of attributes to be returned.
+ # scope - The scope of the search.
+ # filter - Search filter to apply to entries within the specified scope of the search.
+ #
+ # Supported LDAP URL strings look like this, where sections in brackets are optional:
+ #
+ # ldap[s]://[hostport][/[dn[?[attributes][?[scope][?[filter]]]]]]
+ #
+ # where:
+ #
+ # hostport is a host name with an optional ":portnumber"
+ # dn is the base DN to be used for an LDAP search operation
+ # attributes is a comma separated list of attributes to be retrieved
+ # scope is one of these three strings: base one sub (default=base)
+ # filter is LDAP search filter as used in a call to ldap_search
+ #
+ # For example:
+ #
+ # ldap://dc4.ghe.local:456/CN=Maggie,DC=dc4,DC=ghe,DC=local?cn,mail?base?(cn=Charlie)
+ #
+ def initialize(url_string)
+ if !self.class.valid?(url_string)
+ raise InvalidLdapURLException.new("Invalid LDAP URL: #{url_string}")
+ end
+ @uri = URI(url_string)
+ @dn = URI.unescape(@uri.path.sub(/^\//, ""))
+ if @uri.query
+ @attributes, @scope, @filter = @uri.query.split("?")
+ end
+ end
+
+ def self.valid?(url_string)
+ url_string =~ URI::regexp && ["ldap", "ldaps"].include?(URI(url_string).scheme)
+ end
+
+ # Maps the returned scope value from the URL to one of Net::LDAP::Scopes
+ #
+ # The URL scope value can be one of:
+ # "base" - retrieves information only about the DN (base_dn) specified.
+ # "one" - retrieves information about entries one level below the DN (base_dn) specified. The base entry is not included in this scope.
+ # "sub" - retrieves information about entries at all levels below the DN (base_dn) specified. The base entry is included in this scope.
+ #
+ # Which will map to one of the following Net::LDAP::Scopes:
+ # SearchScope_BaseObject = 0
+ # SearchScope_SingleLevel = 1
+ # SearchScope_WholeSubtree = 2
+ #
+ # If no scope or an invalid scope is given, defaults to SearchScope_BaseObject
+ def net_ldap_scope
+ Net::LDAP::SearchScopes[SCOPES[scope]]
+ end
+
+ class InvalidLdapURLException < Exception; end
+ end
+ end
+end
+
diff --git a/lib/github/ldap/user_search/active_directory.rb b/lib/github/ldap/user_search/active_directory.rb
new file mode 100644
index 0000000..2bec4ad
--- /dev/null
+++ b/lib/github/ldap/user_search/active_directory.rb
@@ -0,0 +1,51 @@
+module GitHub
+ class Ldap
+ module UserSearch
+ class ActiveDirectory < Default
+
+ private
+
+ # Private - Overridden from base class to set the base to "", and use the
+ # Global Catalog to perform the user search.
+ def search(search_options)
+ Array(global_catalog_connection.search(search_options.merge(options)))
+ end
+
+ def global_catalog_connection
+ GlobalCatalog.connection(ldap)
+ end
+
+ # When doing a global search for a user's DN, set the search base to blank
+ def options
+ super.merge(base: "")
+ end
+ end
+
+ class GlobalCatalog < Net::LDAP
+ STANDARD_GC_PORT = 3268
+ LDAPS_GC_PORT = 3269
+
+ # Returns a connection to the Active Directory Global Catalog
+ #
+ # See: https://technet.microsoft.com/en-us/library/cc728188(v=ws.10).aspx
+ #
+ def self.connection(ldap)
+ @global_catalog_instance ||= begin
+ netldap = ldap.connection
+ # This is ugly, but Net::LDAP doesn't expose encryption or auth
+ encryption = netldap.instance_variable_get(:@encryption)
+ auth = netldap.instance_variable_get(:@auth)
+
+ new({
+ host: ldap.host,
+ instrumentation_service: ldap.instrumentation_service,
+ port: encryption ? LDAPS_GC_PORT : STANDARD_GC_PORT,
+ auth: auth,
+ encryption: encryption
+ })
+ end
+ end
+ end
+ end
+ end
+end
diff --git a/lib/github/ldap/user_search/default.rb b/lib/github/ldap/user_search/default.rb
new file mode 100644
index 0000000..2f1aa3f
--- /dev/null
+++ b/lib/github/ldap/user_search/default.rb
@@ -0,0 +1,40 @@
+module GitHub
+ class Ldap
+ module UserSearch
+ # The default user search strategy, mainly for allowing Domain#user? to
+ # search for a user on the configured domain controller, or use the Global
+ # Catalog to search across the entire Active Directory forest.
+ class Default
+ include Filter
+
+ def initialize(ldap)
+ @ldap = ldap
+ @options = {
+ :attributes => [],
+ :paged_searches_supported => true,
+ :size => 1
+ }
+ end
+
+ # Performs a normal search on the configured domain controller
+ # using the default base DN, uid, search_options
+ def perform(login, base_name, uid, search_options)
+ search_options[:filter] = login_filter(uid, login)
+ search_options[:base] = base_name
+ search(options.merge(search_options))
+ end
+
+ # The default search. This can be overridden by a child class
+ # like GitHub::Ldap::UserSearch::ActiveDirectory to change the
+ # scope of the search.
+ def search(options)
+ ldap.search(options)
+ end
+
+ private
+
+ attr_reader :options, :ldap
+ end
+ end
+ end
+end
diff --git a/script/changelog b/script/changelog
new file mode 100755
index 0000000..8df90b0
--- /dev/null
+++ b/script/changelog
@@ -0,0 +1,29 @@
+#!/usr/bin/env sh
+# Usage: script/changelog [-r ] [-b ] [-h ]
+#
+# repo: base string of GitHub repository url. e.g. "user_or_org/repository". Defaults to git remote url.
+# base: git ref to compare from. e.g. "v1.3.1". Defaults to latest git tag.
+# head: git ref to compare to. Defaults to "HEAD".
+#
+# Generate a changelog preview from pull requests merged between `base` and
+# `head`.
+#
+set -e
+
+[ $# -eq 0 ] && set -- --help
+
+# parse args
+repo=$(git remote -v | grep push | awk '{print $2}' | cut -d'/' -f4- | sed 's/\.git//')
+base=$(git tag -l | sort -n | tail -n 1)
+head="HEAD"
+api_url="https://api.github.com"
+
+echo "# $repo $base..$head"
+echo
+
+# get merged PR's. Better way is to query the API for these, but this is easier
+for pr in $(git log --oneline $base..$head | grep "Merge pull request" | awk '{gsub("#",""); print $5}')
+do
+ # frustrated with trying to pull out the right values, fell back to ruby
+ curl -s "$api_url/repos/$repo/pulls/$pr" | ruby -rjson -e 'pr=JSON.parse(STDIN.read); puts "* #{pr[%q(title)]} [##{pr[%q(number)]}](#{pr[%q(html_url)]})"'
+done
diff --git a/script/cibuild-apacheds b/script/cibuild-apacheds
new file mode 100755
index 0000000..6e02fa0
--- /dev/null
+++ b/script/cibuild-apacheds
@@ -0,0 +1,7 @@
+#!/usr/bin/env sh
+set -e
+set -x
+
+cd `dirname $0`/..
+
+bundle exec rake
diff --git a/script/cibuild-openldap b/script/cibuild-openldap
new file mode 100755
index 0000000..6e02fa0
--- /dev/null
+++ b/script/cibuild-openldap
@@ -0,0 +1,7 @@
+#!/usr/bin/env sh
+set -e
+set -x
+
+cd `dirname $0`/..
+
+bundle exec rake
diff --git a/script/install-openldap b/script/install-openldap
new file mode 100755
index 0000000..2deddad
--- /dev/null
+++ b/script/install-openldap
@@ -0,0 +1,42 @@
+#!/usr/bin/env sh
+set -e
+set -x
+
+BASE_PATH="$( cd `dirname $0`/../test/fixtures/openldap && pwd )"
+SEED_PATH="$( cd `dirname $0`/../test/fixtures/common && pwd )"
+
+DEBIAN_FRONTEND=noninteractive sudo -E apt-get install -y --force-yes slapd time ldap-utils
+
+sudo /etc/init.d/slapd stop
+
+TMPDIR=$(mktemp -d)
+cd $TMPDIR
+
+# Delete data and reconfigure.
+sudo rm -rf /etc/ldap/slapd.d/*
+sudo rm -rf /var/lib/ldap/*
+sudo slapadd -F /etc/ldap/slapd.d -b "cn=config" -l $BASE_PATH/slapd.conf.ldif
+# Load memberof and ref-int overlays and configure them.
+sudo slapadd -F /etc/ldap/slapd.d -b "cn=config" -l $BASE_PATH/memberof.ldif
+
+# Add base domain.
+sudo slapadd -F /etc/ldap/slapd.d < "ad1.ghe.dev"))
+ conn2 = GitHub::Ldap::ConnectionCache.get_connection(options.merge(:host => "ad1.ghe.dev"))
+ assert_equal conn1.object_id, conn2.object_id
+ end
+
+ def test_creates_new_connections_per_host
+ conn1 = GitHub::Ldap::ConnectionCache.get_connection(options.merge(:host => "ad1.ghe.dev"))
+ conn2 = GitHub::Ldap::ConnectionCache.get_connection(options.merge(:host => "ad2.ghe.dev"))
+ conn3 = GitHub::Ldap::ConnectionCache.get_connection(options.merge(:host => "ad2.ghe.dev"))
+ refute_equal conn1.object_id, conn2.object_id
+ assert_equal conn2.object_id, conn3.object_id
+ end
+end
diff --git a/test/domain_test.rb b/test/domain_test.rb
index 470e00d..4fc0dee 100644
--- a/test/domain_test.rb
+++ b/test/domain_test.rb
@@ -7,13 +7,13 @@ def setup
end
def test_user_valid_login
- user = @domain.valid_login?('calavera', 'passworD1')
- assert_equal 'uid=calavera,dc=github,dc=com', user.dn
+ assert user = @domain.valid_login?('user1', 'passworD1')
+ assert_equal 'uid=user1,ou=People,dc=github,dc=com', user.dn
end
def test_user_with_invalid_password
- assert !@domain.valid_login?('calavera', 'foo'),
- "Login `calavera` expected to be invalid with password `foo`"
+ assert !@domain.valid_login?('user1', 'foo'),
+ "Login `user1` expected to be invalid with password `foo`"
end
def test_user_with_invalid_login
@@ -22,115 +22,132 @@ def test_user_with_invalid_login
end
def test_groups_in_server
- assert_equal 2, @domain.groups(%w(Enterprise People)).size
+ assert_equal 2, @domain.groups(%w(ghe-users ghe-admins)).size
end
def test_user_in_group
- user = @domain.valid_login?('calavera', 'passworD1')
+ assert user = @domain.valid_login?('user1', 'passworD1')
- assert @domain.is_member?(user, %w(Enterprise People)),
- "Expected `Enterprise` or `Poeple` to include the member `#{user.dn}`"
+ assert @domain.is_member?(user, %w(ghe-users ghe-admins)),
+ "Expected `ghe-users` or `ghe-admins` to include the member `#{user.dn}`"
end
def test_user_not_in_different_group
- user = @domain.valid_login?('calavera', 'passworD1')
+ user = @domain.valid_login?('user1', 'passworD1')
- assert !@domain.is_member?(user, %w(People)),
- "Expected `Poeple` not to include the member `#{user.dn}`"
+ refute @domain.is_member?(user, %w(ghe-admins)),
+ "Expected `ghe-admins` not to include the member `#{user.dn}`"
end
def test_user_without_group
- user = @domain.valid_login?('ldaptest', 'secret')
+ user = @domain.valid_login?('groupless-user1', 'passworD1')
- assert !@domain.is_member?(user, %w(People)),
- "Expected `People` not to include the member `#{user.dn}`"
+ assert !@domain.is_member?(user, %w(all-users)),
+ "Expected `all-users` not to include the member `#{user.dn}`"
end
- def test_authenticate_doesnt_return_invalid_users
- user = @domain.authenticate!('calavera', 'passworD1')
- assert_equal 'uid=calavera,dc=github,dc=com', user.dn
+ def test_authenticate_returns_valid_users
+ user = @domain.authenticate!('user1', 'passworD1')
+ assert_equal 'uid=user1,ou=People,dc=github,dc=com', user.dn
end
def test_authenticate_doesnt_return_invalid_users
- assert !@domain.authenticate!('calavera', 'foo'),
+ refute @domain.authenticate!('user1', 'foo'),
"Expected `authenticate!` to not return an invalid user"
end
def test_authenticate_check_valid_user_and_groups
- user = @domain.authenticate!('calavera', 'passworD1', %w(Enterprise People))
+ user = @domain.authenticate!('user1', 'passworD1', %w(ghe-users ghe-admins))
- assert_equal 'uid=calavera,dc=github,dc=com', user.dn
+ assert_equal 'uid=user1,ou=People,dc=github,dc=com', user.dn
end
def test_authenticate_doesnt_return_valid_users_in_different_groups
- assert !@domain.authenticate!('calavera', 'passworD1', %w(People)),
+ refute @domain.authenticate!('user1', 'passworD1', %w(ghe-admins)),
"Expected `authenticate!` to not return an user"
end
def test_membership_empty_for_non_members
- user = @ldap.domain('uid=calavera,dc=github,dc=com').bind
+ user = @ldap.domain('uid=user1,ou=People,dc=github,dc=com').bind
- assert @domain.membership(user, %w(People)).empty?,
- "Expected `calavera` not to be a member of `People`."
+ assert @domain.membership(user, %w(ghe-admins)).empty?,
+ "Expected `user1` not to be a member of `ghe-admins`."
end
def test_membership_groups_for_members
- user = @ldap.domain('uid=calavera,dc=github,dc=com').bind
- groups = @domain.membership(user, %w(Enterprise People))
+ user = @ldap.domain('uid=user1,ou=People,dc=github,dc=com').bind
+ groups = @domain.membership(user, %w(ghe-users ghe-admins))
assert_equal 1, groups.size
- assert_equal 'cn=Enterprise,ou=Group,dc=github,dc=com', groups.first.dn
+ assert_equal 'cn=ghe-users,ou=Groups,dc=github,dc=com', groups.first.dn
end
def test_membership_with_virtual_attributes
ldap = GitHub::Ldap.new(options.merge(virtual_attributes: true))
- user = ldap.domain('uid=calavera,dc=github,dc=com').bind
- user[:memberof] = 'cn=Enterprise,ou=Group,dc=github,dc=com'
+
+ user = ldap.domain('uid=user1,ou=People,dc=github,dc=com').bind
+ user[:memberof] = 'cn=ghe-admins,ou=Groups,dc=github,dc=com'
domain = ldap.domain("dc=github,dc=com")
- groups = domain.membership(user, %w(Enterprise People))
+ groups = domain.membership(user, %w(ghe-admins))
assert_equal 1, groups.size
- assert_equal 'cn=Enterprise,ou=Group,dc=github,dc=com', groups.first.dn
+ assert_equal 'cn=ghe-admins,ou=Groups,dc=github,dc=com', groups.first.dn
end
def test_search
assert 1, @domain.search(
attributes: %w(uid),
- filter: Net::LDAP::Filter.eq('uid', 'calavera')).size
+ filter: Net::LDAP::Filter.eq('uid', 'user1')).size
end
def test_search_override_base_name
assert 1, @domain.search(
base: "this base name is incorrect",
attributes: %w(uid),
- filter: Net::LDAP::Filter.eq('uid', 'calavera')).size
+ filter: Net::LDAP::Filter.eq('uid', 'user1')).size
end
def test_user_exists
- assert_equal 'uid=calavera,dc=github,dc=com', @domain.user?('calavera').dn
+ assert user = @domain.user?('user1')
+ assert_equal 'uid=user1,ou=People,dc=github,dc=com', user.dn
end
def test_user_wildcards_are_filtered
- assert !@domain.user?('cal*'), 'Expected uid `cal*` to not complete'
+ refute @domain.user?('user*'), 'Expected uid `user*` to not complete'
end
def test_user_does_not_exist
- assert !@domain.user?('foobar'), 'Expected uid `foobar` to not exist.'
+ refute @domain.user?('foobar'), 'Expected uid `foobar` to not exist.'
end
def test_user_returns_every_attribute
- assert_equal ['calavera@github.com'], @domain.user?('calavera')[:mail]
+ assert user = @domain.user?('user1')
+ assert_equal ['user1@github.com'], user[:mail]
+ end
+
+ def test_user_returns_subset_of_attributes
+ assert entry = @domain.user?('user1', :attributes => [:cn])
+ assert_equal [:dn, :cn], entry.attribute_names
end
def test_auth_binds
- user = @domain.user?('calavera')
- assert @domain.auth(user, 'passworD1'), 'Expected user to be bound.'
+ assert user = @domain.user?('user1')
+ assert @domain.auth(user, 'passworD1'), 'Expected user to bind'
end
def test_auth_does_not_bind
- user = @domain.user?('calavera')
- assert !@domain.auth(user, 'foo'), 'Expected user not to be bound.'
+ assert user = @domain.user?('user1')
+ refute @domain.auth(user, 'foo'), 'Expected user not not bind'
+ end
+
+ def test_user_search_returns_first_entry
+ entry = mock("Net::Ldap::Entry")
+ search_strategy = mock("GitHub::Ldap::UserSearch::Default")
+ search_strategy.stubs(:perform).returns([entry])
+ @ldap.expects(:user_search_strategy).returns(search_strategy)
+ user = @domain.user?('user1', :attributes => [:cn])
+ assert_equal entry, user
end
end
@@ -143,48 +160,37 @@ class GitHubLdapDomainUnauthenticatedTest < GitHub::Ldap::UnauthenticatedTest
end
class GitHubLdapDomainNestedGroupsTest < GitHub::Ldap::Test
- def self.test_server_options
- {user_fixtures: FIXTURES.join('github-with-subgroups.ldif').to_s}
- end
-
def setup
@ldap = GitHub::Ldap.new(options)
@domain = @ldap.domain("dc=github,dc=com")
end
def test_membership_in_subgroups
- user = @ldap.domain('uid=rubiojr,ou=users,dc=github,dc=com').bind
+ user = @ldap.domain('uid=user1,ou=People,dc=github,dc=com').bind
- assert @domain.is_member?(user, %w(enterprise-ops)),
- "Expected `enterprise-ops` to include the member `#{user.dn}`"
+ assert @domain.is_member?(user, %w(nested-groups)),
+ "Expected `nested-groups` to include the member `#{user.dn}`"
end
def test_membership_in_deeply_nested_subgroups
- assert user = @ldap.domain('uid=user1.1.1.1,ou=users,dc=github,dc=com').bind
+ assert user = @ldap.domain('uid=user1,ou=People,dc=github,dc=com').bind
- assert @domain.is_member?(user, %w(group1)),
- "Expected `group1` to include the member `#{user.dn}` via deep recursion"
+ assert @domain.is_member?(user, %w(n-depth-nested-group4)),
+ "Expected `n-depth-nested-group4` to include the member `#{user.dn}` via deep recursion"
end
end
class GitHubLdapPosixGroupsWithRecursionFallbackTest < GitHub::Ldap::Test
- def self.test_server_options
- {
- custom_schemas: FIXTURES.join('posixGroup.schema.ldif'),
- user_fixtures: FIXTURES.join('github-with-posixGroups.ldif').to_s,
- # so we exercise the recursive group search fallback
- recursive_group_search_fallback: true
- }
- end
-
def setup
- @ldap = GitHub::Ldap.new(options)
+ opts = options.merge \
+ recursive_group_search_fallback: true
+ @ldap = GitHub::Ldap.new(opts)
@domain = @ldap.domain("dc=github,dc=com")
- @cn = "enterprise-posix-devs"
+ @cn = "posix-group1"
end
def test_membership_for_posixGroups
- assert user = @ldap.domain('uid=mtodd,ou=users,dc=github,dc=com').bind
+ assert user = @ldap.domain('uid=user1,ou=People,dc=github,dc=com').bind
assert @domain.is_member?(user, [@cn]),
"Expected `#{@cn}` to include the member `#{user.dn}`"
@@ -192,23 +198,16 @@ def test_membership_for_posixGroups
end
class GitHubLdapPosixGroupsWithoutRecursionTest < GitHub::Ldap::Test
- def self.test_server_options
- {
- custom_schemas: FIXTURES.join('posixGroup.schema.ldif'),
- user_fixtures: FIXTURES.join('github-with-posixGroups.ldif').to_s,
- # so we test the test the non-recursive group membership search
- recursive_group_search_fallback: false
- }
- end
-
def setup
- @ldap = GitHub::Ldap.new(options)
+ opts = options.merge \
+ recursive_group_search_fallback: false
+ @ldap = GitHub::Ldap.new(opts)
@domain = @ldap.domain("dc=github,dc=com")
- @cn = "enterprise-posix-devs"
+ @cn = "posix-group1"
end
def test_membership_for_posixGroups
- assert user = @ldap.domain('uid=mtodd,ou=users,dc=github,dc=com').bind
+ assert user = @ldap.domain('uid=user1,ou=People,dc=github,dc=com').bind
assert @domain.is_member?(user, [@cn]),
"Expected `#{@cn}` to include the member `#{user.dn}`"
@@ -218,27 +217,32 @@ def test_membership_for_posixGroups
# Specifically testing that this doesn't break when posixGroups are not
# supported.
class GitHubLdapWithoutPosixGroupsTest < GitHub::Ldap::Test
- def self.test_server_options
- {
- custom_schemas: FIXTURES.join('posixGroup.schema.ldif'),
- user_fixtures: FIXTURES.join('github-with-posixGroups.ldif').to_s,
- # so we test the test the non-recursive group membership search
- recursive_group_search_fallback: false,
- # explicitly disable posixGroup support (even if the schema supports it)
- posix_support: false
- }
- end
-
def setup
- @ldap = GitHub::Ldap.new(options)
+ opts = options.merge \
+ recursive_group_search_fallback: false, # test non-recursive group membership search
+ posix_support: false # disable posixGroup support
+ @ldap = GitHub::Ldap.new(opts)
@domain = @ldap.domain("dc=github,dc=com")
- @cn = "enterprise-posix-devs"
+ @cn = "posix-group1"
end
def test_membership_for_posixGroups
- assert user = @ldap.domain('uid=mtodd,ou=users,dc=github,dc=com').bind
+ assert user = @ldap.domain('uid=user1,ou=People,dc=github,dc=com').bind
refute @domain.is_member?(user, [@cn]),
"Expected `#{@cn}` to not include the member `#{user.dn}`"
end
end
+
+class GitHubLdapActiveDirectoryGroupsTest < GitHub::Ldap::Test
+ def run(*)
+ return super if self.class.test_env == "activedirectory"
+ Minitest::Result.from(self)
+ end
+
+ def test_filter_groups
+ domain = GitHub::Ldap.new(options).domain("DC=ad,DC=ghe,DC=local")
+ results = domain.filter_groups("ghe-admins")
+ assert_equal 1, results.size
+ end
+end
diff --git a/test/filter_test.rb b/test/filter_test.rb
index 8fc6ba2..4da83c9 100644
--- a/test/filter_test.rb
+++ b/test/filter_test.rb
@@ -1,6 +1,6 @@
require_relative 'test_helper'
-class FilterTest < Minitest::Test
+class FilterTest < GitHub::Ldap::Test
class Subject
include GitHub::Ldap::Filter
def initialize(ldap)
@@ -16,11 +16,12 @@ def [](field)
end
def setup
- @ldap = GitHub::Ldap.new(:uid => 'uid')
+ @ldap = GitHub::Ldap.new(options.merge(:uid => 'uid'))
@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
@@ -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)
@@ -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
diff --git a/test/fixtures/common/seed.ldif b/test/fixtures/common/seed.ldif
new file mode 100644
index 0000000..29284bb
--- /dev/null
+++ b/test/fixtures/common/seed.ldif
@@ -0,0 +1,369 @@
+dn: ou=People,dc=github,dc=com
+objectClass: top
+objectClass: organizationalUnit
+ou: People
+
+dn: ou=Groups,dc=github,dc=com
+objectClass: top
+objectClass: organizationalUnit
+ou: Groups
+
+# Directory Superuser
+dn: uid=admin,dc=github,dc=com
+uid: admin
+cn: system administrator
+sn: administrator
+objectClass: top
+objectClass: person
+objectClass: organizationalPerson
+objectClass: inetOrgPerson
+displayName: Directory Superuser
+userPassword: passworD1
+
+# Users 1-10
+
+dn: uid=user1,ou=People,dc=github,dc=com
+uid: user1
+cn: user1
+sn: user1
+objectClass: top
+objectClass: person
+objectClass: organizationalPerson
+objectClass: inetOrgPerson
+userPassword: passworD1
+mail: user1@github.com
+
+dn: uid=user2,ou=People,dc=github,dc=com
+uid: user2
+cn: user2
+sn: user2
+objectClass: top
+objectClass: person
+objectClass: organizationalPerson
+objectClass: inetOrgPerson
+userPassword: passworD1
+mail: user2@github.com
+
+dn: uid=user3,ou=People,dc=github,dc=com
+uid: user3
+cn: user3
+sn: user3
+objectClass: top
+objectClass: person
+objectClass: organizationalPerson
+objectClass: inetOrgPerson
+userPassword: passworD1
+mail: user3@github.com
+
+dn: uid=user4,ou=People,dc=github,dc=com
+uid: user4
+cn: user4
+sn: user4
+objectClass: top
+objectClass: person
+objectClass: organizationalPerson
+objectClass: inetOrgPerson
+userPassword: passworD1
+mail: user4@github.com
+
+dn: uid=user5,ou=People,dc=github,dc=com
+uid: user5
+cn: user5
+sn: user5
+objectClass: top
+objectClass: person
+objectClass: organizationalPerson
+objectClass: inetOrgPerson
+userPassword: passworD1
+mail: user5@github.com
+
+dn: uid=user6,ou=People,dc=github,dc=com
+uid: user6
+cn: user6
+sn: user6
+objectClass: top
+objectClass: person
+objectClass: organizationalPerson
+objectClass: inetOrgPerson
+userPassword: passworD1
+mail: user6@github.com
+
+dn: uid=user7,ou=People,dc=github,dc=com
+uid: user7
+cn: user7
+sn: user7
+objectClass: top
+objectClass: person
+objectClass: organizationalPerson
+objectClass: inetOrgPerson
+userPassword: passworD1
+mail: user7@github.com
+
+dn: uid=user8,ou=People,dc=github,dc=com
+uid: user8
+cn: user8
+sn: user8
+objectClass: top
+objectClass: person
+objectClass: organizationalPerson
+objectClass: inetOrgPerson
+userPassword: passworD1
+mail: user8@github.com
+
+dn: uid=user9,ou=People,dc=github,dc=com
+uid: user9
+cn: user9
+sn: user9
+objectClass: top
+objectClass: person
+objectClass: organizationalPerson
+objectClass: inetOrgPerson
+userPassword: passworD1
+mail: user9@github.com
+
+dn: uid=user10,ou=People,dc=github,dc=com
+uid: user10
+cn: user10
+sn: user10
+objectClass: top
+objectClass: person
+objectClass: organizationalPerson
+objectClass: inetOrgPerson
+userPassword: passworD1
+mail: user10@github.com
+
+# Emailless User
+
+dn: uid=emailless-user1,ou=People,dc=github,dc=com
+uid: emailless-user1
+cn: emailless-user1
+sn: emailless-user1
+objectClass: top
+objectClass: person
+objectClass: organizationalPerson
+objectClass: inetOrgPerson
+userPassword: passworD1
+
+# Groupless User
+
+dn: uid=groupless-user1,ou=People,dc=github,dc=com
+uid: groupless-user1
+cn: groupless-user1
+sn: groupless-user1
+objectClass: top
+objectClass: person
+objectClass: organizationalPerson
+objectClass: inetOrgPerson
+userPassword: passworD1
+
+# Admin User
+
+dn: uid=admin1,ou=People,dc=github,dc=com
+uid: admin1
+cn: admin1
+sn: admin1
+objectClass: top
+objectClass: person
+objectClass: organizationalPerson
+objectClass: inetOrgPerson
+userPassword: passworD1
+mail: admin1@github.com
+
+# Groups
+
+dn: cn=ghe-users,ou=Groups,dc=github,dc=com
+cn: ghe-users
+objectClass: groupOfNames
+member: uid=user1,ou=People,dc=github,dc=com
+member: uid=emailless-user1,ou=People,dc=github,dc=com
+
+dn: cn=all-users,ou=Groups,dc=github,dc=com
+cn: all-users
+objectClass: groupOfNames
+member: cn=ghe-users,ou=Groups,dc=github,dc=com
+member: uid=user1,ou=People,dc=github,dc=com
+member: uid=user2,ou=People,dc=github,dc=com
+member: uid=user3,ou=People,dc=github,dc=com
+member: uid=user4,ou=People,dc=github,dc=com
+member: uid=user5,ou=People,dc=github,dc=com
+member: uid=user6,ou=People,dc=github,dc=com
+member: uid=user7,ou=People,dc=github,dc=com
+member: uid=user8,ou=People,dc=github,dc=com
+member: uid=user9,ou=People,dc=github,dc=com
+member: uid=user10,ou=People,dc=github,dc=com
+member: uid=emailless-user1,ou=People,dc=github,dc=com
+
+dn: cn=ghe-admins,ou=Groups,dc=github,dc=com
+cn: ghe-admins
+objectClass: groupOfNames
+member: uid=admin1,ou=People,dc=github,dc=com
+
+dn: cn=all-admins,ou=Groups,dc=github,dc=com
+cn: all-admins
+objectClass: groupOfNames
+member: cn=ghe-admins,ou=Groups,dc=github,dc=com
+member: uid=admin1,ou=People,dc=github,dc=com
+
+dn: cn=n-member-group10,ou=Groups,dc=github,dc=com
+cn: n-member-group10
+objectClass: groupOfNames
+member: uid=user1,ou=People,dc=github,dc=com
+member: uid=user2,ou=People,dc=github,dc=com
+member: uid=user3,ou=People,dc=github,dc=com
+member: uid=user4,ou=People,dc=github,dc=com
+member: uid=user5,ou=People,dc=github,dc=com
+member: uid=user6,ou=People,dc=github,dc=com
+member: uid=user7,ou=People,dc=github,dc=com
+member: uid=user8,ou=People,dc=github,dc=com
+member: uid=user9,ou=People,dc=github,dc=com
+member: uid=user10,ou=People,dc=github,dc=com
+
+dn: cn=nested-group1,ou=Groups,dc=github,dc=com
+cn: nested-group1
+objectClass: groupOfNames
+member: uid=user1,ou=People,dc=github,dc=com
+member: uid=user2,ou=People,dc=github,dc=com
+member: uid=user3,ou=People,dc=github,dc=com
+member: uid=user4,ou=People,dc=github,dc=com
+member: uid=user5,ou=People,dc=github,dc=com
+member: uid=user6,ou=People,dc=github,dc=com
+member: uid=user7,ou=People,dc=github,dc=com
+member: uid=user8,ou=People,dc=github,dc=com
+member: uid=user9,ou=People,dc=github,dc=com
+member: uid=user10,ou=People,dc=github,dc=com
+
+dn: cn=nested-groups,ou=Groups,dc=github,dc=com
+cn: nested-groups
+objectClass: groupOfNames
+member: cn=nested-group1,ou=Groups,dc=github,dc=com
+
+dn: cn=n-member-nested-group1,ou=Groups,dc=github,dc=com
+cn: n-member-nested-group1
+objectClass: groupOfNames
+member: cn=nested-group1,ou=Groups,dc=github,dc=com
+
+dn: cn=deeply-nested-group0.0.0,ou=Groups,dc=github,dc=com
+cn: deeply-nested-group0.0.0
+objectClass: groupOfNames
+member: uid=user1,ou=People,dc=github,dc=com
+member: uid=user2,ou=People,dc=github,dc=com
+member: uid=user3,ou=People,dc=github,dc=com
+member: uid=user4,ou=People,dc=github,dc=com
+member: uid=user5,ou=People,dc=github,dc=com
+
+dn: cn=deeply-nested-group0.0.1,ou=Groups,dc=github,dc=com
+cn: deeply-nested-group0.0.1
+objectClass: groupOfNames
+member: uid=user6,ou=People,dc=github,dc=com
+member: uid=user7,ou=People,dc=github,dc=com
+member: uid=user8,ou=People,dc=github,dc=com
+member: uid=user9,ou=People,dc=github,dc=com
+member: uid=user10,ou=People,dc=github,dc=com
+
+dn: cn=deeply-nested-group0.0,ou=Groups,dc=github,dc=com
+cn: deeply-nested-group0.0
+objectClass: groupOfNames
+member: cn=deeply-nested-group0.0.0,ou=Groups,dc=github,dc=com
+member: cn=deeply-nested-group0.0.1,ou=Groups,dc=github,dc=com
+
+dn: cn=deeply-nested-group0,ou=Groups,dc=github,dc=com
+cn: deeply-nested-group0
+objectClass: groupOfNames
+member: cn=deeply-nested-group0.0,ou=Groups,dc=github,dc=com
+
+dn: cn=deeply-nested-groups,ou=Groups,dc=github,dc=com
+cn: deeply-nested-groups
+objectClass: groupOfNames
+member: cn=deeply-nested-group0,ou=Groups,dc=github,dc=com
+
+dn: cn=n-depth-nested-group1,ou=Groups,dc=github,dc=com
+cn: n-depth-nested-group1
+objectClass: groupOfNames
+member: cn=nested-group1,ou=Groups,dc=github,dc=com
+
+dn: cn=n-depth-nested-group2,ou=Groups,dc=github,dc=com
+cn: n-depth-nested-group2
+objectClass: groupOfNames
+member: cn=n-depth-nested-group1,ou=Groups,dc=github,dc=com
+
+dn: cn=n-depth-nested-group3,ou=Groups,dc=github,dc=com
+cn: n-depth-nested-group3
+objectClass: groupOfNames
+member: cn=n-depth-nested-group2,ou=Groups,dc=github,dc=com
+
+dn: cn=n-depth-nested-group4,ou=Groups,dc=github,dc=com
+cn: n-depth-nested-group4
+objectClass: groupOfNames
+member: cn=n-depth-nested-group3,ou=Groups,dc=github,dc=com
+
+dn: cn=n-depth-nested-group5,ou=Groups,dc=github,dc=com
+cn: n-depth-nested-group5
+objectClass: groupOfNames
+member: cn=n-depth-nested-group4,ou=Groups,dc=github,dc=com
+
+dn: cn=n-depth-nested-group6,ou=Groups,dc=github,dc=com
+cn: n-depth-nested-group6
+objectClass: groupOfNames
+member: cn=n-depth-nested-group5,ou=Groups,dc=github,dc=com
+
+dn: cn=n-depth-nested-group7,ou=Groups,dc=github,dc=com
+cn: n-depth-nested-group7
+objectClass: groupOfNames
+member: cn=n-depth-nested-group6,ou=Groups,dc=github,dc=com
+
+dn: cn=n-depth-nested-group8,ou=Groups,dc=github,dc=com
+cn: n-depth-nested-group8
+objectClass: groupOfNames
+member: cn=n-depth-nested-group7,ou=Groups,dc=github,dc=com
+
+dn: cn=n-depth-nested-group9,ou=Groups,dc=github,dc=com
+cn: n-depth-nested-group9
+objectClass: groupOfNames
+member: cn=n-depth-nested-group8,ou=Groups,dc=github,dc=com
+
+dn: cn=head-group,ou=Groups,dc=github,dc=com
+cn: head-group
+objectClass: groupOfNames
+member: cn=tail-group,ou=Groups,dc=github,dc=com
+member: uid=user1,ou=People,dc=github,dc=com
+member: uid=user2,ou=People,dc=github,dc=com
+member: uid=user3,ou=People,dc=github,dc=com
+member: uid=user4,ou=People,dc=github,dc=com
+member: uid=user5,ou=People,dc=github,dc=com
+
+dn: cn=tail-group,ou=Groups,dc=github,dc=com
+cn: tail-group
+objectClass: groupOfNames
+member: cn=head-group,ou=Groups,dc=github,dc=com
+member: uid=user6,ou=People,dc=github,dc=com
+member: uid=user7,ou=People,dc=github,dc=com
+member: uid=user8,ou=People,dc=github,dc=com
+member: uid=user9,ou=People,dc=github,dc=com
+member: uid=user10,ou=People,dc=github,dc=com
+
+dn: cn=recursively-nested-groups,ou=Groups,dc=github,dc=com
+cn: recursively-nested-groups
+objectClass: groupOfNames
+member: cn=head-group,ou=Groups,dc=github,dc=com
+member: cn=tail-group,ou=Groups,dc=github,dc=com
+
+# posixGroup
+
+dn: cn=posix-group1,ou=Groups,dc=github,dc=com
+cn: posix-group1
+objectClass: posixGroup
+gidNumber: 1001
+memberUid: user1
+memberUid: user2
+memberUid: user3
+memberUid: user4
+memberUid: user5
+
+# missing members
+
+dn: cn=missing-users,ou=Groups,dc=github,dc=com
+cn: missing-users
+objectClass: groupOfNames
+member: uid=user1,ou=People,dc=github,dc=com
+member: uid=user2,ou=People,dc=github,dc=com
+member: uid=nonexistent-user,ou=People,dc=github,dc=com
diff --git a/test/fixtures/github-with-looped-subgroups.ldif b/test/fixtures/github-with-looped-subgroups.ldif
deleted file mode 100644
index 02868fe..0000000
--- a/test/fixtures/github-with-looped-subgroups.ldif
+++ /dev/null
@@ -1,82 +0,0 @@
-version: 1
-
-# Admin user
-
-dn: uid=admin,dc=github,dc=com
-objectClass: top
-objectClass: person
-objectClass: organizationalPerson
-objectClass: inetOrgPerson
-cn: system administrator
-sn: administrator
-displayName: Directory Superuser
-uid: admin
-userPassword: secret
-
-# Groups
-
-dn: ou=groups,dc=github,dc=com
-objectclass: organizationalUnit
-
-dn: cn=enterprise,ou=groups,dc=github,dc=com
-cn: Enterprise
-objectClass: groupOfNames
-member: uid=calavera,ou=users,dc=github,dc=com
-member: cn=enterprise-devs,ou=groups,dc=github,dc=com
-member: cn=enterprise-ops,ou=groups,dc=github,dc=com
-
-dn: cn=enterprise-devs,ou=groups,dc=github,dc=com
-cn: enterprise-devs
-objectClass: groupOfNames
-member: uid=benburkert,ou=users,dc=github,dc=com
-member: cn=enterprise,ou=groups,dc=github,dc=com
-
-dn: cn=enterprise-ops,ou=groups,dc=github,dc=com
-cn: enterprise-ops
-objectClass: groupOfNames
-member: uid=sbryant,ou=users,dc=github,dc=com
-member: cn=spaniards,ou=groups,dc=github,dc=com
-
-dn: cn=spaniards,ou=groups,dc=github,dc=com
-cn: spaniards
-objectClass: groupOfNames
-member: uid=calavera,ou=users,dc=github,dc=com
-member: uid=rubiojr,ou=users,dc=github,dc=com
-
-# Users
-
-dn: ou=users,dc=github,dc=com
-objectclass: organizationalUnit
-
-dn: uid=calavera,ou=users,dc=github,dc=com
-cn: David Calavera
-cn: David
-sn: Calavera
-uid: calavera
-userPassword: passworD1
-mail: calavera@github.com
-objectClass: inetOrgPerson
-
-dn: uid=benburkert,ou=users,dc=github,dc=com
-cn: benburkert
-sn: benburkert
-uid: benburkert
-userPassword: passworD1
-mail: benburkert@github.com
-objectClass: inetOrgPerson
-
-dn: uid=sbryant,ou=users,dc=github,dc=com
-cn: sbryant
-sn: sbryant
-uid: sbryant
-userPassword: passworD1
-mail: sbryant@github.com
-objectClass: inetOrgPerson
-
-dn: uid=rubiojr,ou=users,dc=github,dc=com
-cn: rubiojr
-sn: rubiojr
-uid: rubiojr
-userPassword: passworD1
-mail: rubiojr@github.com
-objectClass: inetOrgPerson
diff --git a/test/fixtures/github-with-missing-entries.ldif b/test/fixtures/github-with-missing-entries.ldif
deleted file mode 100644
index be8d316..0000000
--- a/test/fixtures/github-with-missing-entries.ldif
+++ /dev/null
@@ -1,85 +0,0 @@
-version: 1
-
-# Admin user
-
-dn: uid=admin,dc=github,dc=com
-objectClass: top
-objectClass: person
-objectClass: organizationalPerson
-objectClass: inetOrgPerson
-cn: system administrator
-sn: administrator
-displayName: Directory Superuser
-uid: admin
-userPassword: secret
-
-# Groups
-
-dn: ou=groups,dc=github,dc=com
-objectclass: organizationalUnit
-
-dn: cn=enterprise,ou=groups,dc=github,dc=com
-cn: Enterprise
-objectClass: groupOfNames
-member: uid=calavera,ou=users,dc=github,dc=com
-member: cn=enterprise-devs,ou=groups,dc=github,dc=com
-member: cn=enterprise-ops,ou=groups,dc=github,dc=com
-
-dn: cn=enterprise-devs,ou=groups,dc=github,dc=com
-cn: enterprise-devs
-objectClass: groupOfNames
-member: uid=benburkert,ou=users,dc=github,dc=com
-member: cn=enterprise,ou=groups,dc=github,dc=com
-
-dn: cn=enterprise-ops,ou=groups,dc=github,dc=com
-cn: enterprise-ops
-objectClass: groupOfNames
-member: uid=sbryant,ou=users,dc=github,dc=com
-member: cn=spaniards,ou=groups,dc=github,dc=com
-
-# The last member of this group is missing on purpose.
-# See: https://github.com/github/github-ldap/pull/18
-dn: cn=spaniards,ou=groups,dc=github,dc=com
-cn: spaniards
-objectClass: groupOfNames
-member: uid=calavera,ou=users,dc=github,dc=com
-member: uid=rubiojr,ou=users,dc=github,dc=com
-member: uid=felipe,ou=users,dc=github,dc=com
-
-# Users
-
-dn: ou=users,dc=github,dc=com
-objectclass: organizationalUnit
-
-dn: uid=calavera,ou=users,dc=github,dc=com
-cn: David Calavera
-cn: David
-sn: Calavera
-uid: calavera
-userPassword: passworD1
-mail: calavera@github.com
-objectClass: inetOrgPerson
-
-dn: uid=benburkert,ou=users,dc=github,dc=com
-cn: benburkert
-sn: benburkert
-uid: benburkert
-userPassword: passworD1
-mail: benburkert@github.com
-objectClass: inetOrgPerson
-
-dn: uid=sbryant,ou=users,dc=github,dc=com
-cn: sbryant
-sn: sbryant
-uid: sbryant
-userPassword: passworD1
-mail: sbryant@github.com
-objectClass: inetOrgPerson
-
-dn: uid=rubiojr,ou=users,dc=github,dc=com
-cn: rubiojr
-sn: rubiojr
-uid: rubiojr
-userPassword: passworD1
-mail: rubiojr@github.com
-objectClass: inetOrgPerson
diff --git a/test/fixtures/github-with-posixGroups.ldif b/test/fixtures/github-with-posixGroups.ldif
deleted file mode 100644
index ac8b3a0..0000000
--- a/test/fixtures/github-with-posixGroups.ldif
+++ /dev/null
@@ -1,50 +0,0 @@
-version: 1
-
-# Admin user
-
-dn: uid=admin,dc=github,dc=com
-objectClass: top
-objectClass: person
-objectClass: organizationalPerson
-objectClass: inetOrgPerson
-cn: system administrator
-sn: administrator
-displayName: Directory Superuser
-uid: admin
-userPassword: secret
-
-# Groups
-
-dn: ou=groups,dc=github,dc=com
-objectclass: organizationalUnit
-ou: groups
-
-# Posix Groups
-
-dn: cn=enterprise-posix-devs,ou=groups,dc=github,dc=com
-cn: enterprise-posix-devs
-objectClass: posixGroup
-memberUid: benburkert
-memberUid: mtodd
-
-# Users
-
-dn: ou=users,dc=github,dc=com
-objectclass: organizationalUnit
-ou: users
-
-dn: uid=benburkert,ou=users,dc=github,dc=com
-cn: benburkert
-sn: benburkert
-uid: benburkert
-userPassword: passworD1
-mail: benburkert@github.com
-objectClass: inetOrgPerson
-
-dn: uid=mtodd,ou=users,dc=github,dc=com
-cn: mtodd
-sn: mtodd
-uid: mtodd
-userPassword: passworD1
-mail: mtodd@github.com
-objectClass: inetOrgPerson
diff --git a/test/fixtures/github-with-subgroups.ldif b/test/fixtures/github-with-subgroups.ldif
deleted file mode 100644
index 00dc929..0000000
--- a/test/fixtures/github-with-subgroups.ldif
+++ /dev/null
@@ -1,146 +0,0 @@
-version: 1
-
-# Admin user
-
-dn: uid=admin,dc=github,dc=com
-objectClass: top
-objectClass: person
-objectClass: organizationalPerson
-objectClass: inetOrgPerson
-cn: system administrator
-sn: administrator
-displayName: Directory Superuser
-uid: admin
-userPassword: secret
-
-# Groups
-
-dn: ou=groups,dc=github,dc=com
-objectclass: organizationalUnit
-ou: groups
-
-dn: cn=enterprise,ou=groups,dc=github,dc=com
-cn: Enterprise
-objectClass: groupOfNames
-member: uid=calavera,ou=users,dc=github,dc=com
-member: cn=enterprise-devs,ou=groups,dc=github,dc=com
-member: cn=enterprise-ops,ou=groups,dc=github,dc=com
-
-dn: cn=enterprise-devs,ou=groups,dc=github,dc=com
-cn: enterprise-devs
-objectClass: groupOfNames
-member: uid=benburkert,ou=users,dc=github,dc=com
-
-dn: cn=enterprise-ops,ou=groups,dc=github,dc=com
-cn: enterprise-ops
-objectClass: groupOfNames
-member: uid=sbryant,ou=users,dc=github,dc=com
-member: cn=spaniards,ou=groups,dc=github,dc=com
-
-dn: cn=spaniards,ou=groups,dc=github,dc=com
-cn: spaniards
-objectClass: groupOfNames
-member: uid=calavera,ou=users,dc=github,dc=com
-member: uid=rubiojr,ou=users,dc=github,dc=com
-
-dn: cn=group1,ou=groups,dc=github,dc=com
-cn: group1
-objectClass: groupOfNames
-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
-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
-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
-objectClass: groupOfNames
-member: uid=user1.1.1.1,ou=users,dc=github,dc=com
-
-# Users
-
-dn: ou=users,dc=github,dc=com
-objectclass: organizationalUnit
-ou: users
-
-dn: uid=calavera,ou=users,dc=github,dc=com
-cn: David Calavera
-cn: David
-sn: Calavera
-uid: calavera
-userPassword: passworD1
-mail: calavera@github.com
-objectClass: inetOrgPerson
-
-dn: uid=benburkert,ou=users,dc=github,dc=com
-cn: benburkert
-sn: benburkert
-uid: benburkert
-userPassword: passworD1
-mail: benburkert@github.com
-objectClass: inetOrgPerson
-
-dn: uid=sbryant,ou=users,dc=github,dc=com
-cn: sbryant
-sn: sbryant
-uid: sbryant
-userPassword: passworD1
-mail: sbryant@github.com
-objectClass: inetOrgPerson
-
-dn: uid=rubiojr,ou=users,dc=github,dc=com
-cn: rubiojr
-sn: rubiojr
-uid: rubiojr
-userPassword: passworD1
-mail: rubiojr@github.com
-objectClass: inetOrgPerson
-
-dn: uid=mtodd,ou=users,dc=github,dc=com
-cn: mtodd
-sn: mtodd
-uid: mtodd
-userPassword: passworD1
-mail: mtodd@github.com
-objectClass: inetOrgPerson
-
-dn: uid=user1,ou=users,dc=github,dc=com
-uid: user1
-sn: user1
-cn: user1
-userPassword: passworD1
-mail: user1@github.com
-objectClass: inetOrgPerson
-
-dn: uid=user1.1,ou=users,dc=github,dc=com
-uid: user1.1
-sn: user1.1
-cn: user1.1
-userPassword: passworD1
-mail: user1.1@github.com
-objectClass: inetOrgPerson
-
-dn: uid=user1.1.1,ou=users,dc=github,dc=com
-uid: user1.1.1
-sn: user1.1.1
-cn: 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
-uid: user1.1.1.1
-sn: user1.1.1.1
-cn: user1.1.1.1
-userPassword: passworD1
-mail: user1.1.1.1@github.com
-objectClass: inetOrgPerson
diff --git a/test/fixtures/openldap/memberof.ldif b/test/fixtures/openldap/memberof.ldif
new file mode 100644
index 0000000..dac7c6b
--- /dev/null
+++ b/test/fixtures/openldap/memberof.ldif
@@ -0,0 +1,33 @@
+dn: cn=module,cn=config
+cn: module
+objectClass: olcModuleList
+objectClass: top
+olcModulePath: /usr/lib/ldap
+olcModuleLoad: memberof.la
+
+dn: olcOverlay={0}memberof,olcDatabase={1}hdb,cn=config
+objectClass: olcConfig
+objectClass: olcMemberOf
+objectClass: olcOverlayConfig
+objectClass: top
+olcOverlay: memberof
+olcMemberOfDangling: ignore
+olcMemberOfRefInt: TRUE
+olcMemberOfGroupOC: groupOfNames
+olcMemberOfMemberAD: member
+olcMemberOfMemberOfAD: memberOf
+
+dn: cn=module,cn=config
+cn: module
+objectclass: olcModuleList
+objectclass: top
+olcmoduleload: refint.la
+olcmodulepath: /usr/lib/ldap
+
+dn: olcOverlay={1}refint,olcDatabase={1}hdb,cn=config
+objectClass: olcConfig
+objectClass: olcOverlayConfig
+objectClass: olcRefintConfig
+objectClass: top
+olcOverlay: {1}refint
+olcRefintAttribute: memberof member manager owner
diff --git a/test/fixtures/openldap/slapd.conf.ldif b/test/fixtures/openldap/slapd.conf.ldif
new file mode 100644
index 0000000..7d88769
--- /dev/null
+++ b/test/fixtures/openldap/slapd.conf.ldif
@@ -0,0 +1,67 @@
+dn: cn=config
+objectClass: olcGlobal
+cn: config
+olcPidFile: /var/run/slapd/slapd.pid
+olcArgsFile: /var/run/slapd/slapd.args
+olcLogLevel: none
+olcToolThreads: 1
+
+dn: olcDatabase={-1}frontend,cn=config
+objectClass: olcDatabaseConfig
+objectClass: olcFrontendConfig
+olcDatabase: {-1}frontend
+olcSizeLimit: 500
+olcAccess: {0}to * by dn.exact=gidNumber=0+uidNumber=0,cn=peercred,cn=external,cn=auth manage by * break
+olcAccess: {1}to dn.exact="" by * read
+olcAccess: {2}to dn.base="cn=Subschema" by * read
+
+dn: olcDatabase=config,cn=config
+objectClass: olcDatabaseConfig
+olcDatabase: config
+olcAccess: to * by dn.exact=gidNumber=0+uidNumber=0,cn=peercred,cn=external,cn=auth manage by * break
+
+dn: cn=schema,cn=config
+objectClass: olcSchemaConfig
+cn: schema
+
+include: file:///etc/ldap/schema/core.ldif
+include: file:///etc/ldap/schema/cosine.ldif
+include: file:///etc/ldap/schema/nis.ldif
+include: file:///etc/ldap/schema/inetorgperson.ldif
+
+dn: cn=module{0},cn=config
+objectClass: olcModuleList
+cn: module{0}
+olcModulePath: /usr/lib/ldap
+olcModuleLoad: back_hdb
+
+dn: olcBackend=hdb,cn=config
+objectClass: olcBackendConfig
+olcBackend: hdb
+
+dn: olcDatabase=hdb,cn=config
+objectClass: olcDatabaseConfig
+objectClass: olcHdbConfig
+olcDatabase: hdb
+olcDbCheckpoint: 512 30
+olcDbConfig: set_cachesize 1 0 0
+olcDbConfig: set_lk_max_objects 1500
+olcDbConfig: set_lk_max_locks 1500
+olcDbConfig: set_lk_max_lockers 1500
+olcLastMod: TRUE
+olcSuffix: dc=github,dc=com
+olcDbDirectory: /var/lib/ldap
+olcRootDN: cn=admin,dc=github,dc=com
+# admin's password: "passworD1"
+olcRootPW: {SHA}LFSkM9eegU6j3PeGG7UuHrT/KZM=
+olcDbIndex: objectClass eq
+olcAccess: to attrs=userPassword,shadowLastChange
+ by self write
+ by anonymous auth
+ by dn="cn=admin,dc=github,dc=com" write
+ by * none
+olcAccess: to dn.base="" by * read
+olcAccess: to *
+ by self write
+ by dn="cn=admin,dc=github,dc=com" write
+ by * read
diff --git a/test/fixtures/posixGroup.schema.ldif b/test/fixtures/posixGroup.schema.ldif
index 94dd488..3ba04e0 100644
--- a/test/fixtures/posixGroup.schema.ldif
+++ b/test/fixtures/posixGroup.schema.ldif
@@ -1,26 +1,52 @@
version: 1
-dn: m-oid=1.3.6.1.4.1.18055.0.4.1.2.1001,ou=attributeTypes,cn=other,ou=schema
+# attributetype ( 1.3.6.1.1.1.1.1 NAME 'gidNumber'
+# DESC 'An integer uniquely identifying a group in an administrative domain'
+# EQUALITY integerMatch
+# SYNTAX 1.3.6.1.4.1.1466.115.121.1.27 SINGLE-VALUE )
+dn: m-oid=1.3.6.1.1.1.1.1,ou=attributeTypes,cn=other,ou=schema
+objectClass: metaAttributeType
+objectClass: metaTop
+objectClass: top
+m-collective: FALSE
+m-description: An integer uniquely identifying a group in an administrative domain
+m-equality: integerMatch
+m-name: gidNumber
+m-syntax: 1.3.6.1.4.1.1466.115.121.1.27
+m-usage: USER_APPLICATIONS
+m-oid: 1.3.6.1.1.1.1.1
+
+# attributetype ( 1.3.6.1.1.1.1.12 NAME 'memberUid'
+# EQUALITY caseExactIA5Match
+# SUBSTR caseExactIA5SubstringsMatch
+# SYNTAX 1.3.6.1.4.1.1466.115.121.1.26 )
+dn: m-oid=1.3.6.1.1.1.1.12,ou=attributeTypes,cn=other,ou=schema
objectClass: metaAttributeType
objectClass: metaTop
objectClass: top
m-collective: FALSE
m-description: memberUid
-m-equality: caseExactMatch
+m-equality: caseExactIA5Match
m-name: memberUid
-m-syntax: 1.3.6.1.4.1.1466.115.121.1.15
+m-syntax: 1.3.6.1.4.1.1466.115.121.1.26
m-usage: USER_APPLICATIONS
-m-oid: 1.3.6.1.4.1.18055.0.4.1.2.1001
+m-oid: 1.3.6.1.1.1.1.12
-dn: m-oid=1.3.6.1.4.1.18055.0.4.1.3.1001,ou=objectClasses,cn=other,ou=schema
+# objectclass ( 1.3.6.1.1.1.2.2 NAME 'posixGroup' SUP top STRUCTURAL
+# DESC 'Abstraction of a group of accounts'
+# MUST ( cn $ gidNumber )
+# MAY ( userPassword $ memberUid $ description ) )
+dn: m-oid=1.3.6.1.1.1.2.2,ou=objectClasses,cn=other,ou=schema
objectClass: metaObjectClass
objectClass: metaTop
objectClass: top
m-description: posixGroup
-m-may: cn
-m-may: sn
+m-must: cn
+m-must: gidNumber
m-may: memberUid
+m-may: userPassword
+m-may: description
m-supobjectclass: top
m-name: posixGroup
-m-oid: 1.3.6.1.4.1.18055.0.4.1.3.1001
+m-oid: 1.3.6.1.1.1.2.2
m-typeobjectclass: STRUCTURAL
diff --git a/test/group_test.rb b/test/group_test.rb
index 6f1714d..1ed5f82 100644
--- a/test/group_test.rb
+++ b/test/group_test.rb
@@ -1,17 +1,13 @@
require_relative 'test_helper'
class GitHubLdapGroupTest < GitHub::Ldap::Test
- def self.test_server_options
- {user_fixtures: FIXTURES.join('github-with-subgroups.ldif').to_s}
- end
-
def groups_domain
- @ldap.domain("ou=groups,dc=github,dc=com")
+ @ldap.domain("ou=Groups,dc=github,dc=com")
end
def setup
@ldap = GitHub::Ldap.new(options)
- @group = @ldap.group("cn=enterprise,ou=groups,dc=github,dc=com")
+ @group = @ldap.group("cn=ghe-users,ou=Groups,dc=github,dc=com")
end
def test_group?
@@ -25,34 +21,36 @@ def test_group?
end
def test_subgroups
- assert_equal 3, @group.subgroups.size
+ group = @ldap.group("cn=deeply-nested-group0.0,ou=Groups,dc=github,dc=com")
+ assert_equal 2, group.subgroups.size
end
def test_members_from_subgroups
- assert_equal 4, @group.members.size
+ group = @ldap.group("cn=deeply-nested-group0.0,ou=Groups,dc=github,dc=com")
+ assert_equal 10, group.members.size
end
def test_all_domain_groups
groups = groups_domain.all_groups
- assert_equal 8, groups.size
+ assert_equal 27, groups.size
end
def test_filter_domain_groups
- groups = groups_domain.filter_groups('devs')
+ groups = groups_domain.filter_groups('ghe-users')
assert_equal 1, groups.size
end
def test_filter_domain_groups_limited
groups = []
- groups_domain.filter_groups('enter', size: 1) do |entry|
+ groups_domain.filter_groups('deeply-nested-group', size: 1) do |entry|
groups << entry
end
assert_equal 1, groups.size
end
def test_filter_domain_groups_unlimited
- groups = groups_domain.filter_groups('ent')
- assert_equal 3, groups.size
+ groups = groups_domain.filter_groups('deeply-nested-group')
+ assert_equal 5, groups.size
end
def test_unknown_group
@@ -62,33 +60,25 @@ def test_unknown_group
end
class GitHubLdapLoopedGroupTest < GitHub::Ldap::Test
- def self.test_server_options
- {user_fixtures: FIXTURES.join('github-with-looped-subgroups.ldif').to_s}
- end
-
def setup
- @group = GitHub::Ldap.new(options).group("cn=enterprise,ou=groups,dc=github,dc=com")
+ @group = GitHub::Ldap.new(options).group("cn=recursively-nested-groups,ou=Groups,dc=github,dc=com")
end
def test_members_from_subgroups
- assert_equal 4, @group.members.size
+ assert_equal 10, @group.members.size
end
end
class GitHubLdapMissingEntriesTest < GitHub::Ldap::Test
- def self.test_server_options
- {user_fixtures: FIXTURES.join('github-with-missing-entries.ldif').to_s}
- end
-
def setup
@ldap = GitHub::Ldap.new(options)
end
def test_load_right_members
- assert_equal 3, @ldap.domain("cn=spaniards,ou=groups,dc=github,dc=com").bind[:member].size
+ assert_equal 3, @ldap.domain("cn=missing-users,ou=groups,dc=github,dc=com").bind[:member].size
end
def test_ignore_missing_member_entries
- assert_equal 2, @ldap.group("cn=spaniards,ou=groups,dc=github,dc=com").members.size
+ assert_equal 2, @ldap.group("cn=missing-users,ou=groups,dc=github,dc=com").members.size
end
end
diff --git a/test/ldap_test.rb b/test/ldap_test.rb
index 27861d3..d5e9297 100644
--- a/test/ldap_test.rb
+++ b/test/ldap_test.rb
@@ -9,74 +9,153 @@ def test_connection_with_default_options
assert @ldap.test_connection, "Ldap connection expected to succeed"
end
+ def test_connection_with_list_of_hosts_with_one_valid_host
+ ldap = GitHub::Ldap.new(options.merge(hosts: [["localhost", options[:port]]]))
+ assert ldap.test_connection, "Ldap connection expected to succeed"
+ end
+
+ def test_connection_with_list_of_hosts_with_first_valid
+ ldap = GitHub::Ldap.new(options.merge(hosts: [["localhost", options[:port]], ["invalid.local", options[:port]]]))
+ assert ldap.test_connection, "Ldap connection expected to succeed"
+ end
+
+ def test_connection_with_list_of_hosts_with_first_invalid
+ ldap = GitHub::Ldap.new(options.merge(hosts: [["invalid.local", options[:port]], ["localhost", options[:port]]]))
+ assert ldap.test_connection, "Ldap connection expected to succeed"
+ end
+
def test_simple_tls
- assert_equal :simple_tls, @ldap.check_encryption(:ssl)
- assert_equal :simple_tls, @ldap.check_encryption('SSL')
- assert_equal :simple_tls, @ldap.check_encryption(:simple_tls)
+ expected = { method: :simple_tls, tls_options: { } }
+ assert_equal expected, @ldap.check_encryption(:ssl)
+ assert_equal expected, @ldap.check_encryption('SSL')
+ assert_equal expected, @ldap.check_encryption(:simple_tls)
end
def test_start_tls
- assert_equal :start_tls, @ldap.check_encryption(:tls)
- assert_equal :start_tls, @ldap.check_encryption('TLS')
- assert_equal :start_tls, @ldap.check_encryption(:start_tls)
+ expected = { method: :start_tls, tls_options: { } }
+ assert_equal expected, @ldap.check_encryption(:tls)
+ assert_equal expected, @ldap.check_encryption('TLS')
+ assert_equal expected, @ldap.check_encryption(:start_tls)
+ end
+
+ def test_tls_validation
+ assert_equal({ method: :start_tls, tls_options: { verify_mode: OpenSSL::SSL::VERIFY_PEER } },
+ @ldap.check_encryption(:tls, verify_mode: OpenSSL::SSL::VERIFY_PEER))
+ assert_equal({ method: :start_tls, tls_options: { verify_mode: OpenSSL::SSL::VERIFY_NONE } },
+ @ldap.check_encryption(:tls, verify_mode: OpenSSL::SSL::VERIFY_NONE))
+ assert_equal({ method: :start_tls, tls_options: { cert_store: "some/path" } },
+ @ldap.check_encryption(:tls, cert_store: "some/path"))
+ assert_equal({ method: :start_tls, tls_options: {} },
+ @ldap.check_encryption(:tls, nil))
end
def test_search_delegator
- @ldap.domain('dc=github,dc=com').valid_login? 'calavera', 'secret'
+ assert user = @ldap.domain('dc=github,dc=com').valid_login?('user1', 'passworD1')
- result = @ldap.search(
- {:base => 'dc=github,dc=com',
- :attributes => %w(uid),
- :filter => Net::LDAP::Filter.eq('uid', 'calavera')})
+ result = @ldap.search \
+ :base => 'dc=github,dc=com',
+ :attributes => %w(uid),
+ :filter => Net::LDAP::Filter.eq('uid', 'user1')
refute result.empty?
- assert_equal 'calavera', result.first[:uid].first
+ assert_equal 'user1', result.first[:uid].first
end
- def test_virtual_attributes_defaults
- @ldap = GitHub::Ldap.new(options.merge(virtual_attributes: true))
-
- assert @ldap.virtual_attributes.enabled?, "Expected to have virtual attributes enabled with defaults"
- assert_equal 'memberOf', @ldap.virtual_attributes.virtual_membership
+ def test_virtual_attributes_disabled
+ refute @ldap.virtual_attributes.enabled?, "Expected to have virtual attributes disabled"
end
- def test_virtual_attributes_defaults
+ def test_virtual_attributes_configured
ldap = GitHub::Ldap.new(options.merge(virtual_attributes: true))
- assert ldap.virtual_attributes.enabled?, "Expected to have virtual attributes enabled with defaults"
+ assert ldap.virtual_attributes.enabled?,
+ "Expected virtual attributes to be enabled"
assert_equal 'memberOf', ldap.virtual_attributes.virtual_membership
end
- def test_virtual_attributes_hash
+ def test_virtual_attributes_configured_with_membership_attribute
ldap = GitHub::Ldap.new(options.merge(virtual_attributes: {virtual_membership: "isMemberOf"}))
- assert ldap.virtual_attributes.enabled?, "Expected to have virtual attributes enabled with defaults"
+ assert ldap.virtual_attributes.enabled?,
+ "Expected virtual attributes to be enabled"
assert_equal 'isMemberOf', ldap.virtual_attributes.virtual_membership
end
- def test_virtual_attributes_disabled
- refute @ldap.virtual_attributes.enabled?, "Expected to have virtual attributes disabled"
- end
-
def test_search_domains
ldap = GitHub::Ldap.new(options.merge(search_domains: ['dc=github,dc=com']))
- result = ldap.search(filter: Net::LDAP::Filter.eq('uid', 'calavera'))
+ result = ldap.search(filter: Net::LDAP::Filter.eq('uid', 'user1'))
refute result.empty?
- assert_equal 'calavera', result.first[:uid].first
+ assert_equal 'user1', result.first[:uid].first
end
def test_instruments_search
events = @service.subscribe "search.github_ldap"
- result = @ldap.search(filter: "(uid=calavera)", :base => "dc=github,dc=com")
+ result = @ldap.search(filter: "(uid=user1)", :base => "dc=github,dc=com")
refute_predicate result, :empty?
payload, event_result = events.pop
assert payload
assert event_result
assert_equal result, event_result
- assert_equal "(uid=calavera)", payload[:filter].to_s
+ assert_equal "(uid=user1)", payload[:filter].to_s
assert_equal "dc=github,dc=com", payload[:base]
end
+
+ def test_search_strategy_defaults
+ assert_equal GitHub::Ldap::MembershipValidators::Recursive, @ldap.membership_validator
+ assert_equal GitHub::Ldap::MemberSearch::Recursive, @ldap.member_search_strategy
+ end
+
+ def test_search_strategy_detects_active_directory
+ caps = Net::LDAP::Entry.new
+ caps[:supportedcapabilities] = [GitHub::Ldap::ACTIVE_DIRECTORY_V51_OID]
+
+ @ldap.stub :capabilities, caps do
+ @ldap.configure_search_strategy :detect
+
+ assert_equal GitHub::Ldap::MembershipValidators::ActiveDirectory, @ldap.membership_validator
+ assert_equal GitHub::Ldap::MemberSearch::ActiveDirectory, @ldap.member_search_strategy
+ end
+ end
+
+ def test_search_strategy_configured_to_classic
+ @ldap.configure_search_strategy :classic
+ assert_equal GitHub::Ldap::MembershipValidators::Classic, @ldap.membership_validator
+ assert_equal GitHub::Ldap::MemberSearch::Classic, @ldap.member_search_strategy
+ end
+
+ def test_search_strategy_configured_to_recursive
+ @ldap.configure_search_strategy :recursive
+ assert_equal GitHub::Ldap::MembershipValidators::Recursive, @ldap.membership_validator
+ assert_equal GitHub::Ldap::MemberSearch::Recursive, @ldap.member_search_strategy
+ end
+
+ def test_search_strategy_configured_to_active_directory
+ @ldap.configure_search_strategy :active_directory
+ assert_equal GitHub::Ldap::MembershipValidators::ActiveDirectory, @ldap.membership_validator
+ assert_equal GitHub::Ldap::MemberSearch::ActiveDirectory, @ldap.member_search_strategy
+ end
+
+ def test_search_strategy_misconfigured_to_unrecognized_strategy_falls_back_to_default
+ @ldap.configure_search_strategy :unknown
+ assert_equal GitHub::Ldap::MembershipValidators::Recursive, @ldap.membership_validator
+ assert_equal GitHub::Ldap::MemberSearch::Recursive, @ldap.member_search_strategy
+ end
+
+ def test_user_search_strategy_global_catalog_when_configured
+ @ldap.configure_user_search_strategy("global_catalog")
+ assert_kind_of GitHub::Ldap::UserSearch::ActiveDirectory, @ldap.user_search_strategy
+ end
+
+ def test_user_search_strategy_default_when_configured
+ @ldap.configure_user_search_strategy("default")
+ refute_kind_of GitHub::Ldap::UserSearch::ActiveDirectory, @ldap.user_search_strategy
+ assert_kind_of GitHub::Ldap::UserSearch::Default, @ldap.user_search_strategy
+ end
+
+ def test_capabilities
+ assert_kind_of Net::LDAP::Entry, @ldap.capabilities
+ end
end
class GitHubLdapTest < GitHub::Ldap::Test
diff --git a/test/member_search/active_directory_test.rb b/test/member_search/active_directory_test.rb
new file mode 100644
index 0000000..19f2c96
--- /dev/null
+++ b/test/member_search/active_directory_test.rb
@@ -0,0 +1,79 @@
+require_relative '../test_helper'
+
+class GitHubLdapActiveDirectoryMemberSearchStubbedTest < GitHub::Ldap::Test
+ # Only run when AD integration tests aren't run
+ def run(*)
+ return super if self.class.test_env != "activedirectory"
+ Minitest::Result.from(self)
+ end
+
+ def find_group(cn)
+ @domain.groups([cn]).first
+ end
+
+ 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::MemberSearch::ActiveDirectory.new(@ldap)
+ end
+
+ def test_finds_group_members
+ members =
+ @ldap.stub :search, [@entry] do
+ @strategy.perform(find_group("nested-group1")).map(&:dn)
+ end
+ assert_includes members, @entry.dn
+ end
+
+ def test_finds_nested_group_members
+ members =
+ @ldap.stub :search, [@entry] do
+ @strategy.perform(find_group("n-depth-nested-group1")).map(&:dn)
+ end
+ assert_includes members, @entry.dn
+ end
+
+ def test_finds_deeply_nested_group_members
+ members =
+ @ldap.stub :search, [@entry] do
+ @strategy.perform(find_group("n-depth-nested-group9")).map(&:dn)
+ end
+ assert_includes members, @entry.dn
+ end
+end
+
+# See test/support/vm/activedirectory/README.md for details
+class GitHubLdapActiveDirectoryMemberSearchIntegrationTest < GitHub::Ldap::Test
+ # Only run this test suite if ActiveDirectory is configured
+ def run(*)
+ return super if self.class.test_env == "activedirectory"
+ Minitest::Result.from(self)
+ end
+
+ def find_group(cn)
+ @domain.groups([cn]).first
+ end
+
+ def setup
+ @ldap = GitHub::Ldap.new(options)
+ @domain = @ldap.domain(options[:search_domains])
+ @entry = @domain.user?('user1')
+ @strategy = GitHub::Ldap::MemberSearch::ActiveDirectory.new(@ldap)
+ 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
+end
diff --git a/test/member_search/classic_test.rb b/test/member_search/classic_test.rb
new file mode 100644
index 0000000..656e12b
--- /dev/null
+++ b/test/member_search/classic_test.rb
@@ -0,0 +1,40 @@
+require_relative '../test_helper'
+
+class GitHubLdapRecursiveMemberSearchTest < 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::MemberSearch::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::MemberSearch::Classic.new(@ldap, depth: 2)
+ members = strategy.perform(find_group("n-depth-nested-group9")).map(&:dn)
+ assert_includes members, @entry.dn
+ end
+end
diff --git a/test/member_search/recursive_test.rb b/test/member_search/recursive_test.rb
new file mode 100644
index 0000000..a2d388d
--- /dev/null
+++ b/test/member_search/recursive_test.rb
@@ -0,0 +1,40 @@
+require_relative '../test_helper'
+
+class GitHubLdapRecursiveMemberSearchTest < 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::MemberSearch::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::MemberSearch::Recursive.new(@ldap, depth: 2)
+ members = strategy.perform(find_group("n-depth-nested-group9")).map(&:dn)
+ refute_includes members, @entry.dn
+ end
+end
diff --git a/test/membership_validators/active_directory_test.rb b/test/membership_validators/active_directory_test.rb
new file mode 100644
index 0000000..2160f8d
--- /dev/null
+++ b/test/membership_validators/active_directory_test.rb
@@ -0,0 +1,137 @@
+require_relative '../test_helper'
+
+class GitHubLdapActiveDirectoryMembershipValidatorsStubbedTest < GitHub::Ldap::Test
+ # Only run when AD integration tests aren't run
+ def run(*)
+ return super if self.class.test_env != "activedirectory"
+ Minitest::Result.from(self)
+ end
+
+ 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')
+ @validator = GitHub::Ldap::MembershipValidators::ActiveDirectory
+ end
+
+ def make_validator(groups)
+ groups = @domain.groups(groups)
+ @validator.new(@ldap, groups)
+ end
+
+ def test_validates_user_in_group
+ validator = make_validator(%w(nested-group1))
+
+ @ldap.stub :search, [@entry] do
+ assert validator.perform(@entry)
+ end
+ end
+
+ def test_validates_user_in_child_group
+ validator = make_validator(%w(n-depth-nested-group1))
+
+ @ldap.stub :search, [@entry] do
+ assert validator.perform(@entry)
+ end
+ end
+
+ def test_validates_user_in_grandchild_group
+ validator = make_validator(%w(n-depth-nested-group2))
+
+ @ldap.stub :search, [@entry] do
+ assert validator.perform(@entry)
+ end
+ end
+
+ def test_validates_user_in_great_grandchild_group
+ validator = make_validator(%w(n-depth-nested-group3))
+
+ @ldap.stub :search, [@entry] do
+ assert validator.perform(@entry)
+ end
+ end
+
+ def test_does_not_validate_user_not_in_group
+ validator = make_validator(%w(ghe-admins))
+
+ @ldap.stub :search, [] do
+ refute validator.perform(@entry)
+ end
+ end
+
+ def test_does_not_validate_user_not_in_any_group
+ entry = @domain.user?('groupless-user1')
+ validator = make_validator(%w(all-users))
+
+ @ldap.stub :search, [] do
+ refute validator.perform(entry)
+ end
+ end
+end
+
+# See test/support/vm/activedirectory/README.md for details
+class GitHubLdapActiveDirectoryMembershipValidatorsIntegrationTest < GitHub::Ldap::Test
+ # Only run this test suite if ActiveDirectory is configured
+ def run(*)
+ return super if self.class.test_env == "activedirectory"
+ Minitest::Result.from(self)
+ end
+
+ def setup
+ @ldap = GitHub::Ldap.new(options)
+ @domain = @ldap.domain(options[:search_domains])
+ @entry = @domain.user?('user1')
+ @validator = GitHub::Ldap::MembershipValidators::ActiveDirectory
+ end
+
+ def make_validator(groups)
+ groups = @domain.groups(groups)
+ @validator.new(@ldap, groups)
+ end
+
+ def test_validates_user_in_group
+ validator = make_validator(%w(nested-group1))
+ assert validator.perform(@entry)
+ end
+
+ def test_validates_user_in_child_group
+ validator = make_validator(%w(n-depth-nested-group1))
+ assert validator.perform(@entry)
+ end
+
+ def test_validates_user_in_grandchild_group
+ validator = make_validator(%w(n-depth-nested-group2))
+ assert validator.perform(@entry)
+ end
+
+ def test_validates_user_in_great_grandchild_group
+ validator = make_validator(%w(n-depth-nested-group3))
+ assert validator.perform(@entry)
+ end
+
+ def test_does_not_validate_user_not_in_group
+ validator = make_validator(%w(ghe-admins))
+ refute validator.perform(@entry)
+ end
+
+ def test_does_not_validate_user_not_in_any_group
+ skip "update AD ldif to have a groupless user"
+ @entry = @domain.user?('groupless-user1')
+ validator = make_validator(%w(all-users))
+ refute validator.perform(@entry)
+ end
+
+ def test_validates_user_in_posix_group
+ validator = make_validator(%w(posix-group1))
+ assert validator.perform(@entry)
+ end
+
+ def test_validates_user_in_group_with_differently_cased_dn
+ validator = make_validator(%w(all-users))
+ @entry[:dn].map(&:upcase!)
+ assert validator.perform(@entry)
+
+ @entry[:dn].map(&:downcase!)
+ assert validator.perform(@entry)
+ end
+end
diff --git a/test/membership_validators/classic_test.rb b/test/membership_validators/classic_test.rb
new file mode 100644
index 0000000..9b2c32b
--- /dev/null
+++ b/test/membership_validators/classic_test.rb
@@ -0,0 +1,51 @@
+require_relative '../test_helper'
+
+class GitHubLdapClassicMembershipValidatorsTest < 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')
+ @validator = GitHub::Ldap::MembershipValidators::Classic
+ end
+
+ def make_validator(groups)
+ groups = @domain.groups(groups)
+ @validator.new(@ldap, groups)
+ end
+
+ def test_validates_user_in_group
+ validator = make_validator(%w(nested-group1))
+ assert validator.perform(@entry)
+ end
+
+ def test_validates_user_in_child_group
+ validator = make_validator(%w(n-depth-nested-group1))
+ assert validator.perform(@entry)
+ end
+
+ def test_validates_user_in_grandchild_group
+ validator = make_validator(%w(n-depth-nested-group2))
+ assert validator.perform(@entry)
+ end
+
+ def test_validates_user_in_great_grandchild_group
+ validator = make_validator(%w(n-depth-nested-group3))
+ assert validator.perform(@entry)
+ end
+
+ def test_does_not_validate_user_not_in_group
+ validator = make_validator(%w(ghe-admins))
+ refute validator.perform(@entry)
+ end
+
+ def test_does_not_validate_user_not_in_any_group
+ @entry = @domain.user?('groupless-user1')
+ validator = make_validator(%w(all-users))
+ refute validator.perform(@entry)
+ end
+
+ def test_validates_user_in_posix_group
+ validator = make_validator(%w(posix-group1))
+ assert validator.perform(@entry)
+ end
+end
diff --git a/test/membership_validators/recursive_test.rb b/test/membership_validators/recursive_test.rb
new file mode 100644
index 0000000..072ffca
--- /dev/null
+++ b/test/membership_validators/recursive_test.rb
@@ -0,0 +1,56 @@
+require_relative '../test_helper'
+
+class GitHubLdapRecursiveMembershipValidatorsTest < 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')
+ @validator = GitHub::Ldap::MembershipValidators::Recursive
+ end
+
+ def make_validator(groups, options = {})
+ groups = @domain.groups(groups)
+ @validator.new(@ldap, groups, options)
+ end
+
+ def test_validates_user_in_group
+ validator = make_validator(%w(nested-group1))
+ assert validator.perform(@entry)
+ end
+
+ def test_validates_user_in_child_group
+ validator = make_validator(%w(n-depth-nested-group1))
+ assert validator.perform(@entry)
+ end
+
+ def test_validates_user_in_grandchild_group
+ validator = make_validator(%w(n-depth-nested-group2))
+ assert validator.perform(@entry)
+ end
+
+ def test_validates_user_in_great_grandchild_group
+ validator = make_validator(%w(n-depth-nested-group3))
+ assert validator.perform(@entry)
+ end
+
+ def test_does_not_validate_user_in_great_granchild_group_with_depth
+ validator = make_validator(%w(n-depth-nested-group3), depth: 2)
+ refute validator.perform(@entry)
+ end
+
+ def test_does_not_validate_user_not_in_group
+ validator = make_validator(%w(ghe-admins))
+ refute validator.perform(@entry)
+ end
+
+ def test_does_not_validate_user_not_in_any_group
+ @entry = @domain.user?('groupless-user1')
+ validator = make_validator(%w(all-users))
+ refute validator.perform(@entry)
+ end
+
+ def test_validates_user_in_posix_group
+ validator = make_validator(%w(posix-group1))
+ assert validator.perform(@entry)
+ end
+end
diff --git a/test/posix_group_test.rb b/test/posix_group_test.rb
index a71e252..e21b3ac 100644
--- a/test/posix_group_test.rb
+++ b/test/posix_group_test.rb
@@ -1,54 +1,51 @@
require_relative 'test_helper'
class GitHubLdapPosixGroupTest < GitHub::Ldap::Test
- def self.test_server_options
- {user_fixtures: FIXTURES.join('github-with-subgroups.ldif').to_s}
- end
-
def setup
@simple_group = Net::LDAP::Entry._load("""
-dn: cn=enterprise-posix-devs,ou=groups,dc=github,dc=com
-cn: enterprise-posix-devs
+dn: cn=simple-group,ou=Groups,dc=github,dc=com
+cn: simple-group
objectClass: posixGroup
-memberUid: benburkert
-memberUid: mtodd""")
+memberUid: user1
+memberUid: user2""")
@one_level_deep_group = Net::LDAP::Entry._load("""
-dn: cn=enterprise-posix-ops,ou=groups,dc=github,dc=com
-cn: enterprise-posix-ops
+dn: cn=one-level-deep-group,ou=Groups,dc=github,dc=com
+cn: one-level-deep-group
objectClass: posixGroup
objectClass: groupOfNames
-memberUid: sbryant
-member: cn=spaniards,ou=groups,dc=github,dc=com""")
+memberUid: user6
+member: cn=ghe-users,ou=Groups,dc=github,dc=com""")
@two_levels_deep_group = Net::LDAP::Entry._load("""
-dn: cn=enterprise-posix,ou=groups,dc=github,dc=com
-cn: Enterprise Posix
+dn: cn=two-levels-deep-group,ou=Groups,dc=github,dc=com
+cn: two-levels-deep-group
objectClass: posixGroup
objectClass: groupOfNames
-memberUid: calavera
-member: cn=enterprise-devs,ou=groups,dc=github,dc=com
-member: cn=enterprise-ops,ou=groups,dc=github,dc=com""")
+memberUid: user6
+member: cn=n-depth-nested-group2,ou=Groups,dc=github,dc=com
+member: cn=posix-group1,ou=Groups,dc=github,dc=com""")
@empty_group = Net::LDAP::Entry._load("""
-dn: cn=enterprise-posix-empty,ou=groups,dc=github,dc=com
-cn: enterprise-posix-empty
+dn: cn=empty-group,ou=Groups,dc=github,dc=com
+cn: empty-group
objectClass: posixGroup""")
@ldap = GitHub::Ldap.new(options.merge(search_domains: %w(dc=github,dc=com)))
end
def test_posix_group
- assert GitHub::Ldap::PosixGroup.valid?(@simple_group),
+ entry = @ldap.search(filter: "(cn=posix-group1)").first
+ assert GitHub::Ldap::PosixGroup.valid?(entry),
"Expected entry to be a valid posixGroup"
end
def test_posix_simple_members
- group = GitHub::Ldap::PosixGroup.new(@ldap, @simple_group)
+ assert group = @ldap.group("cn=posix-group1,ou=Groups,dc=github,dc=com")
members = group.members
- assert_equal 2, members.size
- assert_equal %w(benburkert mtodd), members.map(&:uid).flatten.sort
+ assert_equal 5, members.size
+ assert_equal %w(user1 user2 user3 user4 user5), members.map(&:uid).flatten.sort
end
def test_posix_combined_group
@@ -62,7 +59,7 @@ def test_posix_combined_group_unique_members
group = GitHub::Ldap::PosixGroup.new(@ldap, @two_levels_deep_group)
members = group.members
- assert_equal 4, members.size
+ assert_equal 10, members.size
end
def test_empty_subgroups
@@ -81,7 +78,7 @@ def test_posix_combined_group_subgroups
def test_is_member_simple_group
group = GitHub::Ldap::PosixGroup.new(@ldap, @simple_group)
- user = @ldap.domain("uid=benburkert,ou=users,dc=github,dc=com").bind
+ user = @ldap.domain("uid=user1,ou=People,dc=github,dc=com").bind
assert group.is_member?(user),
"Expected user in the memberUid list to be a member of the posixgroup"
@@ -89,7 +86,7 @@ def test_is_member_simple_group
def test_is_member_combined_group
group = GitHub::Ldap::PosixGroup.new(@ldap, @one_level_deep_group)
- user = @ldap.domain("uid=calavera,ou=users,dc=github,dc=com").bind
+ user = @ldap.domain("uid=user1,ou=People,dc=github,dc=com").bind
assert group.is_member?(user),
"Expected user in a subgroup to be a member of the posixgroup"
@@ -97,7 +94,7 @@ def test_is_member_combined_group
def test_is_not_member_simple_group
group = GitHub::Ldap::PosixGroup.new(@ldap, @simple_group)
- user = @ldap.domain("uid=calavera,ou=users,dc=github,dc=com").bind
+ user = @ldap.domain("uid=user10,ou=People,dc=github,dc=com").bind
refute group.is_member?(user),
"Expected user to not be member when her uid is not in the list of memberUid"
@@ -105,7 +102,7 @@ def test_is_not_member_simple_group
def test_is_member_combined_group
group = GitHub::Ldap::PosixGroup.new(@ldap, @one_level_deep_group)
- user = @ldap.domain("uid=benburkert,ou=users,dc=github,dc=com").bind
+ user = @ldap.domain("uid=user10,ou=People,dc=github,dc=com").bind
refute group.is_member?(user),
"Expected user to not be member when she's not member of any subgroup"
diff --git a/test/referral_chaser_test.rb b/test/referral_chaser_test.rb
new file mode 100644
index 0000000..3a19973
--- /dev/null
+++ b/test/referral_chaser_test.rb
@@ -0,0 +1,102 @@
+require_relative 'test_helper'
+
+class GitHubLdapReferralChaserTestCases < GitHub::Ldap::Test
+
+ def setup
+ @ldap = GitHub::Ldap.new(options)
+ @chaser = GitHub::Ldap::ReferralChaser.new(@ldap)
+ end
+
+ def test_creates_referral_with_connection_credentials
+ @ldap.expects(:search).yields({ search_referrals: ["ldap://dc1.ghe.local/"]}).returns([])
+
+ referral = mock("GitHub::Ldap::ReferralChaser::Referral")
+ referral.stubs(:search).returns([])
+
+ GitHub::Ldap::ReferralChaser::Referral.expects(:new)
+ .with("ldap://dc1.ghe.local/", "uid=admin,dc=github,dc=com", "passworD1", options[:port])
+ .returns(referral)
+
+ @chaser.search({})
+ end
+
+ def test_creates_referral_with_default_port
+ @ldap.expects(:search).yields({
+ search_referrals: ["ldap://dc1.ghe.local/CN=Maggie%20Mae,CN=Users,DC=dc4,DC=ghe,DC=local"]
+ }).returns([])
+
+ stub_referral_connection = mock("GitHub::Ldap")
+ stub_referral_connection.stubs(:search).returns([])
+ GitHub::Ldap::ConnectionCache.expects(:get_connection).with(has_entry(port: options[:port])).returns(stub_referral_connection)
+ chaser = GitHub::Ldap::ReferralChaser.new(@ldap)
+ chaser.search({})
+ end
+
+ def test_creates_referral_for_first_referral_string
+ @ldap.expects(:search).multiple_yields([
+ { search_referrals:
+ ["ldap://dc1.ghe.local/CN=Maggie%20Mae,CN=Users,DC=dc4,DC=ghe,DC=local",
+ "ldap://dc2.ghe.local/CN=Maggie%20Mae,CN=Users,DC=dc4,DC=ghe,DC=local"]
+ }
+ ],[
+ { search_referrals:
+ ["ldap://dc3.ghe.local/CN=Maggie%20Mae,CN=Users,DC=dc4,DC=ghe,DC=local",
+ "ldap://dc4.ghe.local/CN=Maggie%20Mae,CN=Users,DC=dc4,DC=ghe,DC=local"]
+ }
+ ]).returns([])
+
+ referral = mock("GitHub::Ldap::ReferralChaser::Referral")
+ referral.stubs(:search).returns([])
+
+ GitHub::Ldap::ReferralChaser::Referral.expects(:new)
+ .with(
+ "ldap://dc1.ghe.local/CN=Maggie%20Mae,CN=Users,DC=dc4,DC=ghe,DC=local",
+ "uid=admin,dc=github,dc=com",
+ "passworD1",
+ options[:port])
+ .returns(referral)
+
+ @chaser.search({})
+ end
+
+ def test_returns_referral_search_results
+ @ldap.expects(:search).multiple_yields([
+ { search_referrals:
+ ["ldap://dc1.ghe.local/CN=Maggie%20Mae,CN=Users,DC=dc4,DC=ghe,DC=local",
+ "ldap://dc2.ghe.local/CN=Maggie%20Mae,CN=Users,DC=dc4,DC=ghe,DC=local"]
+ }
+ ],[
+ { search_referrals:
+ ["ldap://dc3.ghe.local/CN=Maggie%20Mae,CN=Users,DC=dc4,DC=ghe,DC=local",
+ "ldap://dc4.ghe.local/CN=Maggie%20Mae,CN=Users,DC=dc4,DC=ghe,DC=local"]
+ }
+ ]).returns([])
+
+ referral = mock("GitHub::Ldap::ReferralChaser::Referral")
+ referral.expects(:search).returns(["result", "result"])
+
+ GitHub::Ldap::ReferralChaser::Referral.expects(:new).returns(referral)
+
+ results = @chaser.search({})
+ assert_equal(["result", "result"], results)
+ end
+
+ def test_handle_blank_url_string_in_referral
+ @ldap.expects(:search).yields({ search_referrals: [""] })
+
+ results = @chaser.search({})
+ assert_equal([], results)
+ end
+
+ def test_returns_referral_search_results
+ @ldap.expects(:search).yields({ foo: ["not a referral"] })
+
+ GitHub::Ldap::ReferralChaser::Referral.expects(:new).never
+ results = @chaser.search({})
+ end
+
+ def test_referral_should_use_host_from_referral_string
+ GitHub::Ldap::ConnectionCache.expects(:get_connection).with(has_entry(host: "dc4.ghe.local"))
+ GitHub::Ldap::ReferralChaser::Referral.new("ldap://dc4.ghe.local/CN=Maggie%20Mae,CN=Users,DC=dc4,DC=ghe,DC=local", "", "")
+ end
+end
diff --git a/test/support/vm/activedirectory/.gitignore b/test/support/vm/activedirectory/.gitignore
new file mode 100644
index 0000000..137e678
--- /dev/null
+++ b/test/support/vm/activedirectory/.gitignore
@@ -0,0 +1 @@
+env.sh
diff --git a/test/support/vm/activedirectory/README.md b/test/support/vm/activedirectory/README.md
new file mode 100644
index 0000000..36155bd
--- /dev/null
+++ b/test/support/vm/activedirectory/README.md
@@ -0,0 +1,26 @@
+# Local ActiveDirectory Integration Testing
+
+Integration tests are not run for ActiveDirectory in continuous integration
+because we cannot install a Windows VM on TravisCI. To test ActiveDirectory,
+configure a local VM with AD running (this is left as an exercise for the
+reader).
+
+To run integration tests against the local ActiveDirectory VM, from the project
+root run:
+
+``` bash
+# duplicate example env.sh for specific config
+$ cp test/support/vm/activedirectory/env.sh{.example,}
+
+# edit env.sh and fill in with your VM's values, then
+$ source test/support/vm/activedirectory/env.sh
+
+# run all tests against AD
+$ time bundle exec rake
+
+# run a specific test file against AD
+$ time bundle exec ruby test/membership_validators/active_directory_test.rb
+
+# reset environment to test other LDAP servers
+$ source test/support/vm/activedirectory/reset-env.sh
+```
diff --git a/test/support/vm/activedirectory/env.sh.example b/test/support/vm/activedirectory/env.sh.example
new file mode 100644
index 0000000..3ca2c9b
--- /dev/null
+++ b/test/support/vm/activedirectory/env.sh.example
@@ -0,0 +1,8 @@
+# Copy this to ad-env.sh, and fill in with your own values
+
+export TESTENV=activedirectory
+export INTEGRATION_HOST=123.123.123.123
+export INTEGRATION_PORT=389
+export INTEGRATION_USER="CN=Administrator,CN=Users,DC=ad,DC=example,DC=com"
+export INTEGRATION_PASSWORD='passworD1'
+export INTEGRATION_SEARCH_DOMAINS='CN=Users,DC=example,DC=com'
diff --git a/test/support/vm/activedirectory/reset-env.sh b/test/support/vm/activedirectory/reset-env.sh
new file mode 100644
index 0000000..971423f
--- /dev/null
+++ b/test/support/vm/activedirectory/reset-env.sh
@@ -0,0 +1,6 @@
+unset TESTENV
+unset INTEGRATION_HOST
+unset INTEGRATION_PORT
+unset INTEGRATION_USER
+unset INTEGRATION_PASSWORD
+unset INTEGRATION_SEARCH_DOMAINS
diff --git a/test/support/vm/openldap/.gitignore b/test/support/vm/openldap/.gitignore
new file mode 100644
index 0000000..dace708
--- /dev/null
+++ b/test/support/vm/openldap/.gitignore
@@ -0,0 +1 @@
+/.vagrant
diff --git a/test/support/vm/openldap/README.md b/test/support/vm/openldap/README.md
new file mode 100644
index 0000000..ced5a63
--- /dev/null
+++ b/test/support/vm/openldap/README.md
@@ -0,0 +1,32 @@
+# Local OpenLDAP Integration Testing
+
+Set up a [Vagrant](http://www.vagrantup.com/) VM to run tests against OpenLDAP locally.
+
+To run tests against OpenLDAP (instead of ApacheDS) locally:
+
+``` bash
+# start VM (from the correct directory)
+$ cd test/support/vm/openldap/
+$ vagrant up
+
+# get the IP address of the VM
+$ ip=$(vagrant ssh -- "ifconfig eth1 | grep -o -E '[0-9]+\.[0-9]+\.[0-9]+\.[0-9]+' | head -n1")
+
+# change back to root project directory
+$ cd ../../../..
+
+# run all tests against OpenLDAP
+$ time TESTENV=openldap INTEGRATION_HOST=$ip bundle exec rake
+
+# run a specific test file against OpenLDAP
+$ time TESTENV=openldap INTEGRATION_HOST=$ip bundle exec ruby test/membership_validators/recursive_test.rb
+
+# run OpenLDAP tests by default
+$ export TESTENV=openldap
+$ export TESTENV=$ip
+
+# now run tests without having to set ENV variables
+$ time bundle exec rake
+```
+
+You may need to `gem install vagrant` first in order to provision the VM.
diff --git a/test/support/vm/openldap/Vagrantfile b/test/support/vm/openldap/Vagrantfile
new file mode 100644
index 0000000..abb44ae
--- /dev/null
+++ b/test/support/vm/openldap/Vagrantfile
@@ -0,0 +1,35 @@
+# -*- mode: ruby -*-
+# vi: set ft=ruby :
+
+# Vagrantfile API/syntax version. Don't touch unless you know what you're doing!
+VAGRANTFILE_API_VERSION = "2"
+
+Vagrant.configure(VAGRANTFILE_API_VERSION) do |config|
+ config.vm.hostname = "openldap.github.org"
+
+ config.vm.box = "hashicorp/precise64"
+
+ config.vm.network "private_network", type: :dhcp
+
+ config.ssh.forward_agent = true
+
+ # config.vm.provision "shell", inline: "apt-get update; exec env /vagrant_data/script/install-openldap"
+ config.vm.provision "shell", inline: 'echo "HIIIIIII"', run: "always"
+
+ config.vm.synced_folder "../../../..", "/vagrant_data"
+
+ config.vm.provider "vmware_fusion" do |vb, override|
+ override.vm.box = "hashicorp/precise64"
+ vb.memory = 4596
+ vb.vmx["displayname"] = "integration tests vm"
+ vb.vmx["numvcpus"] = "2"
+ end
+
+ config.vm.provider "virtualbox" do |vb, override|
+ vb.memory = 4096
+ vb.customize ["modifyvm", :id, "--nicpromisc2", "allow-all"]
+ vb.customize ["modifyvm", :id, "--chipset", "ich9"]
+ vb.customize ["modifyvm", :id, "--vram", "16"]
+ end
+
+end
diff --git a/test/test_helper.rb b/test/test_helper.rb
index d996c5f..e92caa6 100644
--- a/test/test_helper.rb
+++ b/test/test_helper.rb
@@ -10,38 +10,92 @@
require 'github/ldap'
require 'github/ldap/server'
+require 'minitest/mock'
require 'minitest/autorun'
+require 'mocha/minitest'
+
+if ENV.fetch('TESTENV', "apacheds") == "apacheds"
+ # Make sure we clean up running test server
+ # NOTE: We need to do this manually since its internal `at_exit` hook
+ # collides with Minitest's autorun at_exit handling, hence this hook.
+ Minitest.after_run do
+ GitHub::Ldap.stop_server
+ end
+end
+
class GitHub::Ldap::Test < Minitest::Test
+ def self.test_env
+ ENV.fetch("TESTENV", "apacheds")
+ end
+
def self.run(reporter, options = {})
start_server
- super
+ result = super
stop_server
+ result
end
def self.stop_server
- GitHub::Ldap.stop_server
+ if test_env == "apacheds"
+ # see Minitest.after_run hook above.
+ # GitHub::Ldap.stop_server
+ end
+ end
+
+ def self.test_server_options
+ {
+ custom_schemas: FIXTURES.join('posixGroup.schema.ldif').to_s,
+ user_fixtures: FIXTURES.join('common/seed.ldif').to_s,
+ allow_anonymous: true,
+ verbose: ENV.fetch("VERBOSE", "0") == "1"
+ }
end
def self.start_server
- server_opts = respond_to?(:test_server_options) ? test_server_options : {}
- GitHub::Ldap.start_server(server_opts)
+ if test_env == "apacheds"
+ # skip this if a server has already been started
+ return if GitHub::Ldap.ldap_server
+
+ GitHub::Ldap.start_server(test_server_options)
+ end
end
def options
@service = MockInstrumentationService.new
- @options ||= GitHub::Ldap.server_options.merge \
- host: 'localhost',
- uid: 'uid',
- :instrumentation_service => @service
+ @options ||=
+ case self.class.test_env
+ when "apacheds"
+ GitHub::Ldap.server_options.merge \
+ admin_user: 'uid=admin,dc=github,dc=com',
+ admin_password: 'passworD1',
+ host: 'localhost',
+ uid: 'uid',
+ instrumentation_service: @service
+ when "openldap"
+ {
+ host: ENV.fetch("INTEGRATION_HOST", "localhost"),
+ port: 389,
+ admin_user: 'uid=admin,dc=github,dc=com',
+ admin_password: 'passworD1',
+ search_domains: %w(dc=github,dc=com),
+ uid: 'uid',
+ instrumentation_service: @service
+ }
+ when "activedirectory"
+ {
+ host: ENV.fetch("INTEGRATION_HOST"),
+ port: ENV.fetch("INTEGRATION_PORT", 389),
+ admin_user: ENV.fetch("INTEGRATION_USER"),
+ admin_password: ENV.fetch("INTEGRATION_PASSWORD"),
+ search_domains: ENV.fetch("INTEGRATION_SEARCH_DOMAINS"),
+ instrumentation_service: @service
+ }
+ end
end
end
class GitHub::Ldap::UnauthenticatedTest < GitHub::Ldap::Test
- def self.start_server
- GitHub::Ldap.start_server(:allow_anonymous => true)
- end
-
def options
@options ||= begin
super.delete_if {|k, _| [:admin_user, :admin_password].include?(k)}
diff --git a/test/url_test.rb b/test/url_test.rb
new file mode 100644
index 0000000..db44ce2
--- /dev/null
+++ b/test/url_test.rb
@@ -0,0 +1,85 @@
+require_relative 'test_helper'
+
+class GitHubLdapURLTestCases < GitHub::Ldap::Test
+
+ def setup
+ @url = GitHub::Ldap::URL.new("ldap://dc4.ghe.local:123/CN=Maggie%20Mae,CN=Users,DC=dc4,DC=ghe,DC=local?cn,mail,telephoneNumber?base?(cn=Charlie)")
+ end
+
+ def test_host
+ assert_equal "dc4.ghe.local", @url.host
+ end
+
+ def test_port
+ assert_equal 123, @url.port
+ end
+
+ def test_scheme
+ assert_equal "ldap", @url.scheme
+ end
+
+ def test_default_port
+ url = GitHub::Ldap::URL.new("ldap://dc4.ghe.local/CN=Maggie%20Mae,CN=Users,DC=dc4,DC=ghe,DC=local?attributes?scope?filter")
+ assert_equal 389, url.port
+ end
+
+ def test_simple_url
+ url = GitHub::Ldap::URL.new("ldap://dc4.ghe.local")
+ assert_equal 389, url.port
+ assert_equal "dc4.ghe.local", url.host
+ assert_equal "ldap", url.scheme
+ assert_equal "", url.dn
+ assert_equal nil, url.attributes
+ assert_equal nil, url.filter
+ assert_equal nil, url.scope
+ end
+
+ def test_invalid_scheme
+ ex = assert_raises(GitHub::Ldap::URL::InvalidLdapURLException) do
+ GitHub::Ldap::URL.new("http://dc4.ghe.local")
+ end
+ assert_equal("Invalid LDAP URL: http://dc4.ghe.local", ex.message)
+ end
+
+ def test_invalid_url
+ ex = assert_raises(GitHub::Ldap::URL::InvalidLdapURLException) do
+ GitHub::Ldap::URL.new("not a url")
+ end
+ assert_equal("Invalid LDAP URL: not a url", ex.message)
+ end
+
+ def test_parse_dn
+ assert_equal "CN=Maggie Mae,CN=Users,DC=dc4,DC=ghe,DC=local", @url.dn
+ end
+
+ def test_parse_attributes
+ assert_equal "cn,mail,telephoneNumber", @url.attributes
+ end
+
+ def test_parse_filter
+ assert_equal "(cn=Charlie)", @url.filter
+ end
+
+ def test_parse_scope
+ assert_equal "base", @url.scope
+ end
+
+ def test_default_scope
+ url = GitHub::Ldap::URL.new("ldap://dc4.ghe.local/base_dn?cn=joe??filter")
+ assert_equal "", url.scope
+ end
+
+ def test_net_ldap_scopes
+ sub_scope_url = GitHub::Ldap::URL.new("ldap://ghe.local/base_dn?cn=joe?sub?filter")
+ one_scope_url = GitHub::Ldap::URL.new("ldap://ghe.local/base_dn?cn=joe?one?filter")
+ base_scope_url = GitHub::Ldap::URL.new("ldap://ghe.local/base_dn?cn=joe?base?filter")
+ default_scope_url = GitHub::Ldap::URL.new("ldap://dc4.ghe.local/base_dn?cn=joe??filter")
+ invalid_scope_url = GitHub::Ldap::URL.new("ldap://dc4.ghe.local/base_dn?cn=joe?invalid?filter")
+
+ assert_equal Net::LDAP::SearchScope_BaseObject, base_scope_url.net_ldap_scope
+ assert_equal Net::LDAP::SearchScope_SingleLevel, one_scope_url.net_ldap_scope
+ assert_equal Net::LDAP::SearchScope_WholeSubtree, sub_scope_url.net_ldap_scope
+ assert_equal Net::LDAP::SearchScope_BaseObject, default_scope_url.net_ldap_scope
+ assert_equal Net::LDAP::SearchScope_BaseObject, invalid_scope_url.net_ldap_scope
+ end
+end
diff --git a/test/user_search/active_directory_test.rb b/test/user_search/active_directory_test.rb
new file mode 100644
index 0000000..32bed79
--- /dev/null
+++ b/test/user_search/active_directory_test.rb
@@ -0,0 +1,53 @@
+require_relative '../test_helper'
+
+class GitHubLdapActiveDirectoryUserSearchTests < GitHub::Ldap::Test
+
+ def test_global_catalog_returns_empty_array_for_no_results
+ ldap = GitHub::Ldap.new(options.merge(host: 'ghe.dev'))
+ ad_user_search = GitHub::Ldap::UserSearch::ActiveDirectory.new(ldap)
+
+ mock_global_catalog_connection = mock("GitHub::Ldap::UserSearch::GlobalCatalog")
+ mock_global_catalog_connection.expects(:search).returns(nil)
+ ad_user_search.expects(:global_catalog_connection).returns(mock_global_catalog_connection)
+ results = ad_user_search.perform("login", "CN=Joe", "uid", {})
+ assert_equal [], results
+ end
+
+ def test_global_catalog_returns_array_of_results
+ ldap = GitHub::Ldap.new(options.merge(host: 'ghe.dev'))
+ ad_user_search = GitHub::Ldap::UserSearch::ActiveDirectory.new(ldap)
+
+ mock_global_catalog_connection = mock("GitHub::Ldap::UserSearch::GlobalCatalog")
+ stub_entry = mock("Net::LDAP::Entry")
+
+ mock_global_catalog_connection.expects(:search).returns([stub_entry])
+ ad_user_search.expects(:global_catalog_connection).returns(mock_global_catalog_connection)
+
+ results = ad_user_search.perform("login", "CN=Joe", "uid", {})
+ assert_equal [stub_entry], results
+ end
+
+ def test_searches_with_empty_base_dn
+ ldap = GitHub::Ldap.new(options.merge(host: 'ghe.dev'))
+ ad_user_search = GitHub::Ldap::UserSearch::ActiveDirectory.new(ldap)
+
+ mock_global_catalog_connection = mock("GitHub::Ldap::UserSearch::GlobalCatalog")
+ mock_global_catalog_connection.expects(:search).with(has_entry(:base => ""))
+ ad_user_search.expects(:global_catalog_connection).returns(mock_global_catalog_connection)
+ ad_user_search.perform("login", "CN=Joe", "uid", {})
+ end
+
+ def test_global_catalog_default_settings
+ ldap = GitHub::Ldap.new(options.merge(host: 'ghe.dev'))
+ global_catalog = GitHub::Ldap::UserSearch::GlobalCatalog.connection(ldap)
+ instrumentation_service = global_catalog.instance_variable_get(:@instrumentation_service)
+
+ auth = global_catalog.instance_variable_get(:@auth)
+ assert_equal :simple, auth[:method]
+ assert_equal "uid=admin,dc=github,dc=com", auth[:username]
+ assert_equal "passworD1", auth[:password]
+ assert_equal "ghe.dev", global_catalog.host
+ assert_equal 3268, global_catalog.port
+ assert_equal "MockInstrumentationService", instrumentation_service.class.name
+ end
+end
diff --git a/test/user_search/default_test.rb b/test/user_search/default_test.rb
new file mode 100644
index 0000000..abc230a
--- /dev/null
+++ b/test/user_search/default_test.rb
@@ -0,0 +1,19 @@
+require_relative '../test_helper'
+
+class GitHubLdapActiveDirectoryUserSearchTests < GitHub::Ldap::Test
+ def setup
+ @ldap = GitHub::Ldap.new(options)
+ @default_user_search = GitHub::Ldap::UserSearch::Default.new(@ldap)
+ end
+
+ def test_default_search_options
+ @ldap.expects(:search).with(has_entries(
+ attributes: [],
+ size: 1,
+ paged_searches_supported: true,
+ base: "CN=HI,CN=McDunnough",
+ filter: kind_of(Net::LDAP::Filter)
+ ))
+ @default_user_search.perform("","CN=HI,CN=McDunnough","",{})
+ end
+end