Thanks to visit codestin.com
Credit goes to github.com

Skip to content

Support Active Directory Forest searches #91

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Closed
wants to merge 26 commits into from
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
26 commits
Select commit Hold shift + click to select a range
8c945ce
fix travis to update gems before build
timmjd Apr 17, 2016
0ba3966
Implement forest search to obtain non universal groups from ad
timmjd Apr 16, 2016
7c15f6f
First pass at moving forest searching into AD-only strategy
Jul 8, 2016
4a9e082
Move connection attr_reader to a better spot
Jul 8, 2016
efcf915
Docs & better structure for configure_entry_search_strategy
Jul 8, 2016
50af684
Require forest_search
Jul 9, 2016
c2cdb52
Add naming context to ForestSearch
Jul 9, 2016
b5a64c7
Better formatting
Jul 9, 2016
9547020
Only search domains if not nil
Jul 9, 2016
186fa8b
First stab at separate tests for ForestSearch
Jul 9, 2016
3abc142
Add mocha for service/LDAP mocking
Jul 11, 2016
28d5019
Changed config name to 'use_forest_search'
Jul 11, 2016
e3f791c
Create LDAP object with ConfigurationNamingContext
Jul 11, 2016
03c7d90
Added documentation; no need to create reference to search result
Jul 11, 2016
8bdc1fa
Memoize forest object; added docs & general cleanup
Jul 11, 2016
1143676
Init mock LDAP/connection with production-like naming context
Jul 11, 2016
df990ab
Add test for falling back on @connection search when domains are nil
Jul 11, 2016
1f40e82
No need for assert
Jul 11, 2016
a799cc4
Test fall back on connection when domains are nil
Jul 11, 2016
c06f0b0
Test for iterating over domain controllers
Jul 11, 2016
69f1e3b
Test for concatenating search results
Jul 11, 2016
6078bdf
Test rejecting searches from different rootdn
Jul 11, 2016
3b7974f
Test searching domain controllers with differen subdomains
Jul 11, 2016
afa1f6a
Tweak to search documentation
Jul 11, 2016
4e8f7b3
Test name fix
Jul 11, 2016
7cd27e8
minor doc cleanup
Jul 12, 2016
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions .travis.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
1 change: 1 addition & 0 deletions Gemfile
Original file line number Diff line number Diff line change
Expand Up @@ -5,4 +5,5 @@ gemspec

group :test, :development do
gem "byebug", :platforms => [:mri_20, :mri_21]
gem "mocha"
end
27 changes: 24 additions & 3 deletions lib/github/ldap.rb
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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)
Expand Down Expand Up @@ -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
Expand All @@ -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
Copy link
Contributor Author

@davesims davesims Jul 8, 2016

Choose a reason for hiding this comment

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

Return an empty Entry if rs doesn't contain any.

rescue Net::LDAP::LdapError => error
payload[:error] = error
# stubbed result
Expand Down Expand Up @@ -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
Expand Down
99 changes: 99 additions & 0 deletions lib/github/ldap/forest_search.rb
Original file line number Diff line number Diff line change
@@ -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?
Copy link
Contributor Author

Choose a reason for hiding this comment

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

This conditional & block should probably be removed. I don't think forest.empty? can ever be true, since every Active Directory has a forest as the top-level container by definition, even if it only has one Domain Controller. This has been the case in local testing with our AD vagrant instance with a single domain. If the forest is empty, something else has gone wrong.

@connection.search(options, &block)
else
forest.each_with_object([]) do |(ncname, connection), res|
if options[:base].end_with?(ncname)
Copy link
Contributor Author

Choose a reason for hiding this comment

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

This needs to inspect the Domain Components to make sure the that right Domain Controller is performing the search.

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:
#
# {<nCNname> => <connection>}
#
# 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" => <Net::LDAP:0x007f9c3e20b200>,
# "DC=fu,DC=bar,DC=local" => <Net::LDAP:0x007f9c3e20b890>}
#
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,
Copy link
Contributor Author

@davesims davesims Jul 11, 2016

Choose a reason for hiding this comment

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

Note that since all of the forest domain controllers' connections are being initialized with server[:dnsroot] (which will be something like "ad.ghe.com") this assumes the GHE instance can resolve the DNS for each domain controller in the forest. DNS will likely be owned by internal ActiveDirectory DNS in this case.

ForestSearch won't work if the GHE is set up with a static IP for the AD instance, and doesn't have the shared DNS nameserver in its resolve.conf.

https://help.github.com/enterprise/2.0/admin/articles/configuring-dns-ssl-and-subdomain-settings/#setting-dns-nameservers

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
118 changes: 118 additions & 0 deletions test/forest_search_test.rb
Original file line number Diff line number Diff line change
@@ -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