-
Notifications
You must be signed in to change notification settings - Fork 27
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
Changes from all commits
8c945ce
0ba3966
7c15f6f
4a9e082
efcf915
50af684
c2cdb52
b5a64c7
9547020
186fa8b
3abc142
28d5019
e3f791c
03c7d90
8bdc1fa
1143676
df990ab
1f40e82
a799cc4
c06f0b0
69f1e3b
6078bdf
3b7974f
afa1f6a
4e8f7b3
7cd27e8
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
Original file line number | Diff line number | Diff line change |
---|---|---|
|
@@ -5,4 +5,5 @@ gemspec | |
|
||
group :test, :development do | ||
gem "byebug", :platforms => [:mri_20, :mri_21] | ||
gem "mocha" | ||
end |
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? | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. This conditional & block should probably be removed. I don't think |
||
@connection.search(options, &block) | ||
else | ||
forest.each_with_object([]) do |(ncname, connection), res| | ||
if options[:base].end_with?(ncname) | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. This 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, | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Note that since all of the forest domain controllers' connections are being initialized with 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 |
||
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 |
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 |
Uh oh!
There was an error while loading. Please reload this page.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Return an empty
Entry
ifrs
doesn't contain any.