# === ActiveLDAP - an OO-interface to LDAP objects inspired by ActiveRecord # Author: Will Drewry # License: See LICENSE and COPYING.txt # Copyright 2004 Will Drewry # # == Summary # ActiveLDAP lets you read and update LDAP entries in a completely object # oriented fashion, even handling attributes with multiple names seamlessly. # It was inspired by ActiveRecord so extending it to deal with custom # LDAP schemas is as effortless as knowing the 'ou' of the objects, and the # primary key. (fix this up some) # # == Example # irb> require 'activeldap' # > true # irb> user = ActiveLDAP::User.new("drewry") # > # user.cn # > "foo" # irb> user.commonname # > "foo" # irb> user.cn = "Will Drewry" # > "Will Drewry" # irb> user.cn # > "Will Drewry" # irb> user.validate # > nil # irb> user.write # # require 'ldap' require 'ldap/schema' require 'log4r' module ActiveLDAP # OO-interface to LDAP assuming pam/nss_ldap-style organization with Active specifics # Each subclass does a ldapsearch for the matching entry. # If no exact match, raise an error. # If match, change all LDAP attributes in accessor attributes on the object. # -- these are ACTUALLY populated from schema - see subschema.rb example # -- @conn.schema().each{|k,vs| vs.each{|v| print("#{k}: #{v}\n")}} # -- extract objectClasses from match and populate # Multiple entries become lists. # If this isn't read-only then lists become multiple entries, etc. # AttributeEmpty # # An exception raised when a required attribute is found to be empty class AttributeEmpty < RuntimeError end # ConfigurationError # # An exception raised when there is a problem with Base.connect arguments class ConfigurationError < RuntimeError end # DeleteError # # An exception raised when an ActiveLDAP delete action fails class DeleteError < RuntimeError end # WriteError # # An exception raised when an ActiveLDAP write action fails class WriteError < RuntimeError end # AuthenticationError # # An exception raised when user authentication fails class AuthenticationError < RuntimeError end # ConnectionError # # An exception raised when the LDAP conenction fails class ConnectionError < RuntimeError end # ObjectClassError # # An exception raised when an objectClass is not defined in the schema class ObjectClassError < RuntimeError end # AttributeAssignmentError # # An exception raised when there is an issue assigning a value to # an attribute class AttributeAssignmentError < RuntimeError end # Base # # Base is the primary class which contains all of the core # ActiveLDAP functionality. It is meant to only ever be subclassed # by extension classes. class Base # Parsed schema structures attr_reader :must, :may attr_accessor :logger # All class-wide variables @@config = nil # Container for current connection settings @@schema = nil # LDAP server's schema @@conn = nil # LDAP connection @@reconnect_attempts = 0 # Number of reconnects attempted # Driver generator # # TODO add type checking # This let's you call this method to create top-level extension object. This # is really just a proof of concept and has not truly useful purpose. # example: Base.create_object(:class => "user", :dnattr => "uid", :classes => ['top']) # # THIS METHOD IS DANGEROUS. INPUT IS NOT SANITIZED. def Base.create_object(config={}) # Just upcase the first letter of the new class name str = config[:class] class_name = str[0].chr.upcase + str[1..-1] attr = config[:dnattr] # "uid" prefix = config[:base] # "ou=People" # [ 'top', 'posixAccount' ] classes_array = config[:classes] || [] # [ [ :groups, {:class_name => "Group", :foreign_key => "memberUid"}] ] belongs_to_array = config[:belongs_to] || [] # [ [ :members, {:class_name => "User", :foreign_key => "uid", :local_key => "memberUid"}] ] has_many_array = config[:has_many] || [] raise TypeError, ":objectclasses must be an array" unless classes_array.respond_to? :size raise TypeError, ":belongs_to must be an array" unless belongs_to_array.respond_to? :size raise TypeError, ":has_many must be an array" unless has_many_array.respond_to? :size # Build classes array classes = '[' classes_array.map! {|x| x = "'#{x}'"} classes << classes_array.join(', ') classes << ']' # Build belongs_to belongs_to = [] if belongs_to_array.size > 0 belongs_to_array.each do |bt| line = [ "belongs_to :#{bt[0]}" ] bt[1].keys.each do |key| line << ":#{key} => '#{bt[1][key]}'" end belongs_to << line.join(', ') end end # Build has_many has_many = [] if has_many_array.size > 0 has_many_array.each do |hm| line = [ "has_many :#{hm[0]}" ] hm[1].keys.each do |key| line << ":#{key} => '#{hm[1][key]}'" end has_many << line.join(', ') end end self.class.module_eval <<-"end_eval" class ::#{class_name} < ActiveLDAP::Base ldap_mapping :dnattr => "#{attr}", :prefix => "#{prefix}", :classes => #{classes} #{belongs_to.join("\n")} #{has_many.join("\n")} end end_eval end # Connect and bind to LDAP creating a class variable for use by all ActiveLDAP # objects. # # == +config+ # +config+ must be a hash that may contain any of the following fields: # :user, :password_block, :logger, :host, :port, :base, :bind_format, :try_sasl, :allow_anonymous # :user specifies the username to bind with. # :bind_format specifies the string to substitute the username into on bind. e.g. uid=%s,ou=People,dc=dataspill,dc=org. Overrides @@bind_format. # :password_block specifies a Proc object that will yield a String to be used as the password when called. # :logger specifies a preconfigured Log4r::Logger to be used for all logging # :host sets the LDAP server hostname # :port sets the LDAP server port # :base overwrites Base.base - this affects EVERYTHING # :try_sasl indicates that a SASL bind should be attempted when binding to the server (default: false) # :allow_anonymous indicates that a true anonymous bind is allowed when trying to bind to the server (default: true) # :retries - indicates the number of attempts to reconnect that will be undertaken when a stale connection occurs. -1 means infinite. # :sasl_quiet - if true, sets @sasl_quiet on the Ruby/LDAP connection # :method - whether to use :ssl, :tls, or :plain (unencrypted) # :retry_wait - seconds to wait before retrying a connection # :ldap_scope - dictates how to find objects. ONELEVEL by default to avoid dn_attr collisions across OUs. Think before changing. # See lib/configuration.rb for defaults for each option def Base.connect(config={}) # Process config # Class options ## These will be replace by configuration.rb defaults if defined @@config = DEFAULT_CONFIG.dup config.keys.each do |key| if key == :base # Scrub before inserting base = config[:base].gsub(/['}{#]/, '') Base.class_eval("def Base.base();'#{base}';end") else @@config[key] = config[key] end end # Assign a easier name for the logger @@logger = @@config[:logger] || nil # Setup default logger to console if @@logger.nil? @@logger = Log4r::Logger.new('activeldap') @@logger.level = Log4r::OFF Log4r::StderrOutputter.new 'console' @@logger.add('console') end # Reset for the new connection @@reconnect_attempts = 0 # Make the connection. do_connect() # Make irb users happy with a 'true' return true end # Base.connect # Base.close # This method deletes the LDAP connection object. # This does NOT reset any overridden values from a Base.connect call. def Base.close begin @@conn.unbind unless @@conn.nil? rescue # Doesn't matter. end @@conn = nil # Make sure it is cleaned up ObjectSpace.garbage_collect end # Return the LDAP connection object currently in use def Base.connection return @@conn end # Set the LDAP connection avoiding Base.connect or multiplexing connections def Base.connection=(conn) @@conn = conn end # Attempts to reconnect up to the number of times allowed # If forced, try once then fail with ConnectionError if not connected. def Base.reconnect(force=false) not_connected = true while not_connected # Just to be clean, unbind if possible begin @@conn.unbind() if not @@conn.nil? and @@conn.bound? rescue # Ignore complaints. end @@conn = nil if @@config[:retries] == -1 or force == true @@logger.debug('Attempting to reconnect') # Reset the attempts if this was forced. @@reconnect_attempts = 0 if @@reconnect_attempts != 0 begin do_connect() not_connected = false rescue => detail @@logger.error("Reconnect to server failed: #{detail.exception}") @@logger.error("Reconnect to server failed backtrace: #{detail.backtrace}") # Do not loop if forced raise ConnectionError, detail.message if force end elsif @@reconnect_attempts < @@config[:retries] @@logger.debug('Attempting to reconnect') @@reconnect_attempts += 1 begin do_connect() not_connected = false # Reset to 0 if a connection was made. @@reconnect_attempts = 0 rescue => detail @@logger.error("Reconnect to server failed: #{detail.exception}") @@logger.error("Reconnect to server failed backtrace: #{detail.backtrace}") end else # Raise a warning raise ConnectionError, 'Giving up trying to reconnect to LDAP server.' end # Sleep before looping sleep @@config[:retry_wait] end return true end # Return the schema object def Base.schema @@schema end # search # # Wraps Ruby/LDAP connection.search to make it easier to search for specific # data without cracking open Base.connection def Base.search(config={}) unless Base.connection if @@config ActiveLDAP::Base.connect(@@config) else ActiveLDAP::Base.connect end end config[:filter] = 'objectClass=*' unless config.has_key? :filter config[:attrs] = [] unless config.has_key? :attrs config[:scope] = LDAP::LDAP_SCOPE_SUBTREE unless config.has_key? :scope config[:base] = base() unless config.has_key? :base values = [] config[:attrs] = config[:attrs].to_a # just in case begin @@conn.search(config[:base], config[:scope], config[:filter], config[:attrs]) do |m| res = {} res['dn'] = [m.dn.dup] # For consistency with the below m.attrs.each do |attr| if config[:attrs].member? attr or config[:attrs].empty? res[attr] = m.vals(attr).dup end end values.push(res) end rescue RuntimeError => detail #TODO# Check for 'No message' when retrying # The connection may have gone stale. Let's reconnect and retry. retry if Base.reconnect() # Do nothing on failure @@logger.debug "No matches for #{config[:filter]} and attrs #{config[:attrs]}" end return values end # find # # Finds the first match for value where |value| is the value of some # |field|, or the wildcard match. This is only useful for derived classes. # usage: Subclass.find(:attribute => "cn", :value => "some*val", :objects => true) # Subclass.find('some*val') # def Base.find(config = {}) unless Base.connection if @@config ActiveLDAP::Base.connect(@@config) else ActiveLDAP::Base.connect end end if self.class == Class klass = self.ancestors[0].to_s.split(':').last real_klass = self.ancestors[0] else klass = self.class.to_s.split(':').last real_klass = self.class end # Allow a single string argument attr = dnattr() objects = false val = config # Or a hash if config.respond_to?"has_key?" attr = config[:attribute] || dnattr() val = config[:value] || '*' objects = config[:objects] end matches = [] begin # Get some attributes @@conn.search(base(), @@config[:ldap_scope], "(#{attr}=#{val})") do |m| # Extract the dnattr value dnval = m.dn.split(/,/)[0].split(/=/)[1] if objects return real_klass.new(m) else return dnval end end rescue RuntimeError => detail #TODO# Check for 'No message' when retrying # The connection may have gone stale. Let's reconnect and retry. retry if Base.reconnect() # Do nothing on failure @@logger.debug "no matches for #{attr}=#{val}" end return nil end private_class_method :find # find_all # # Finds all matches for value where |value| is the value of some # |field|, or the wildcard match. This is only useful for derived classes. def Base.find_all(config = {}) unless Base.connection if @@config ActiveLDAP::Base.connect(@@config) else ActiveLDAP::Base.connect end end if self.class == Class real_klass = self.ancestors[0] else real_klass = self.class end # Allow a single string argument val = config attr = dnattr() objects = false # Or a hash if config.respond_to?"has_key?" val = config[:value] || '*' attr = config[:attribute] || dnattr() objects = config[:objects] end matches = [] begin # Get some attributes @@conn.search(base(), @@config[:ldap_scope], "(#{attr}=#{val})") do |m| # Extract the dnattr value dnval = m.dn.split(/,/)[0].split(/=/)[1] if objects matches.push(real_klass.new(m)) else matches.push(dnval) end end rescue RuntimeError => detail #TODO# Check for 'No message' when retrying # The connection may have gone stale. Let's reconnect and retry. retry if Base.reconnect() # Do nothing on failure @@logger.debug "no matches for #{attr}=#{val}" end return matches end private_class_method :find_all # Base.base # # This method when included into Base provides # an inheritable, overwritable configuration setting # # This should be a string with the base of the # ldap server such as 'dc=example,dc=com', and # it should be overwritten by including # configuration.rb into this class. # When subclassing, the specified prefix will be concatenated. def Base.base 'dc=example,dc=com' end # Base.dnattr # # This is a placeholder for the class method that will # be overridden on calling ldap_mapping in a subclass. # Using a class method allows for clean inheritance from # classes that already have a ldap_mapping. def Base.dnattr '' end # Base.required_classes # # This method when included into Base provides # an inheritable, overwritable configuration setting # # The value should be the minimum required objectClasses # to make an object in the LDAP server, or an empty array []. # This should be overwritten by configuration.rb. # Note that subclassing does not cause concatenation of # arrays to occurs. def Base.required_classes [] end ### All instance methods, etc # new # # Creates a new instance of Base initializing all class and all # initialization. Defines local defaults. See examples If multiple values # exist for dnattr, the first one put here will be authoritative # TODO: Add # support for relative distinguished names # val can be a dn attribute value, a full DN, or a LDAP::Entry. The use # with a LDAP::Entry is primarily meant for internal use by find and # find_all. def initialize(val) @exists = false # Try a default connection if none made explicitly if Base.connection.nil? and @@reconnect_attempts < @@config[:retries] # Use @@config if it has been prepopulated and the conn is down. if @@config ActiveLDAP::Base.reconnect else ActiveLDAP::Base.connect end elsif Base.connection.nil? @@logger.error('Attempted to initialize a new object with no connection') raise ConnectionError, 'Number of reconnect attempts exceeded.' end if val.class == LDAP::Entry # Call import, which is basically initialize # without accessing LDAP. @@logger.debug "initialize: val is a LDAP::Entry - running import." import(val) return end if val.class != String raise TypeError, "Object key must be a String" end @data = {} # where the r/w entry data is stored @ldap_data = {} # original ldap entry data @attr_methods = {} # list of valid method calls for attributes used for dereferencing @last_oc = false # for use in other methods for "caching" if dnattr().empty? raise RuntimeError, "dnattr() not set for this class." end # Break val apart if it is a dn if val.match(/^#{dnattr()}=([^,=]+),#{base()}$/i) val = $1 elsif val.match(/[=,]/) @@logger.info "initialize: Changing val from '#{val}' to '' because it doesn't match the DN." val = '' end # Do a search - if it exists, pull all data and parse schema, if not, just set the hierarchical data if val.class != String or val.empty? raise TypeError, 'a dn attribute String must be supplied ' + 'on initialization' else # Create what should be the authoritative DN @dn = "#{dnattr()}=#{val},#{base()}" # Search for the existing entry begin # Get some attributes Base.connection.search(base(), @@config[:ldap_scope], "(#{dnattr()}=#{val})") do |m| @exists = true # Save DN @dn = m.dn # Load up data into tmp @@logger.debug("loading entry: #{@dn}") m.attrs.each do |attr| # Load with subtypes just like @data @@logger.debug("calling make_subtypes for m.vals(attr).dup") safe_attr, value = make_subtypes(attr, m.vals(attr).dup) @@logger.debug("finished make_subtypes for #{attr}") # Add subtype to any existing values if @ldap_data.has_key? safe_attr value.each do |v| @ldap_data[safe_attr].push(v) end else @ldap_data[safe_attr] = value end end end rescue RuntimeError => detail #TODO# Check for 'No message' when retrying # The connection may have gone stale. Let's reconnect and retry. retry if Base.reconnect() # Do nothing on failure @@logger.error('new: unable to search for entry') raise detail rescue LDAP::ResultError end end # Do the actual object setup work. if @exists # Populate schema data send(:apply_objectclass, @ldap_data['objectClass']) # Populate real data now that we have the schema with aliases @ldap_data.each do |pair| real_attr = @attr_methods[pair[0]] @@logger.debug("new: #{pair[0].inspect} method maps to #{real_attr}") @data[real_attr] = pair[1].dup @@logger.debug("new: #{real_attr} set to #{pair[1]}") end else send(:apply_objectclass, required_classes()) # Setup dn attribute (later rdn too!) real_dnattr = @attr_methods[dnattr()] @data[real_dnattr] = val @@logger.debug("new: setting dnattr: #{real_dnattr} = #{val}") end end # initialize # Hide new in Base private_class_method :new # attributes # # Return attribute methods so that a program can determine available # attributes dynamically without schema awareness def attributes @@logger.debug("stub: attributes called") send(:apply_objectclass, @data['objectClass']) if @data['objectClass'] != @last_oc return @attr_methods.keys end # exists? # # Return whether the entry exists in LDAP or not def exists? @@logger.debug("stub: exists? called") return @exists end # dn # # Return the authoritative dn def dn @@logger.debug("stub: dn called") return @dn.dup end # validate # # Basic validation: # - Verify that every 'MUST' specified in the schema has a value defined # - Enforcement of undefined attributes is handled in the objectClass= method # Must call enforce_types() first before enforcement can be guaranteed def validate @@logger.debug("stub: validate called") # Clean up attr values, etc send(:enforce_types) # Validate objectclass settings @data['objectClass'].each do |klass| unless klass.class == String raise TypeError, "Value in objectClass array is not a String. (#{klass.class}:#{klass.inspect})" end unless Base.schema.names("objectClasses").member? klass raise ObjectClassError, "objectClass '#{klass}' unknown to LDAP server." end end # make sure this doesn't drop any of the required objectclasses required_classes().each do |oc| unless @data['objectClass'].member? oc.to_s raise ObjectClassError, "'#{oc}' must be a defined objectClass for class '#{self.class}' as set in the ldap_mapping" end end # Make sure all MUST attributes have a value @data['objectClass'].each do |objc| @must.each do |req_attr| deref = @attr_methods[req_attr] # Set default if it wasn't yet set. @data[deref] = [] if @data[deref].nil? # Check for missing requirements. if @data[deref].empty? raise AttributeEmpty, "objectClass '#{objc}' requires attribute '#{Base.schema.attribute_aliases(req_attr).join(', ')}'" end end end @@logger.debug("stub: validate finished") end # delete # # Delete this entry from LDAP def delete @@logger.debug("stub: delete called") begin @@conn.delete(@dn) @exists = false rescue RuntimeError => detail #todo# check for 'no message' when retrying # the connection may have gone stale. let's reconnect and retry. retry if Base.reconnect() raise DeleteError, "Failed to delete LDAP entry: '#{@dn}'" rescue LDAP::ResultError => detail raise DeleteError, "Failed to delete LDAP entry: '#{@dn}'" end end # write # # Write and validate this object into LDAP # either adding or replacing attributes # TODO: Binary data support # TODO: Relative DN support def write @@logger.debug("stub: write called") # Validate against the objectClass requirements validate # Put all changes into one change entry to ensure # automatic rollback upon failure. entry = [] # Expand subtypes to real ldap_data entries # We can't reuse @ldap_data because an exception would leave # an object in an unknown state @@logger.debug("#write: dup'ing @ldap_data") ldap_data = Marshal.load(Marshal.dump(@ldap_data)) @@logger.debug("#write: dup finished @ldap_data") @@logger.debug("#write: expanding subtypes in @ldap_data") ldap_data.keys.each do |key| ldap_data[key].each do |value| if value.class == Hash suffix, real_value = extract_subtypes(value) if ldap_data.has_key? key + suffix ldap_data[key + suffix].push(real_value) else ldap_data[key + suffix] = real_value end ldap_data[key].delete(value) end end end @@logger.debug("#write: subtypes expanded for @ldap_data") # Expand subtypes to real data entries, but leave @data alone @@logger.debug("#write: dup'ing @data") data = Marshal.load(Marshal.dump(@data)) @@logger.debug("#write: finished dup'ing @data") @@logger.debug("#write: expanding subtypes for @data") data.keys.each do |key| data[key].each do |value| if value.class == Hash suffix, real_value = extract_subtypes(value) if data.has_key? key + suffix data[key + suffix].push(real_value) else data[key + suffix] = real_value end data[key].delete(value) end end end @@logger.debug("#write: subtypes expanded for @data") if @exists # Cycle through all attrs to determine action action = {} replaceable = [] # Now that all the subtypes will be treated as unique attributes # we can see what's changed and add anything that is brand-spankin' # new. @@logger.debug("#write: traversing ldap_data determining replaces and deletes") ldap_data.each do |pair| suffix = '' binary = 0 name, *suffix_a = pair[0].split(/;/) suffix = ';'+ suffix_a.join(';') if suffix_a.size > 0 name = @attr_methods[name] name = pair[0].split(/;/)[0] if name.nil? # for objectClass, or removed vals value = data[name+suffix] # Detect subtypes and account for them binary = LDAP::LDAP_MOD_BVALUES if Base.schema.binary? name replaceable.push(name+suffix) if pair[1] != value # Create mod entries if not value.empty? # Ditched delete then replace because attribs with no equality match rules # will fails @@logger.debug("updating attribute of existing entry: #{name+suffix}: #{value.inspect}") entry.push(LDAP.mod(LDAP::LDAP_MOD_REPLACE|binary, name + suffix, value)) else # Since some types do not have equality matching rules, delete doesn't work # Replacing with nothing is equivalent. @@logger.debug("removing attribute from existing entry: #{name+suffix}") entry.push(LDAP.mod(LDAP::LDAP_MOD_REPLACE|binary, name + suffix, [])) end end end @@logger.debug("#write: finished traversing ldap_data") @@logger.debug("#write: traversing data determining adds") data.each do |pair| suffix = '' binary = 0 name, *suffix_a = pair[0].split(/;/) suffix = ';' + suffix_a.join(';') if suffix_a.size > 0 name = @attr_methods[name] name = pair[0].split(/;/)[0] if name.nil? # for obj class or removed vals value = pair[1] if not replaceable.member? name+suffix # Detect subtypes and account for them binary = LDAP::LDAP_MOD_BVALUES if Base.schema.binary? name @@logger.debug("adding attribute to existing entry: #{name+suffix}: #{value.inspect}") # REPLACE will function like ADD, but doesn't hit EQUALITY problems # TODO: Added equality(attr) to Schema2 entry.push(LDAP.mod(LDAP::LDAP_MOD_REPLACE|binary, name + suffix, value)) unless value.empty? end end @@logger.debug("#write: traversing data complete") begin @@logger.debug("#write: modifying #{@dn}") @@conn.modify(@dn, entry) @@logger.debug("#write: modify successful") rescue RuntimeError => detail #todo# check for 'no message' when retrying # the connection may have gone stale. let's reconnect and retry. retry if Base.reconnect() raise WriteError, "Could not update LDAP entry: #{detail}" rescue => detail raise WriteError, "Could not update LDAP entry: #{detail}" end else # add everything! @@logger.debug("#write: adding all attribute value pairs") @@logger.debug("#write: adding #{@attr_methods[dnattr()].inspect} = #{data[@attr_methods[dnattr()]].inspect}") entry.push(LDAP.mod(LDAP::LDAP_MOD_ADD, @attr_methods[dnattr()], data[@attr_methods[dnattr()]])) @@logger.debug("#write: adding objectClass = #{data[@attr_methods['objectClass']].inspect}") entry.push(LDAP.mod(LDAP::LDAP_MOD_ADD, 'objectClass', data[@attr_methods['objectClass']])) data.each do |pair| if pair[1].size > 0 and pair[0] != 'objectClass' and pair[0] != @attr_methods[dnattr()] # Detect subtypes and account for them if Base.schema.binary? pair[0].split(/;/)[0] binary = LDAP::LDAP_MOD_BVALUES else binary = 0 end @@logger.debug("adding attribute to new entry: #{pair[0].inspect}: #{pair[1].inspect}") entry.push(LDAP.mod(LDAP::LDAP_MOD_ADD|binary, pair[0], pair[1])) end end begin @@logger.debug("#write: adding #{@dn}") @@conn.add(@dn, entry) @@logger.debug("#write: add successful") @exists = true rescue RuntimeError => detail # The connection may have gone stale. Let's reconnect and retry. retry if Base.reconnect() raise WriteError, "Could not add LDAP entry[#{Base.connection.err2string(Base.connection.err)}]: #{detail}" rescue LDAP::ResultError => detail raise WriteError, "Could not add LDAP entry[#{Base.connection.err2string(Base.connection.err)}]: #{detail}" end end @@logger.debug("#write: resetting @ldap_data to a dup of @data") @ldap_data = Marshal.load(Marshal.dump(@data)) @@logger.debug("#write: @ldap_data reset complete") @@logger.debug("stub: write exitted") end # method_missing # # If a given method matches an attribute or an attribute alias # then call the appropriate method. # TODO: Determine if it would be better to define each allowed method # using class_eval instead of using method_missing. This would # give tab completion in irb. def method_missing(name, *args) @@logger.debug("stub: called method_missing(#{name.inspect}, #{args.inspect})") # dynamically update the available attributes without requiring an # explicit call. The cache 'last_oc' saves a lot of cpu time. if @data['objectClass'] != @last_oc @@logger.debug("method_missing(#{name.inspect}, #{args.inspect}): updating apply_objectclass(#{@data['objectClass'].inspect})") send(:apply_objectclass, @data['objectClass']) end key = name.to_s case key when /^(\S+)=$/ real_key = $1 @@logger.debug("method_missing: attr_methods has_key? #{real_key}") if @attr_methods.has_key? real_key raise ArgumentError, "wrong number of arguments (#{args.size} for 1)" if args.size != 1 @@logger.debug("method_missing: calling :attribute_method=(#{real_key}, #{args[0]})") return send(:attribute_method=, real_key, args[0]) end else @@logger.debug("method_missing: attr_methods has_key? #{key}") if @attr_methods.has_key? key raise ArgumentError, "wrong number of arguments (#{args.size} for 1)" if args.size > 1 return attribute_method(key, *args) end end raise NoMethodError, "undefined method `#{key}' for #{self}" end # Add available attributes to the methods alias_method :__methods, :methods def methods return __methods + attributes() end private # import(LDAP::Entry) # # Overwrites an existing entry (usually called by new) # with the data given in the data given in LDAP::Entry. # def import(entry=nil) @@logger.debug("stub: import called") if entry.class != LDAP::Entry raise TypeError, "argument must be a LDAP::Entry" end @data = {} # where the r/w entry data is stored @ldap_data = {} # original ldap entry data @attr_methods = {} # list of valid method calls for attributes used for dereferencing # Get some attributes @dn = entry.dn entry.attrs.each do |attr| # Load with subtypes just like @data @@logger.debug("calling make_subtypes for entry.vals(attr).dup") safe_attr, value = make_subtypes(attr, entry.vals(attr).dup) @@logger.debug("finished make_subtypes for #{attr}") # Add subtype to any existing values if @ldap_data.has_key? safe_attr value.each do |v| @ldap_data[safe_attr].push(v) end else @ldap_data[safe_attr] = value end end # Assume if we are importing it that it exists @exists = true # Populate schema data send(:apply_objectclass, @ldap_data['objectClass']) # Populate real data now that we have the schema with aliases @ldap_data.each do |pair| real_attr = @attr_methods[pair[0]] @@logger.debug("new: #{pair[0].inspect} method maps to #{real_attr}") @data[real_attr] = pair[1].dup @@logger.debug("new: #{real_attr} set to #{pair[1]}") end end # import # enforce_types # # enforce_types applies your changes without attempting to write to LDAP. This means that # if you set userCertificate to somebinary value, it will wrap it up correctly. def enforce_types @@logger.debug("stub: enforce_types called") send(:apply_objectclass, @data['objectClass']) if @data['objectClass'] != @last_oc # Enforce attribute value formatting @data.keys.each do |key| @data[key] = attribute_input_handler(key, @data[key]) end @@logger.debug("stub: enforce_types done") return true end # apply_objectclass # # objectClass= special case for updating appropriately # This updates the objectClass entry in @data. It also # updating all required and allowed attributes while # removing defined attributes that are no longer valid # given the new objectclasses. def apply_objectclass(val) @@logger.debug("stub: objectClass=(#{val.inspect}) called") new_oc = val new_oc = [val] if new_oc.class != Array if defined?(@last_oc).nil? @last_oc = false end return new_oc if @last_oc == new_oc # Store for caching purposes @last_oc = new_oc.dup # Set the actual objectClass data define_attribute_methods('objectClass') @data['objectClass'] = new_oc.uniq # Build |data| from schema # clear attr_method mapping first @attr_methods = {} @must = [] @may = [] new_oc.each do |objc| # get all attributes for the class attributes = Base.schema.class_attributes(objc.to_s) @must += attributes[:must] @may += attributes[:may] end @must.uniq! @may.uniq! (@must+@may).each do |attr| # Update attr_method with appropriate define_attribute_methods(attr) end # Delete all now innew_ocid attributes given the new objectClasses @data.keys.each do |key| # If it's not a proper aliased attribute, drop it unless @attr_methods.has_key? key @data.delete(key) end end end # Enforce typing: # Hashes are for subtypes # Arrays are for multiple entries def attribute_input_handler(attr, value) @@logger.debug("stub: called attribute_input_handler(#{attr.inspect}, #{value.inspect})") if attr.nil? raise RuntimeError, 'The first argument, attr, must not be nil. Please report this as a bug!' end binary = Base.schema.binary_required? attr single = Base.schema.single_value? attr case value.class.to_s when 'Array' if single and value.size > 1 raise TypeError, "Attribute #{attr} can only have a single value" end value.map! do |entry| if entry.class != Hash @@logger.debug("coercing value for #{attr} into a string because nested values exceeds a useful depth: #{entry.inspect} -> #{entry.to_s}") entry = entry.to_s end entry = attribute_input_handler(attr, entry)[0] end when 'Hash' if value.keys.size > 1 raise TypeError, "Hashes must have one key-value pair only." end unless value.keys[0].match(/^(lang-[a-z][a-z]*)|(binary)$/) @@logger.warn("unknown subtype did not match lang-* or binary: #{value.keys[0]}") end # Contents MUST be a String or an Array if value.keys[0] != 'binary' and binary suffix, real_value = extract_subtypes(value) value = make_subtypes(name + suffix + ';binary', real_value) end value = [value] when 'String' if binary value = {'binary' => value} end return [value] else value = [value.to_s] end return value end # make_subtypes # # Makes the Hashized value from the full attributename # e.g. userCertificate;binary => "some_bin" # becomes userCertificate => {"binary" => "some_bin"} def make_subtypes(attr, value) @@logger.debug("stub: called make_subtypes(#{attr.inspect}, #{value.inspect})") return [attr, value] unless attr.match(/;/) ret_attr, *subtypes = attr.split(/;/) return [ret_attr, [make_subtypes_helper(subtypes, value)]] end # make_subtypes_helper # # This is a recursive function for building # nested hashed from multi-subtyped values def make_subtypes_helper(subtypes, value) @@logger.debug("stub: called make_subtypes_helper(#{subtypes.inspect}, #{value.inspect})") return value if subtypes.size == 0 return {subtypes[0] => make_subtypes_helper(subtypes[1..-1], value)} end # extract_subtypes # # Extracts all of the subtypes from a given set of nested hashes # and returns the attribute suffix and the final true value def extract_subtypes(value) @@logger.debug("stub: called extract_subtypes(#{value.inspect})") subtype = '' ret_val = value if value.class == Hash subtype = ';' + value.keys[0] ret_val = value[value.keys[0]] subsubtype = '' if ret_val.class == Hash subsubtype, ret_val = extract_subtypes(ret_val) end subtype += subsubtype end ret_val = [ret_val] unless ret_val.class == Array return subtype, ret_val end # Performs the actually connection. This separate so that it may # be called to refresh stale connections. def Base.do_connect() begin case @@config[:method] when :ssl @@conn = LDAP::SSLConn.new(@@config[:host], @@config[:port], false) when :tls @@conn = LDAP::SSLConn.new(@@config[:host], @@config[:port], true) when :plain @@conn = LDAP::Conn.new(@@config[:host], @@config[:port]) else raise ConfigurationError,"#{@@config[:method]} is not one of the available connect methods :ssl, :tls, or :plain" end rescue ConfigurationError => e # Pass through raise e rescue => e @@logger.error("Failed to connect using #{@@config[:method]}") raise e end # Enforce LDAPv3 @@conn.set_option(LDAP::LDAP_OPT_PROTOCOL_VERSION, 3) # Authenticate do_bind # Retrieve the schema. We need this to automagically determine attributes begin @@schema = @@conn.schema() if @@schema.nil? rescue => e @@logger.error("Failed to retrieve the schema (#{@@config[:method]})") @@logger.error("Schema failure exception: #{e.exception}") @@logger.error("Schema failure backtrace: #{e.backtrace}") raise ConnectionError, "#{e.exception} - LDAP connection failure, or server does not support schema queries." end @@logger.debug "Connected to #{@@config[:host]}:#{@@config[:port]} using #{@@config[:method]}" end # Wrapper all bind activity def Base.do_bind() bind_dn = @@config[:bind_format] % [@@config[:user]] # Rough bind loop: # Attempt 1: SASL if available # Attempt 2: SIMPLE with credentials if password block # Attempt 3: SIMPLE ANONYMOUS if 1 and 2 fail (or pwblock returns '') if @@config[:try_sasl] and do_sasl_bind(bind_dn) @@logger.info('Bound SASL') elsif do_simple_bind(bind_dn) @@logger.info('Bound simple') elsif @@config[:allow_anonymous] and do_anonymous_bind(bind_dn) @@logger.info('Bound simple') else @@logger.error('Failed to bind using any available method') raise *LDAP::err2exception(@@conn.err) if @@conn.err != 0 end return @@conn.bound? end # Base.do_anonymous_bind # # Bind to LDAP with the given DN, but with no password. (anonymous!) def Base.do_anonymous_bind(bind_dn) @@logger.info "Attempting anonymous authentication" begin @@conn.bind() return true rescue => e @@logger.debug "LDAP Error: #{@@conn.err2string(@@conn.err)}" @@logger.debug "Exception: #{e.exception}" @@logger.debug "Backtrace: #{e.backtrace}" @@logger.warn "Warning: Anonymous authentication failed." @@logger.warn "message: #{e.message}" return false end end # Base.do_simple_bind # # Bind to LDAP with the given DN and password def Base.do_simple_bind(bind_dn) # Bail if we have no password or password block if not @@config[:password_block].nil? and not @@config[:password].nil? return false end # TODO: Give a warning to reconnect users with password clearing # Get the passphrase for the first time, or anew if we aren't storing password = '' if not @@config[:password].nil? password = @@config[:password] elsif not @@config[:password_block].nil? unless @@config[:password_block].respond_to?(:call) @@logger.error('Skipping simple bind: ' + ':password_block not nil or Proc object. Ignoring.') return false end password = @@config[:password_block].call else @@logger.error('Skipping simple bind: ' + ':password_block and :password options are empty.') return false end begin @@conn.bind(bind_dn, password) rescue => e @@logger.debug "LDAP Error: #{@@conn.err2string(@@conn.err)}" # TODO: replace this with LDAP::err2exception() if @@conn.err == LDAP::LDAP_SERVER_DOWN @@logger.error "Warning: " + e.message else @@logger.warn "Warning: SIMPLE authentication failed." end return false end # Store the password for quick reference later if @@config[:store_password] @@config[:password] = password elsif @@config[:store_password] == false @@config[:password] = nil end return true end # Base.do_sasl_bind # # Bind to LDAP with the given DN using any available SASL methods def Base.do_sasl_bind(bind_dn) # Get all SASL mechanisms mechanisms = @@conn.root_dse[0]['supportedSASLMechanisms'] # Use GSSAPI if available # Currently only GSSAPI is supported with Ruby/LDAP from # http://caliban.org/files/redhat/RPMS/i386/ruby-ldap-0.8.2-4.i386.rpm # TODO: Investigate further SASL support if mechanisms.respond_to? :member? and mechanisms.member? 'GSSAPI' begin @@conn.sasl_quiet = @@config[:sasl_quiet] if @@config.has_key?(:sasl_quiet) @@conn.sasl_bind(bind_dn, 'GSSAPI') return true rescue @@logger.debug "LDAP Error: #{@@conn.err2string(@@conn.err)}" @@logger.warn "Warning: SASL GSSAPI authentication failed." return false end end return false end # base # # Returns the value of self.class.base # This is just syntactic sugar def base @@logger.debug("stub: called base") self.class.base end # required_classes # # Returns the value of self.class.required_classes # This is just syntactic sugar def required_classes @@logger.debug("stub: called required_classes") self.class.required_classes end # dnattr # # Returns the value of self.class.dnattr # This is just syntactic sugar def dnattr @@logger.debug("stub: called dnattr") self.class.dnattr end # attribute_method # # Return the value of the attribute called by method_missing? def attribute_method(method, not_array = false) @@logger.debug("stub: called attribute_method(#{method.inspect}, #{not_array.inspect}") attr = @attr_methods[method] # Set the default value to empty if attr is not set. @data[attr] = [] if @data[attr].nil? # Return a copy of the stored data return array_of(@data[attr].dup, false) if not_array return @data[attr] end # attribute_method= # # Set the value of the attribute called by method_missing? def attribute_method=(method, value) @@logger.debug("stub: called attribute_method=(#{method.inspect}, #{value.inspect})") # Get the attr and clean up the input attr = @attr_methods[method] @@logger.debug("attribute_method=(#{method.inspect}, #{value.inspect}): method maps to #{attr}") # Check if it is the DN attribute if dnattr() == attr raise AttributeAssignmentError, 'cannot modify the DN attribute value' end # Assign the value @data[attr] = value # Return the passed in value @@logger.debug("stub: exitting attribute_method=") return @data[attr] end # define_attribute_methods # # Make a method entry for _every_ alias of a valid attribute and map it # onto the first attribute passed in. def define_attribute_methods(attr) @@logger.debug("stub: called define_attribute_methods(#{attr.inspect})") if @attr_methods.has_key? attr return end aliases = Base.schema.attribute_aliases(attr) aliases.each do |ali| @@logger.debug("associating #{ali} --> #{attr}") @attr_methods[ali] = attr end @@logger.debug("stub: leaving define_attribute_methods(#{attr.inspect})") end # array_of # # Returns the array form of a value, or not an array if # false is passed in. def array_of(value, to_a = true) @@logger.debug("stub: called array_of(#{value.inspect}, #{to_a.inspect})") if to_a case value.class.to_s when 'Array' return value when 'Hash' return [value] else return [value.to_s] end else case value.class.to_s when 'Array' return nil if value.size == 0 return value[0] if value.size == 1 return value when 'Hash' return value else return value.to_s end end end end # Base end # ActiveLDAP