lib/ruby_sync/connectors/ldap_connector.rb in rubysync-0.0.1 vs lib/ruby_sync/connectors/ldap_connector.rb in rubysync-0.0.2

- old
+ new

@@ -16,36 +16,115 @@ lib_path = File.dirname(__FILE__) + '/..' $:.unshift lib_path unless $:.include?(lib_path) || $:.include?(File.expand_path(lib_path)) require 'ruby_sync' - +require 'net/ldif' $VERBOSE = false require 'net/ldap' #$VERBOSE = true +RUBYSYNC_ASSOCIATION_ATTRIBUTE = "RubySyncAssociation" +RUBYSYNC_ASSOCIATION_CLASS = "RubySyncSynchable" + class Net::LDAP::Entry def to_hash return @myhash.dup end end module RubySync::Connectors class LdapConnector < RubySync::Connectors::BaseConnector - attr_accessor :host, :port, :bind_method, :username, :password, - :search_filter, :search_base, - :association_attribute # name of the attribute in which to store the association key(s) - + option :host, + :port, + :bind_method, + :username, + :password, + :search_filter, + :search_base, + :association_attribute, # name of the attribute in which to store the association key(s) + :changelog_dn + + association_attribute 'RubySyncAssociation' + bind_method :simple + host 'localhost' + port 389 + search_filter "cn=*" + changelog_dn "cn=changelog" + + def initialize options={} + super options + @last_change_number = 1 + # TODO: Persist the current CSN, for now we'll just skip to the end of the changelog + skip_existing_changelog_entries + end + + def started #TODO: If vault, check the schema to make sure that the association_attribute is there - @association_attribute ||= 'RubySyncAssociation' end - def check - Net::LDAP.open(:host=>@host, :port=>@port, :auth=>auth) do |ldap| - ldap.search :base => @search_base, :filter => @search_filter do |entry| + + # Look for changelog entries. This is not supported by all LDAP servers + # you may need to subclass for OpenLDAP and Active Directory + # Changelog entries have these attributes + # targetdn + # changenumber + # objectclass + # changes + # changetime + # changetype + # dn + # + # TODO: Detect presence/location of changelog from root DSE + def each_change + with_ldap do |ldap| + log.debug "@last_change_number = #{@last_change_number}" + filter = "(changenumber>=#{@last_change_number})" + first = true + @full_refresh_required = false + ldap.search :base => changelog_dn, :filter =>filter do |change| + change_number = change.changenumber[0].to_i + if first + first = false + # TODO: Persist the change_number so that we don't do a full resync everytime rubysync starts + if change_number != @last_change_number + log.warn "Earliest change number (#{change_number}) differs from that recorded (#{@last_change_number})." + log.warn "A full refresh is required." + @full_refresh_required = true + break + end + else + @last_change_number = change_number if change_number > @last_change_number + # todo: A proper DN object would be nice instead of string manipulation + target_dn = change.targetdn[0].gsub(/\s*,\s*/,',') + if target_dn =~ /#{search_base}$/oi + change_type = change.changetype[0] + event = event_for_changelog_entry(change) + yield event + end + end + end + end + end + + + def skip_existing_changelog_entries + with_ldap do |ldap| + filter = "(changenumber>=#{@last_change_number})" + @full_refresh_required = false + ldap.search :base => changelog_dn, :filter =>filter do |change| + change_number = change.changenumber[0].to_i + @last_change_number = change_number if change_number > @last_change_number + end + end + end + + def each_entry + Net::LDAP.open(:host=>host, :port=>port, :auth=>auth) do |ldap| + ldap.search :base => search_base, :filter => search_filter do |entry| operations = operations_for_entry(entry) association_key = (is_vault?)? nil : entry.dn yield RubySync::Event.add(self, entry.dn, association_key, operations) end end @@ -61,38 +140,30 @@ def stopped end - def initialize options - super options - @bind_method ||= :simple - @host ||= 'localhost' - @port ||= 389 - @search_filter ||= "cn=*" - end def self.sample_config return <<END - options( - :host=>'localhost', - :port=>10389, - :username=>'uid=admin,ou=system', - :password=>'secret', - :search_filter=>"cn=*", - :search_base=>"dc=example,dc=com" - # :bind_method=>:simple, - ) + + host 'localhost' + port 10389 + username 'uid=admin,ou=system' + password 'secret' + search_filter "cn=*" + search_base "dc=example,dc=com" + #:bind_method :simple END end def add(path, operations) with_ldap do |ldap| - return false unless ldap.add :dn=>path, :attributes=>perform_operations(operations) + ldap.add :dn=>path, :attributes=>perform_operations(operations) end return true rescue Net::LdapException log.warning "Exception occurred while adding LDAP record" false @@ -109,84 +180,166 @@ def [](path) with_ldap do |ldap| result = ldap.search :base=>path, :scope=>Net::LDAP::SearchScope_BaseObject, :filter=>'objectclass=*' return nil if !result or result.size == 0 - result[0].to_hash + answer = {} + result[0].attribute_names.each do |name| + answer[name.to_s] = result[0][name] + end + answer end end + # Called by unit tests to inject data + def test_add id, details + details << RubySync::Operation.new(:add, "objectclass", ['inetOrgPerson', 'organizationalPerson', 'person', 'top', 'RubySyncSynchable']) + add id, details + end + def target_transform event - event.add_default 'objectclass', 'inetOrgUser' - # TODO: Add modifier and timestamp unless LDAP dir does this automatically + #event.add_default 'objectclass', 'inetOrgUser' + #is_vault? and event.add_value 'objectclass', RUBYSYNC_ASSOCIATION_CLASS end - def associate_with_foreign_key key, path + def associate association, path with_ldap do |ldap| - ldap.add_attribute(path, @association_attribute, key.to_s) + # todo: check and warn if path is outside of search_base + ldap.modify :dn=>path, :operations=>[ + [:add, RUBYSYNC_ASSOCIATION_ATTRIBUTE, association.to_s] + ] end end - def path_for_foreign_key key - entry = entry_for_foreign_key key - (entry)? entry.dn : nil + def path_for_association association + with_ldap do |ldap| + filter = "#{RUBYSYNC_ASSOCIATION_ATTRIBUTE}=#{association.to_s}" + log.debug "Searching with filter: #{filter}" + results = ldap.search :base=>@search_base, + :filter=>filter, + :attributes=>[] + results or return nil + case results.length + when 0: return nil + when 1: return results[0].dn + else + raise Exception.new("Duplicate association found for #{association.to_s}") + end + end end - def foreign_key_for path - entry = self[path] - (entry)? entry.dn : nil # TODO: That doesn't look right. Should return an association key, not a path. - end - - def remove_foreign_key key + def associations_for path with_ldap do |ldap| - entry = entry_for_foreign_key key - if entry - modify :dn=>entry.dn, :operations=>[ [:delete, @association_attribute, key] ] + results = ldap.search :base=>path, + :scope=>Net::LDAP::SearchScope_BaseObject, + :attributes=>[RUBYSYNC_ASSOCIATION_ATTRIBUTE] + unless results and results.length > 0 + log.warn "Attempted association lookup on non-existent LDAP entry '#{path}'" + return [] end + associations = results[0][RUBYSYNC_ASSOCIATION_ATTRIBUTE] + return (associations)? associations.as_array : [] end end - - def find_associated foreign_key - entry = entry_for_foreign_key key - (entry)? operations_for_entry(entry) : nil - end + def remove_association association + path = path_for_association association + with_ldap do |ldap| + ldap.modify :dn=>path, :modifications=>[ + [:delete, RUBYSYNC_ASSOCIATION_ATTRIBUTE, association.to_s] + ] + end + end + + # def associate_with_foreign_key key, path + # with_ldap do |ldap| + # ldap.add_attribute(path, association_attribute, key.to_s) + # end + # end + # + # def path_for_foreign_key key + # entry = entry_for_foreign_key key + # (entry)? entry.dn : nil + # end + # + # def foreign_key_for path + # entry = self[path] + # (entry)? entry.dn : nil # TODO: That doesn't look right. Should return an association key, not a path. + # end + # + # def remove_foreign_key key + # with_ldap do |ldap| + # entry = entry_for_foreign_key key + # if entry + # modify :dn=>entry.dn, :operations=>[ [:delete, association_attribute, key] ] + # end + # end + # end + # + # def find_associated foreign_key + # entry = entry_for_foreign_key key + # (entry)? operations_for_entry(entry) : nil + # end + + private + def event_for_changelog_entry cle + payload = nil + dn = cle.targetdn[0] + changetype = cle.changetype[0] + if cle.attribute_names.include? :changes + payload = [] + cr = Net::LDIF.parse("dn: #{dn}\nchangetype: #{changetype}\n#{cle.changes[0]}")[0] + if changetype.to_sym == :add + # cr.data will be a hash of arrays or strings (attr-name=>[value1, value2, ...]) + cr.data.each do |name, values| + payload << RubySync::Operation.add(name, values) + end + else + # cr.data will be an array of arrays of form [:action, :subject, [values]] + cr.data.each do |record| + payload << RubySync::Operation.new(record[0], record[1], record[2]) + end + end + end + RubySync::Event.new(changetype, self, dn, nil, payload) + end + + def operations_for_entry entry - # TODO: This could probably be done better by mixing Enumerable into Entry and then calling collect ops = [] entry.each do |name, values| ops << RubySync::Operation.add(name, values) end ops end def entry_for_foreign_key key with_ldap do |ldap| - result = ldap.search :base=>@search_base, :filter=>"#{@association_attribute}=#{key}" + result = ldap.search :base=>search_base, :filter=>"#{association_attribute}=#{key}" return nil if !result or result.size == 0 result[0] end end def with_ldap result = nil - Net::LDAP.open(:host=>@host, :port=>@port, :auth=>auth) do |ldap| + Net::LDAP.open(:host=>host, :port=>port, :auth=>auth) do |ldap| result = yield ldap end result end def auth - {:method=>@bind_method, :username=>@username, :password=>@password} + {:method=>bind_method, :username=>username, :password=>password} end # Produce an array of operation arrays suitable for the LDAP library def to_ldap_operations operations operations.map {|op| [op.type, op.subject, op.values]} end end -end \ No newline at end of file +end