diff --git a/.travis.yml b/.travis.yml index 09e4709..d9fd02b 100644 --- a/.travis.yml +++ b/.travis.yml @@ -1,12 +1,22 @@ language: ruby rvm: - - 1.9.3 + - 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 diff --git a/CHANGELOG.md b/CHANGELOG.md index 6c108b5..e082762 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,45 @@ # 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) diff --git a/Gemfile b/Gemfile index 4abbfe8..a409814 100644 --- a/Gemfile +++ b/Gemfile @@ -6,3 +6,7 @@ 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 482f6ea..eb5fb01 100644 --- a/README.md +++ b/README.md @@ -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`. diff --git a/github-ldap.gemspec b/github-ldap.gemspec index 0033761..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.5.0" - 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.9.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 3258ac4..33e6627 100644 --- a/lib/github/ldap.rb +++ b/lib/github/ldap.rb @@ -1,20 +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' - require 'github/ldap/membership_validators' - 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`. @@ -32,10 +43,17 @@ class Ldap # # Returns the return value of the block. def_delegator :@connection, :open + def_delegator :@connection, :host attr_reader :uid, :search_domains, :virtual_attributes, :membership_validator, - :instrumentation_service + :member_search_strategy, + :instrumentation_service, + :user_search_strategy, + :connection, + :admin_user, + :admin_password, + :port # Build a new GitHub::Ldap instance # @@ -43,7 +61,13 @@ class Ldap # # 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 # @@ -62,9 +86,15 @@ class Ldap 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] }) @@ -72,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 @@ -88,8 +118,11 @@ def initialize(options = {}) # when a base is not explicitly provided. @search_domains = Array(options[:search_domains]) - # configure which strategy should be used to validate user membership - configure_membership_validation_strategy(options[:membership_validator]) + # 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] @@ -195,7 +228,7 @@ def capabilities instrument "capabilities.github_ldap" do |payload| begin @connection.search_root_dse - rescue Net::LDAP::LdapError => error + rescue Net::LDAP::Error => error payload[:error] = error # stubbed result Net::LDAP::Entry.new @@ -206,16 +239,18 @@ def capabilities # 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 @@ -236,23 +271,100 @@ def configure_virtual_attributes(attributes) end end - # Internal: Configure the membership validation strategy. + # Internal: Configure the member search and membership validation strategies. + # + # TODO: Inline the logic in these two methods here. # - # Used by GitHub::Ldap::MembershipValidators::Detect to force a specific - # strategy (instead of detecting host capabilities and deciding at runtime). + # 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 `strategy` is not provided, or doesn't match a known strategy, - # defaults to `:detect`. Otherwise the configured strategy is selected. + # If no known strategy is provided, detects ActiveDirectory capabilities or + # falls back to the Recursive strategy by default. # - # Returns the selected membership validator strategy Symbol. + # Returns the membership validator strategy Class. def configure_membership_validation_strategy(strategy = nil) @membership_validator = case strategy.to_s - when "classic", "recursive", "active_directory" - strategy.to_sym + 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 - :detect + # 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 aa2066b..07af950 100644 --- a/lib/github/ldap/domain.rb +++ b/lib/github/ldap/domain.rb @@ -115,10 +115,7 @@ def valid_login?(login, password) # Returns the user if the login matches any `uid`. # Returns nil if there are no matches. def user?(login, search_options = {}) - options = search_options.merge \ - filter: login_filter(@uid, login), - size: 1 - search(options).first + @ldap.user_search_strategy.perform(login, @base_name, @uid, search_options).first end # Check if a user can be bound with a password. @@ -163,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 64f5aa3..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) 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 index 1135a2d..c629a37 100644 --- a/lib/github/ldap/membership_validators.rb +++ b/lib/github/ldap/membership_validators.rb @@ -1,26 +1,4 @@ require 'github/ldap/membership_validators/base' -require 'github/ldap/membership_validators/detect' require 'github/ldap/membership_validators/classic' require 'github/ldap/membership_validators/recursive' require 'github/ldap/membership_validators/active_directory' - -module GitHub - class Ldap - # Provides various strategies for validating membership. - # - # For example: - # - # groups = domain.groups(%w(Engineering)) - # validator = GitHub::Ldap::MembershipValidators::Classic.new(ldap, groups) - # validator.perform(entry) #=> true - # - module MembershipValidators - # Internal: Mapping of strategy name to class. - STRATEGIES = { - :classic => GitHub::Ldap::MembershipValidators::Classic, - :recursive => GitHub::Ldap::MembershipValidators::Recursive, - :active_directory => GitHub::Ldap::MembershipValidators::ActiveDirectory - } - end - end -end diff --git a/lib/github/ldap/membership_validators/active_directory.rb b/lib/github/ldap/membership_validators/active_directory.rb index 0c531c4..ff4e4fc 100644 --- a/lib/github/ldap/membership_validators/active_directory.rb +++ b/lib/github/ldap/membership_validators/active_directory.rb @@ -24,14 +24,23 @@ def perform(entry) # 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 - matched = ldap.search \ + # + # 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 - matched.map(&:dn).include?(entry.dn) + # 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" diff --git a/lib/github/ldap/membership_validators/base.rb b/lib/github/ldap/membership_validators/base.rb index 3c47853..be378d1 100644 --- a/lib/github/ldap/membership_validators/base.rb +++ b/lib/github/ldap/membership_validators/base.rb @@ -13,9 +13,11 @@ class Base # # - ldap: GitHub::Ldap object # - groups: Array of Net::LDAP::Entry group objects - def initialize(ldap, groups) - @ldap = ldap - @groups = groups + # - options: Hash of options + def initialize(ldap, groups, options = {}) + @ldap = ldap + @groups = groups + @options = options end # Abstract: Performs the membership validation check. diff --git a/lib/github/ldap/membership_validators/detect.rb b/lib/github/ldap/membership_validators/detect.rb deleted file mode 100644 index ba8c4ba..0000000 --- a/lib/github/ldap/membership_validators/detect.rb +++ /dev/null @@ -1,69 +0,0 @@ -module GitHub - class Ldap - module MembershipValidators - # Detects the LDAP host's capabilities and determines the appropriate - # membership validation strategy at runtime. Currently detects for - # ActiveDirectory in-chain membership validation. An explicit strategy can - # also be defined via `GitHub::Ldap#membership_validator=`. See also - # `GitHub::Ldap#configure_membership_validation_strategy`. - class Detect < Base - # Internal: The capability required to use the ActiveDirectory strategy. - # See: http://msdn.microsoft.com/en-us/library/cc223359.aspx. - ACTIVE_DIRECTORY_V61_R2_OID = "1.2.840.113556.1.4.2080".freeze - - def perform(entry) - # short circuit validation if there are no groups to check against - return true if groups.empty? - - strategy.perform(entry) - end - - # Internal: Returns the membership validation strategy object. - def strategy - @strategy ||= begin - strategy = detect_strategy - strategy.new(ldap, groups) - end - end - - # Internal: Detects LDAP host's capabilities and chooses the best - # strategy for the host. - # - # If the strategy has been set explicitly, skips detection and uses the - # configured strategy instead. - # - # Returns the strategy class. - def detect_strategy - case - when GitHub::Ldap::MembershipValidators::STRATEGIES.key?(strategy_config) - GitHub::Ldap::MembershipValidators::STRATEGIES[strategy_config] - when active_directory_capability? - GitHub::Ldap::MembershipValidators::STRATEGIES[:active_directory] - else - GitHub::Ldap::MembershipValidators::STRATEGIES[:recursive] - end - end - - # Internal: Returns the configured membership validator strategy Symbol. - def strategy_config - ldap.membership_validator - 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_V61_R2_OID) - end - - # Internal: Returns the Net::LDAP::Entry object describing the LDAP - # host's capabilities (via the Root DSE). - def capabilities - ldap.capabilities - end - end - end - end -end diff --git a/lib/github/ldap/membership_validators/recursive.rb b/lib/github/ldap/membership_validators/recursive.rb index 8c40aeb..3b78545 100644 --- a/lib/github/ldap/membership_validators/recursive.rb +++ b/lib/github/ldap/membership_validators/recursive.rb @@ -21,7 +21,31 @@ class Recursive < Base DEFAULT_MAX_DEPTH = 9 ATTRS = %w(dn cn) - def perform(entry, depth = DEFAULT_MAX_DEPTH) + # 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? @@ -36,7 +60,7 @@ def perform(entry, depth = DEFAULT_MAX_DEPTH) next if membership.empty? # recurse to at most `depth` - depth.times do |n| + (depth_override || depth).times do |n| # find groups whose members include membership groups membership = domain.search(filter: membership_filter(membership), attributes: ATTRS) 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/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/install-openldap b/script/install-openldap index bb0033f..2deddad 100755 --- a/script/install-openldap +++ b/script/install-openldap @@ -13,10 +13,8 @@ TMPDIR=$(mktemp -d) cd $TMPDIR # Delete data and reconfigure. -sudo cp -v /var/lib/ldap/DB_CONFIG ./DB_CONFIG sudo rm -rf /etc/ldap/slapd.d/* sudo rm -rf /var/lib/ldap/* -sudo cp -v ./DB_CONFIG /var/lib/ldap/DB_CONFIG 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 diff --git a/test/connection_cache_test.rb b/test/connection_cache_test.rb new file mode 100644 index 0000000..1b55a6b --- /dev/null +++ b/test/connection_cache_test.rb @@ -0,0 +1,18 @@ +require_relative 'test_helper' + +class GitHubLdapConnectionCacheTestCases < GitHub::Ldap::Test + + def test_returns_cached_connection + conn1 = GitHub::Ldap::ConnectionCache.get_connection(options.merge(:host => "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 797f716..4fc0dee 100644 --- a/test/domain_test.rb +++ b/test/domain_test.rb @@ -140,6 +140,15 @@ def test_auth_does_not_bind 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 class GitHubLdapDomainTest < GitHub::Ldap::Test @@ -224,3 +233,16 @@ def test_membership_for_posixGroups "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 58992a8..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,7 +16,7 @@ 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" diff --git a/test/ldap_test.rb b/test/ldap_test.rb index 6d4d5fb..d5e9297 100644 --- a/test/ldap_test.rb +++ b/test/ldap_test.rb @@ -9,16 +9,44 @@ 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 @@ -73,28 +101,56 @@ def test_instruments_search assert_equal "dc=github,dc=com", payload[:base] end - def test_membership_validator_default - assert_equal :detect, @ldap.membership_validator + 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_membership_validator_configured_to_classic_strategy - @ldap.configure_membership_validation_strategy :classic - assert_equal :classic, @ldap.membership_validator + 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_membership_validator_configured_to_recursive_strategy - @ldap.configure_membership_validation_strategy :recursive - assert_equal :recursive, @ldap.membership_validator + 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_membership_validator_configured_to_active_directory_strategy - @ldap.configure_membership_validation_strategy :active_directory - assert_equal :active_directory, @ldap.membership_validator + 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_membership_validator_misconfigured_to_unrecognized_strategy_falls_back_to_default - @ldap.configure_membership_validation_strategy :unknown - assert_equal :detect, @ldap.membership_validator + 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 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 index 0caafe2..2160f8d 100644 --- a/test/membership_validators/active_directory_test.rb +++ b/test/membership_validators/active_directory_test.rb @@ -3,7 +3,8 @@ class GitHubLdapActiveDirectoryMembershipValidatorsStubbedTest < GitHub::Ldap::Test # Only run when AD integration tests aren't run def run(*) - self.class.test_env != "activedirectory" ? super : self + return super if self.class.test_env != "activedirectory" + Minitest::Result.from(self) end def setup @@ -72,7 +73,8 @@ def test_does_not_validate_user_not_in_any_group class GitHubLdapActiveDirectoryMembershipValidatorsIntegrationTest < GitHub::Ldap::Test # Only run this test suite if ActiveDirectory is configured def run(*) - self.class.test_env == "activedirectory" ? super : self + return super if self.class.test_env == "activedirectory" + Minitest::Result.from(self) end def setup @@ -123,4 +125,13 @@ 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/detect_test.rb b/test/membership_validators/detect_test.rb deleted file mode 100644 index 8bf522a..0000000 --- a/test/membership_validators/detect_test.rb +++ /dev/null @@ -1,49 +0,0 @@ -require_relative '../test_helper' - -# NOTE: Since this strategy is targeted at detecting ActiveDirectory -# capabilities, and we don't have AD setup in CI, we stub out actual queries -# and test against what AD *would* respond with. - -class GitHubLdapDetectMembershipValidatorsTest < 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::Detect - end - - def make_validator(groups) - groups = @domain.groups(groups) - @validator.new(@ldap, groups) - end - - def test_defers_to_configured_strategy - @ldap.configure_membership_validation_strategy(:classic) - validator = make_validator(%w(group)) - - assert_kind_of GitHub::Ldap::MembershipValidators::Classic, validator.strategy - end - - def test_detects_active_directory - caps = Net::LDAP::Entry.new - caps[:supportedcapabilities] = - [GitHub::Ldap::MembershipValidators::Detect::ACTIVE_DIRECTORY_V61_R2_OID] - - validator = make_validator(%w(group)) - @ldap.stub :capabilities, caps do - assert_kind_of GitHub::Ldap::MembershipValidators::ActiveDirectory, - validator.strategy - end - end - - def test_falls_back_to_recursive - caps = Net::LDAP::Entry.new - caps[:supportedcapabilities] = [] - - validator = make_validator(%w(group)) - @ldap.stub :capabilities, caps do - assert_kind_of GitHub::Ldap::MembershipValidators::Recursive, - validator.strategy - end - end -end diff --git a/test/membership_validators/recursive_test.rb b/test/membership_validators/recursive_test.rb index e351532..072ffca 100644 --- a/test/membership_validators/recursive_test.rb +++ b/test/membership_validators/recursive_test.rb @@ -8,9 +8,9 @@ def setup @validator = GitHub::Ldap::MembershipValidators::Recursive end - def make_validator(groups) + def make_validator(groups, options = {}) groups = @domain.groups(groups) - @validator.new(@ldap, groups) + @validator.new(@ldap, groups, options) end def test_validates_user_in_group @@ -34,8 +34,8 @@ def test_validates_user_in_great_grandchild_group end def test_does_not_validate_user_in_great_granchild_group_with_depth - validator = make_validator(%w(n-depth-nested-group3)) - refute validator.perform(@entry, 2) + validator = make_validator(%w(n-depth-nested-group3), depth: 2) + refute validator.perform(@entry) end def test_does_not_validate_user_not_in_group 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/test_helper.rb b/test/test_helper.rb index 5beca09..e92caa6 100644 --- a/test/test_helper.rb +++ b/test/test_helper.rb @@ -13,6 +13,8 @@ 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 @@ -29,8 +31,9 @@ def self.test_env def self.run(reporter, options = {}) start_server - super + result = super stop_server + result end def self.stop_server 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 pFad - Phonifier reborn

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

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


Alternative Proxies:

Alternative Proxy

pFad Proxy

pFad v3 Proxy

pFad v4 Proxy