diff --git a/.travis.yml b/.travis.yml index 09e4709..3583912 100644 --- a/.travis.yml +++ b/.travis.yml @@ -7,6 +7,9 @@ env: - TESTENV=openldap - TESTENV=apacheds +before_install: + - gem update bundler + install: - if [ "$TESTENV" = "openldap" ]; then ./script/install-openldap; fi - bundle install diff --git a/Gemfile b/Gemfile index 4abbfe8..edf9461 100644 --- a/Gemfile +++ b/Gemfile @@ -5,4 +5,5 @@ gemspec group :test, :development do gem "byebug", :platforms => [:mri_20, :mri_21] + gem "mocha" end diff --git a/lib/github/ldap.rb b/lib/github/ldap.rb index 0545247..28a24ed 100644 --- a/lib/github/ldap.rb +++ b/lib/github/ldap.rb @@ -9,6 +9,7 @@ require 'github/ldap/virtual_attributes' require 'github/ldap/instrumentation' require 'github/ldap/member_search' +require 'github/ldap/forest_search' require 'github/ldap/membership_validators' module GitHub @@ -84,6 +85,7 @@ def initialize(options = {}) end configure_virtual_attributes(options[:virtual_attributes]) + configure_entry_search_strategy(options[:use_forest_search]) # enable fallback recursive group search unless option is false @recursive_group_search_fallback = (options[:recursive_group_search_fallback] != false) @@ -180,10 +182,10 @@ def search(options, &block) instrument "search.github_ldap", options.dup do |payload| result = if options[:base] - @connection.search(options, &block) + @entry_search_strategy.search(options, &block) else search_domains.each_with_object([]) do |base, result| - rs = @connection.search(options.merge(:base => base), &block) + rs = @entry_search_strategy.search(options.merge(:base => base), &block) result.concat Array(rs) unless rs == false end end @@ -201,7 +203,11 @@ def capabilities @capabilities ||= instrument "capabilities.github_ldap" do |payload| begin - @connection.search_root_dse + rs = @connection.search( + :ignore_server_caps => true, + :base => "", + :scope => Net::LDAP::SearchScope_BaseObject) + (rs and rs.first) || Net::LDAP::Entry.new rescue Net::LDAP::LdapError => error payload[:error] = error # stubbed result @@ -256,6 +262,21 @@ def configure_search_strategy(strategy = nil) configure_member_search_strategy(strategy) end + # Internal: Configure the entry search strategy. + # + # If the user has configured GHE to use forest searches AND we have an active + # Directory instance that has the right capabilities, use a ForestSearch + # strategy. Otherwise, the entry search strategy is simple the existing LDAP + # connection object. + # + def configure_entry_search_strategy(use_forest_search) + @entry_search_strategy = if use_forest_search && active_directory_capability? && capabilities[:configurationnamingcontext].any? + @entry_search_strategy = GitHub::Ldap::ForestSearch.new(@connection, capabilities[:configurationnamingcontext].first) + else + @entry_search_strategy = @connection + end + end + # Internal: Configure the membership validation strategy. # # If no known strategy is provided, detects ActiveDirectory capabilities or diff --git a/lib/github/ldap/forest_search.rb b/lib/github/ldap/forest_search.rb new file mode 100644 index 0000000..359c6d5 --- /dev/null +++ b/lib/github/ldap/forest_search.rb @@ -0,0 +1,99 @@ +require 'github/ldap/instrumentation' + +module GitHub + class Ldap + # For ActiveDirectory environments that have a forest with multiple domain controllers, + # this strategy class allows for entry searches across all domains in that forest. + class ForestSearch + include Instrumentation + + # Build a new GitHub::Ldap::ForestSearch instance + # + # connection: GitHub::Ldap object representing the main AD connection. + # naming_context: The Distinguished Name (DN) of this forest's Configuration + # Naming Context, e.g., "CN=Configuration,DC=ad,DC=ghe,DC=com" + # + # See: https://technet.microsoft.com/en-us/library/aa998375(v=exchg.65).aspx + # + def initialize(connection, naming_context) + @naming_context = naming_context + @connection = connection + end + + # Search over all domain controllers in the ActiveDirectory forest. + # + # options: is a hash with the same options that Net::LDAP::Connection#search supports. + # block: is an optional block to pass to the search. + # + # If no domain controllers are found in the forest, fall back on searching + # the main GitHub::Ldap object in @connection. + # + # If @forest is populated, iterate over each domain controller and perform + # the requested search, excluding domain controllers whose naming context + # is not in scope for the search base DN defined in options[:base]. + # + def search(options, &block) + instrument "forest_search.github_ldap" do + if forest.empty? + @connection.search(options, &block) + else + forest.each_with_object([]) do |(ncname, connection), res| + if options[:base].end_with?(ncname) + rs = connection.search(options, &block) + res.concat Array(rs) unless rs == false + end + end + end + end + end + + private + + attr_reader :connection, :naming_context + + # Internal: Queries configuration for available domains + # + # Membership of local or global groups need to be evaluated by contacting referral + # Domain Controllers + # + # returns: A memoized Hash of Domain Controllers from this AD forest in the format: + # + # { => } + # + # where "nCName" specifies the distinguished name of the naming context for the domain + # controller, and "connection" is an instance of Net::LDAP that represents a connection + # to that domain controller, for instance: + # + # {"DC=ad,DC=ghe,DC=local" => , + # "DC=fu,DC=bar,DC=local" => } + # + def forest + @forest ||= begin + instrument "get_domain_forest.github_ldap" do + domains = @connection.search( + base: naming_context, + search_referrals: true, + filter: Net::LDAP::Filter.eq("nETBIOSName", "*") + ) + if domains + domains.each_with_object({}) do |server, result| + if server[:ncname].any? && server[:dnsroot].any? + result[server[:ncname].first] = Net::LDAP.new({ + host: server[:dnsroot].first, + port: @connection.instance_variable_get(:@encryption)? 636 : 389, + auth: @connection.instance_variable_get(:@auth), + encryption: @connection.instance_variable_get(:@encryption), + instrumentation_service: @connection.instance_variable_get(:@instrumentation_service) + }) + end + end + else + {} + end + end + end + end + + end + end +end diff --git a/test/forest_search_test.rb b/test/forest_search_test.rb new file mode 100644 index 0000000..d610a9a --- /dev/null +++ b/test/forest_search_test.rb @@ -0,0 +1,118 @@ +require_relative 'test_helper' +require 'mocha/mini_test' + +class GitHubLdapForestSearchTest < GitHub::Ldap::Test + def setup + @connection = Net::LDAP.new({ + host: options[:host], + port: options[:port], + instrumentation_service: options[:instrumentation_service] + }) + configuration_naming_context = "CN=Configuration,DC=ad,DC=ghe,DC=local" + @forest_search = GitHub::Ldap::ForestSearch.new(@connection, configuration_naming_context) + end + + def test_uses_connection_search_when_no_forest_present + # First search returns an empty Hash of domain controllers + @connection.expects(:search).returns({}) + # Since the forest is empty, should fall back on the base connection + @connection.expects(:search) + @forest_search.search({}) + end + + def test_uses_connection_search_when_domains_nil + # First search returns nil + @connection.expects(:search).returns(nil) + # Since the forest is empty, should fall back on the base connection + @connection.expects(:search) + @forest_search.search({}) + end + + def test_iterates_over_domain_controllers_when_forest_present + mock_domains = Object.new + mock_domain_controller = Object.new + + # Mock out two Domain Controller connections (Net::LDAP objects) + mock_dc_connection1 = Object.new + mock_dc_connection2 = Object.new + rootdn = "DC=ad,DC=ghe,DC=local" + # Create a mock forest that contains the two mock DCs + forest = [[rootdn, mock_dc_connection1],[rootdn, mock_dc_connection2]] + + # First search returns the Hash of domain controllers + # This is what the forest is built from. + @connection.expects(:search).returns(mock_domains) + mock_domains.expects(:each_with_object).returns(forest) + + # Then we expect that a search will be performed on the LDAP object + # created from the returned forest of domain controllers + mock_dc_connection1.expects(:search) + mock_dc_connection2.expects(:search) + base = "CN=user1,CN=Users,DC=ad,DC=ghe,DC=local" + @forest_search.search({:base => base}) + end + + def test_returns_concatenated_search_results_from_forest + mock_domains = Object.new + mock_domain_controller = Object.new + + mock_dc_connection1 = Object.new + mock_dc_connection2 = Object.new + rootdn = "DC=ad,DC=ghe,DC=local" + forest = [[rootdn, mock_dc_connection1],[rootdn, mock_dc_connection2]] + + @connection.expects(:search).returns(mock_domains) + mock_domains.expects(:each_with_object).returns(forest) + + mock_dc_connection1.expects(:search).returns(["entry1"]) + mock_dc_connection2.expects(:search).returns(["entry2"]) + base = "CN=user1,CN=Users,DC=ad,DC=ghe,DC=local" + results = @forest_search.search({:base => base}) + assert_equal results, ["entry1", "entry2"] + end + + def test_does_not_search_from_different_rootdn + mock_domains = Object.new + mock_domain_controller = Object.new + + mock_dc_connection1 = Object.new + mock_dc_connection2 = Object.new + forest = {"DC=ad,DC=ghe,DC=local" => mock_dc_connection1, + "DC=fu,DC=bar,DC=local" => mock_dc_connection2} + + @connection.expects(:search).returns(mock_domains) + mock_domains.expects(:each_with_object).returns(forest) + + mock_dc_connection1.expects(:search).returns(["entry1"]) + mock_dc_connection2.expects(:search).never + + base = "CN=user1,CN=Users,DC=ad,DC=ghe,DC=local" + results = @forest_search.search({:base => base}) + assert_equal results, ["entry1"] + end + + def test_searches_domain_controllers_from_different_domains + server1 = {:ncname => ["DC=ghe,DC=local"], :dnsroot => ["ghe.local"]} + server2 = {:ncname => ["DC=ad,DC=ghe,DC=local"], :dnsroot => ["ad.ghe.local"]} + server3 = {:ncname => ["DC=eng,DC=ad,DC=ghe,DC=local"], :dnsroot => ["eng.ad.ghe.local"]} + mock_domains = [server1,server2,server3] + + mock_domain_controller = Object.new + @connection.expects(:search).returns(mock_domains) + + mock_dc_connection1 = Object.new + mock_dc_connection2 = Object.new + mock_dc_connection3 = Object.new + + Net::LDAP.expects(:new).returns(mock_dc_connection1) + Net::LDAP.expects(:new).returns(mock_dc_connection2) + Net::LDAP.expects(:new).returns(mock_dc_connection3) + + mock_dc_connection1.expects(:search).once + mock_dc_connection2.expects(:search).once + mock_dc_connection3.expects(:search).once + + base = "CN=user1,CN=Users,DC=eng,DC=ad,DC=ghe,DC=local" + @forest_search.search({:base => base}) + end +end