require "active_support" require "active_record" require "net/ldap" require "iconv" # = PassiveLDAP # # This class is for ActiveRecord <=> LDAP interoparibility, designed so # most of the data can be stored in SQL / ActiveRecord tables, but some data # (usally the User datas) may be stored in an LDAP directory. PassiveLDAP # tries to emulate ActiveRecord as much as possible (like it includes # ActiveRecord::Validation, so you may use those methods # for attribute validations), and extending it with some methods that are # useful when using an LDAP directory. This library can be thought of # a high level library on top of Net::LDAP # # PassiveLDAP has some "advanced" features. See PassiveLDAP::Base#set_protection_level, PassiveLDAP::Base#set_password and PassiveLDAP::Base#passive_ldap[:default_array_separator] # # == Usage # # Create a subclass of PassiveLDAP, then use the following macros in the subclass' body # to set the connection, and the attributes of the objects: PassiveLDAP::Base#passive_ldap and PassiveLDAP::Base#passive_ldap_attr. # # In other aspects PassiveLDAP tries to emulate ActiveRecord, so you may check # it's documentation too. Methods marked with AR are methods used in ActiveRecord too, # and they are usually compatible with AR (or they raise ARFeatureMissing or ARMethodMissing) # # == Example # # the User class is a real-life example of the usage of PassiveLDAP. # # check the documentation of PassiveLDAP::Base#passive_ldap and PassiveLDAP::Base#passive_ldap_attr too # # == ActiveRecord compatibility # # PassiveLDAP mixes-in some of the modules that ActiveRecord::Base uses. Things that are somehow tested: # * Validations: #validates_presence_of and #validates_format_of does work, and should other ones too, except # #validates_uniqueness_of, because it depends on SQL. PassiveLDAP has a new validation scheme: # #validates_format_of_each, which will do a #validates_format_of for each element of a multi-valued # attribute. # * Reflections: the Rails 1.2.x dynamic scaffold (after some modifications so it will work with Rails 2.0.2) # works with PassiveLDAP, but ActiveScaffold doesn't (even after some tinkering. Don't know why, it will only # show the number of records, and the same amount of bars) # # The other ones (like Aggregations, Callbacks, Observers, etc.) may work too (or may raise lots of errors), but # are untested # # PassiveLDAP should work as a "belongs_to" in an ActiveRecord # example: # class User < PassiveLDAP::Base # #config # end # class Account < ActiveRecord::Base # belongs_to :user, :class_name => "User", :foreign_key => "user_id" # # some more config # end # # after this you may say something like: # an_account.user.cn # # Don't use "eager loading" as that will of course not work! (it is SQL specific) # # Setting #has_one or #has_many in PassiveLDAP is untested (likely to fail) # example: # class User < PassiveLDAP::Base # has_one :account # # more config # end # # == Disclaimer # # The library is in an early alpha-stage. Use at your own risk. # # Bug-fixes and feature-additions sent to my email adress are welcome! module PassiveLDAP # some type constants that may be used as the :type parameter of an attribute declaration # # Currently available types: # * ANSI_Date, which will convert an ANSI date number to a human readable time string. # This type is read only, so there is only a :from conversion specified here. There may be a few hours of difference, # because of time zone errors. This should be fixed. # * Epoch_Date, which will convert an epoch date to a human readable time string. module Types #-- # 116444700000000000: miliseconds between 1601-01-01 and 1970-01-01. Or something like that # No error checking. Will throw errors at dates like "infinity" # # RDoc has an error parsing the document if the constant Hash below # is split through separate lines, and if I use do..end instead of { }. # The parsing goes wrong even if the Hash contains the word "end". That is # why I ende up using the ?: operator and putting the whole value into one line #++ ANSI_Date = { :from => Proc.new { |s| (s.nil? or s=="" or s=="0") ? "unused" : Time.at((Integer(s) - 116444700000000000) / 10000000).to_s } } Epoch_Date = { :from => Proc.new { |s| Time.at(s.to_i).to_s } } end =begin ########################################################### # Exception definitions ########################################################### =end # superclass of the PassiveLDAP exceptions class PassiveLDAPError < Exception #:doc: end # Raised when the record is not found class RecordNotFound < PassiveLDAPError end # Raised when the record is not saved class RecordNotSaved < PassiveLDAPError end # Raised when the assignment fails (like the attribute does not exist) class AttributeAssignmentError < PassiveLDAPError end # Raised when the distinguished name does not exist when the item is saved, or when someone tries to change the dn of an # already existing object class DistinguishedNameException < PassiveLDAPError end # Thrown in case the connection fails class ConnectionError < PassiveLDAPError end # Thrown if a method present in ActiveRecord is called but it is not implemented in PassiveLDAP (but should be sometime) class ARMethodMissing < PassiveLDAPError end # Thrown if a method doesn't implement all features what it should if it were an ActiveRecord, and such a feature is used class ARFeatureMissing < PassiveLDAPError end # Base class. See the documentation of #passive_ldap and #passive_ldap_attr class Base VERSION = "0.1" # AR Determines whether to use Time.local (using :local) or Time.utc (using :utc) when pulling dates and times from the database. # This is set to :local by default. cattr_accessor :default_timezone, :instance_writer => false @@default_timezone = :local class << self =begin ########################################################### # public PassiveLDAP-only class methods ########################################################### =end # gets the hash set with #passive_ldap def settings read_inheritable_attribute(:connection) end # gets the attributes hash set with #passive_ldap_attr (excluding hidden values) def attrs read_inheritable_attribute(:attrs) end # gets the attributes hash set with #passive_ldap_attr (including hidden values) def attrs_all read_inheritable_attribute(:attr_orig) end # gets the attribute_ldap_server_name=>attribute_passive_ldap_name hash def attr_mapto read_inheritable_attribute(:mapto) end # gets the attribute_passive_ldap_name=>attribute_ldap_server_name hash def attr_mapfrom read_inheritable_attribute(:mapfrom) end # Binds to the directory with the username and password given. Password may be a Proc object, # see the documentation of Net::LDAP#bind # # Will return true if the bind is sucesful, and will raise a ConnectionError with the message returned from the server # if the bind fails # # If password and username is nil, bind will try to bind with the default connection parameters # # Beware! Password is the first parameter! def bind(password = nil, username = nil) ldap = initialize_ldap_con ldap.authenticate(username,password) if password ldap.bind raise ConnectionError, ldap.get_operation_result.message unless ldap.get_operation_result.code == 0 true end =begin ########################################################### # public ActiveRecord compatible class methods ########################################################### =end # AR Returns an array of the generated methods def generated_methods @generated_methods ||= Set.new end # AR Returns true - attribute methods are generated in initalize def generated_methods? true end # AR always returns the number of records. # Should be changed to something more intelligent # # Doesn't raise ARFeatureMissing yet def count(*args) find(:all).length end # AR returns an array of the attribute names as strings (if mapped then it will return the mapped name) def column_names unless @column_names @column_names = ["id"] attrs.each { |key,value| @column_names << value[:name].to_s if key != settings[:id_attribute] } end @column_names end # AR returns an array of the columns as ActiveRecord::ConnectionAdapters::Column # # The id is 'int(8)' the multi-valued attributes are 'text', all others are 'varchar' def columns unless @columns @columns = self.column_names.collect { |e| if e == "id" then i = ActiveRecord::ConnectionAdapters::Column.new("id",'0','int(8)',false) i.primary = true else i = ActiveRecord::ConnectionAdapters::Column.new(e,'',attrs[attr_mapfrom[e.to_sym]][:multi_valued]?'text':'varchar',true) end i } end @columns end # AR returns a hash of column objects. See columns def columns_hash unless @columns_hash a = self.columns @columns_hash = {} a.each { |e| @columns_hash[e.name] = e } end @columns_hash end # AR return the array of column objects without the id column def content_columns a = columns a.delete_if { |e| e.name == "id" } a end # AR Creates an object (or multiple objects) and saves it to the database, if validations pass. The resulting object is # returned whether the object was saved successfully to the database or not. # # The attributes parameter can be either be a Hash or an Array of Hashes. These Hashes describe the attributes on # the objects that are to be created. def create(attributes = nil) if attributes.nil? then a = new a.save a else attributes = [attributes] unless attributes.kind_of?(Array) c = [] attributes.each { |b| b[:id] ||= nil a = new(b[:id]) b.each { |key,value| if key!=:id then a[key] = value end } a.save c << a } if attributes.length==1 then c[0] else c end end end # AR deletes the record. Object will be instantiated def delete(id) a = new(id) a.destroy end # AR not implemented. Raises ARMethodMissing def delete_all(conditions = nil) raise ARMethodMissing, "ARMethodMissing: delete_all" end # AR same as delete def destroy(id) delete(id) end # AR not implemented. Raises ARMethodMissing def destroy_all(conditions = nil) raise ARMethodMissing, "ARMethodMissing: destroy_all" end # AR checks whether the given id, or an object that satisfies the given Net::LDAP::Filter exist in the directory # # will throw ARFeatureMissing if id_or_filter is not an integer or a Filter def exists?(id_or_filter) raise ARFeatureMissing, "id_or_filter must be an id or a filter" unless id_or_filter.kind_of?(Integer) or (id_or_filter.kind_of?(String) and id_or_filter.to_i.to_s == id_or_filter) or id_or_filter.kind_of?(Net::LDAP::Filter) begin if id_or_filter.kind_of?(Net::LDAP::Filter) then find(:first,id_or_filter) else find(id_or_filter) end rescue RecordNotFound return false end true end # AR find a user defined by it's ID and return the object. # If it is not found in the database it will raise RecordNotFound # # If you pass the :all symbol as parameter, it will return an array with all objects in the directory. If # no object is found it will return an empty array # # If you pass the :first symbol as parameter, it will return the first object in the directory # # the optional filter parameter is used to join a new filter to the default one. The filter parameter is only # used in :all and :first searches # # will throw ARFeatureMissing if passed a Hash or an Array instead of a Net::LDAP::Filter, or if the first parameter # is not an id, or one the following symbols: :all, :first # # Currently it will allow Hash filters, if all of the Hash parameters are nil. This is because doing so belongs_to # relations will work. def find(user, filter = nil) raise ARFeatureMissing, "User must be a number, :all or :first. Supplied was #{filter.inspect}" unless user.kind_of?(Integer) or user == :all or user == :first or (user.kind_of?(String) and user.to_i.to_s == user) if filter.kind_of?(Hash) then testf = true filter.each { |key,value| testf = false unless value.nil? } filter = nil if testf end raise ARFeatureMissing, "Filter must be a Net::LDAP::Filter or nil. Supplied was #{filter.inspect}" unless filter.nil? or filter.kind_of?(Net::LDAP::Filter) #filter = nil unless filter.kind_of?(Net::LDAP::Filter) if user == :all or user == :first then a = [] ldap = self.initialize_ldap_con if filter then filter = filter & self.settings[:multiple_record_filter].call(self) else filter = self.settings[:multiple_record_filter].call(self) end alreadygot = false ldap.search( :return_result => false, :scope => self.settings[:record_scope], :base => self.settings[:record_base], :filter => filter ) do |entry| eval "a << self.new(entry.#{self.settings[:id_attribute].id2name}[0].to_i)" unless user == :first and alreadygot alreadygot = true end raise ConnectionError, ldap.get_operation_result.message unless ldap.get_operation_result.code == 0 if user == :all then a elsif a == [] then raise PassiveLDAP::RecordNotFound else a[0] end else a = self.new(user) if a.exists_in_directory then a else raise PassiveLDAP::RecordNotFound end end end # AR returns a humanized attribute name def human_attribute_name(attribute_key_name) attribute_key_name.humanize end # AR returns a string like "User id:integer name:string mail:text" multi-valued attributes will be text def inspect() a = column_names b = self.name a.each { |e| if e == "id" then b = b + " id:integer" else if attrs[attr_mapfrom[e.to_sym]][:multi_valued] then b = b + " #{e}:text" else b = b + " #{e}:string" end end } b end # AR returns :id def primary_key :id end # AR not implemented. Will raise ARMethodMissing def serialize(attr_name, class_name = Object) raise ARMethodMissing, "ARMethodMissing: serialize" end # AR not implemented. Will raise ARMethodMissing def serialized_attributes raise ARMethodMissing, "ARMethodMissing: serialized_attributes" end # AR will return the name of the class def table_name self.name end # AR Updates an object or objects (if passed an Array) with the attributes given. Uses save! def update(id, attributes) id = [id] unless id.kind_of?(Array) attributes = [attributes] unless attributes.kind_of?(Array) if id.length != attributes.length then raise PassiveLDAPError, "Argument numbers don't mach" end c = [] id.each_index { |v| a = new(id[v]) a.update_attributes(attributes[v]) c << a } id.length==1 ? c[0] : c end # AR not implemented. Will raise ARMethodMissing def update_all(updates, conditions = nil, options = {}) raise ARMethodMissing, "ARMethodMissing: update_all" end # AR not implemented. Will raise ARMethodMissing def update_counters(id,counters) raise ARMethodMissing, "ARMethodMissing: update_counters" end end =begin ########################################################### # public PassiveLDAP-only instance methods ########################################################### =end # Bind to the directory to check whether the credentials are right or not. If there are no parameters # specified bind will do the following: # * If the actual protection_level is 0 it will bind with the default connection # * If the level is 1 it will bind with the dn of the record and the password, that is set with # #set_protection_level # * If the level is above 2 it will bind with the dn and password set with #set_protection_level # # Parameters may be used to set the dn and the password used to bind to the directory. Beware! # The first parameter is the password! You may omit the username, in which case the # dn of the record will be used to bind to the directory # # bind will return true if the connection is succesful and will raise a ConnectionError with # a message from the server if the authentication fails def bind(password = nil, username = nil) if password then ldap = self.class.initialize_ldap_con if username then ldap.authenticate(username,password) else ldap.authenticate(dn,password) end ldap.bind raise ConnectionError, ldap.get_operation_result.message unless ldap.get_operation_result.code == 0 else ldap = initialize_ldap_con ldap.bind raise ConnectionError, ldap.get_operation_result.message unless ldap.get_operation_result.code == 0 end true end # changes the password of the record. # # Currently method may only be :active_directory # # For options check #set_password_ad # # will return false if unsuccesful, adding the response from the server to the errors list def set_password(newpass, method, options = nil) set_password! rescue RecordNotSaved return false else return true end # same as set_password but will raise a RecordNotSaved exception in unsuccesful def set_password!(newpass, method, options = nil) if method == :active_directory then set_password_ad(newpass, options) else raise ARFeatureMissing, "Only AD password changes supported!" end rescue Exception => e @errors.add_to_base(e) raise end # Attributes may have different protection levels. Protection level means, that some attributes # may only be changed by privileged users. Level 0 means that the attribute may be changed by the # main connection. Level 1 means, the attribute can be changed by the owner of the attribute, but cannot # be changed by the main connection. Level 2 and higher level means that the attribute can only be changed # with a user, who has enough privileges. # # For example if PassiveLDAP is used for storing User information, # you might set most of the attributes to level 1 (so the password of the user will be needed to change # those information) and some attributes (such as printAccount, or like) may be set to level 2 or higher, so # only privileged users (like administrators) could change those attributes. # # the method has 3 paramteres. The first one sets the desired level, the second one is the password of the # user (if the level is greater or equal than 1) and the third one is the username (full dn!) of the # user (if the level is above 1) # # Protection means that when issuing a save method, only those attributes will be saved, that are below # or equal to the protection level set here, the other ones won't be sent to the LDAP server. Of course # you should set the appropriate rights in the server too for maximum security. # # Class methods (like find) will be run with the connection's authenticity information while instance methods will run # with the actual username and password set with set_protection_level # # Beware! the second parameter is the password and the third is the username! def set_protection_level(level = 0, password = nil, username = nil) @protection_level = level @protection_username = username @protection_password = password end # gets whether the id is set. Returns always true def id? true end # gets the distinguished name of the record. Returns nil if the record is nonexistent in the directory def dn @attributes[:dn] end # sets the distinguished name of the record. The dn can only be set when the record is not originated from the directory # (so it is a new record) Otherwise a DistinguishedNameException is raised def dn=(newdn) raise PassiveLDAP::DistinguishedNameException, "DN cannot be changed" unless @oldattr[:dn].nil? @dn = newdn @attributes[:dn]=newdn end # returns whether the record is new, or it is originated from the directory # # if it exists it will return the dn of the record, if not it will return nil def exists_in_directory @oldattr[:dn] end # gets the original value (the value that was read from the directory, or nil if this is a new record) of an attribute def get_old_attribute(attribute) attribute = attribute.to_sym unless attribute.kind_of?(Symbol) if self.class.attr_mapfrom.has_key?(attribute) then @oldattr[self.class.attr_mapfrom[attribute]] else if attribute == :id then @oldattr[self.settings[:id_attribute]] else raise PassiveLDAP::AttributeAssignmentError, "Attribute #{attribute} does not exist" end end end # returns the user id as string def to_s @id.to_s end # returns the attrbiute. If it is multi_valued no conversion will be done even if the # array_separator is something else than nil def get_attribute(attribute) attribute = attribute.to_sym unless attribute.kind_of?(Symbol) if self.class.attr_mapfrom.has_key?(attribute) then key = self.class.attr_mapfrom[attribute] if @attributes.has_key?(key) then @attributes[key] else nil end else if attribute == :id then self.id else raise PassiveLDAP::AttributeAssignmentError, "Attribute #{attribute} does not exist" end end end # sets the attribute. If it is multi_valued you need to pass an array even if # the array_separator is set def set_attribute(attribute,value, raise_error_when_readonly = false) attribute = attribute.to_sym unless attribute.kind_of?(Symbol) if self.class.attr_mapfrom.has_key?(attribute) then alt_name = self.class.attr_mapfrom[attribute] if self.class.attrs[alt_name][:read_only] if raise_error_when_readonly then raise PassiveLDAP::AttributeAssignmentError, "Attribute #{attribute} is read-only" else return false end end if self.class.attrs[alt_name][:multi_valued] then raise PassiveLDAP::AttributeAssignmentError, "Array expected, because #{attribute} is multi-valued" unless value.kind_of?(Array) else raise PassiveLDAP::AttributeAssignmentError, "Didn't expect an Array, because #{attribute} is not multi-valued" if value.kind_of?(Array) end eval "@#{attribute.to_s} = value" @attributes[alt_name] = value else if attribute == :id then self.id=value else raise PassiveLDAP::AttributeAssignmentError, "Attribute #{attribute} does not exist" end end end # sets the array_separator def array_separator(new_sep = nil) @array_separator = new_sep end =begin ########################################################### # public ActiveRecord compatible instance methods ########################################################### =end # AR create a record object and populate it's data from the LDAP directory. # If the record is not found it will create an empty user with that id # # Beware! If userid is nil it will try to guess a new id number using the Proc in #passive_ldap[:new_id]. By default # this guess is not guaranteed to be unique in a multi-threaded application. See #passive_ldap # # the parameter may be a Hash with attributes that are the initial values. def initialize(userid = nil) values = nil if userid.kind_of?(Hash) values = userid.clone values[:id] ||= nil userid = values[:id] end raise ARFeatureMissing, "Id must be a Hash or a number" unless userid.kind_of?(Integer) or (userid.kind_of?(String) and userid.to_i.to_s == userid) or userid.nil? userid = self.class.settings[:new_id].call(self) if userid.nil? @array_separator = self.class.settings[:default_array_separator] @protection_level = 0 @protection_username = nil @protection_password = nil @generated_methods = Set.new @dn = nil self.class.attrs.each { |name,value| alt_name = value[:name] eval "@#{alt_name.to_s} = nil" if not self.class.method_defined?(alt_name) then self.class.module_eval <<-EOF def #{alt_name.id2name} read_mapped_attribute(:#{alt_name.to_s}) end def #{alt_name.id2name}=(a) write_mapped_attribute(:#{alt_name.to_s},a) end def #{alt_name.id2name}? if @attributes.has_key?(:#{name.to_s}) then unless @attributes[:#{name.to_s}].nil? or @attributes[:#{name.to_s}] == "" or @attributes[:#{name.to_s}] == [] then true else false end else false end end EOF @generated_methods << "#{alt_name.id2name}".to_sym @generated_methods << "#{alt_name.id2name}=".to_sym @generated_methods << "#{alt_name.id2name}?".to_sym end } reload(:id => userid) @errors = ActiveRecord::Errors.new(self) unless values.nil? values[:id] = userid values.each { |key,value| write_mapped_attribute(key,value) unless key == :id } self.id = userid end yield self if block_given? end # AR gets the value of the attribute. If the attribute has an alternate name then you have to use it here def [](attribute) read_mapped_attribute(attribute) end # AR sets the value of the attribute. If the attribute has an alternate name then you have to use it here def []=(attribute,value) write_mapped_attribute(attribute,value) end # AR Returns an array of symbols of the attributes that can be changed; sorted alphabetically def attribute_names() a = self.class.column_names a.collect { |e| e.to_sym }.sort end # AR Returns true if the specified attribute has been set by the user # or by a database load and is neither nil nor empty? # # It will always be true for the :id and :dn attribute (even if the :dn is not set) def attribute_present?(attribute) attribute = attribute.to_sym unless attribute.kind_of?(Symbol) return true if attribute == :id or attribute == :dn return false unless attribute_names.include?(attribute) a = self.class.attr_mapfrom[attribute] if @attributes[a].nil? then false elsif @attributes[a].kind_of?(Array) then if @attributes[a] == [] then false else true end elsif @attributes[a].kind_of?(String) then if @attributes[a] == "" then false else true end else true end end # AR Returns a hash of all the attributes with their names as keys and clones of their objects as values. # # Options will be ignored (is it used in AR anyway?) def attributes(options = nil) a = { :id => id } @attributes.each { |key,value| v = value v = value.clone if value.duplicable? if self.class.attrs.has_key?(key) then a[self.class.attrs[key][:name]] = v end } a end # AR sets multiple attributes at once. if guard_protected_attributes if true only level settings[:default_protection_level] attributes will be # changed. guard_protected_attributes may be set to an Integer, indicating which is the maximum level of the attributes # that need to be changed, or to false indicating that all attributes need to be changed def attributes=(new_attributes, guard_protected_attribute = true) guard_protected_attribute = self.class.settings[:default_protection_level] if guard_protected_attribute == true new_attributes.each { |key,value| k = key k = key.to_sym unless key.kind_of?(Symbol) if self.class.attr_mapfrom.has_key?(k) then level = self.class.attrs[self.class.attr_mapfrom[k]][:level] if !guard_protected_attribute or (guard_protected_attribute.kind_of?(Integer) and guard_protected_attribute >= level) then self[k] = value end end } end # AR not implemented. Raises ARMethodMissing def clone raise ARMethodMissing, "ARMethodMissing: clone" end # AR returns the column object of the named attribute def column_for_attribute(name) self.class.columns_hash[name.to_s] end # AR deletes the record in the directory and freezes the object def destroy ldap = initialize_ldap_con ldap.delete(:dn => dn) raise ConnectionError, ldap.get_operation_result.message unless ldap.get_operation_result.code == 0 freeze end # AR gets the id of the record def id @attributes[self.class.settings[:id_attribute]] end # AR sets the id of the record def id=(a) raise PassiveLDAP::AttributeAssignmentError, "Id must be an integer" unless a.kind_of?(Integer) or (a.kind_of?(String) and a.to_i.to_s == a) @attributes[self.class.settings[:id_attribute]] = a @id = a end # AR Returns the contents of the record as a string # # should be nicer def inspect "#{self.class.name}: #{attributes.inspect}" end # AR Returns true if this object hasn't been saved yet - that is, a record for the object doesn't exist in the directory yet. def new_record? if exists_in_directory then false else true end end # AR reloads the data from the directory. If the record does not exists it will erase all attributes and set id to the # old value. If the record was acquired from the directory and the id was changed the old id will be used to load the data, # but the id will be set to the new one after the data has benn loaded. This may be changed with the :newid option # # options may be # * :id: set the id to this new value. If set the :newid attribute won't be checked # * :oldattr: set to true if you want to load the attributes only into the @oldattr variable, but not into the @attributes # * :newid: set to true if you want to load the new id's data (if you changed the id of the data before reloading) def reload(options = nil) options = {} if options.nil? id_set = true options[:newid] ||= false options[:oldattr] ||= false unless options.has_key?(:id) then id_set = false new_id = id options[:id] ||= id options[:id] = @oldattr[self.class.settings[:id_attribute]] unless options[:newid] end @oldattr = {} ldap = self.class.initialize_ldap_con entry = ldap.search( :base => self.class.settings[:record_base], :scope => self.class.settings[:record_scope], :filter => self.class.settings[:single_record_filter].call(self.class,options[:id].to_s) ) raise ConnectionError, ldap.get_operation_result.message unless ldap.get_operation_result.code == 0 if entry and entry != [] then @oldattr[:dn] = entry[0].dn.downcase entry[0].each { |name, values| if self.class.attrs_all.has_key?(name) then if self.class.attrs_all[name][:multi_valued] then @oldattr[name] = values else @oldattr[name] = values[0] end end } else @oldattr[:dn] = nil end @oldattr[self.class.settings[:id_attribute]] = options[:id] unless options[:oldattr] then @attributes = @oldattr.clone @dn = @attributes[:dn] @attributes.each { |key,value| if self.class.attrs.has_key?(key) then alt_name = self.class.attrs[key][:name] eval "@#{alt_name.to_s} = value" end } @id = options[:id] if !id_set and !options[:newid] then @attributes[self.class.settings[:id_attribute]] = new_id @id = new_id end end end # AR needed by ActiveRecord::Callbacks def respond_to_without_attributes?(method, include_priv=false) method_name = method.to_s method_name.chomp!("?") method_name.chomp!("!") return false if self.class.attr_mapfrom.has_key?(method_name.to_sym) respond_to?(method, include_priv) end # AR Saves the changes back to the LDAP server. # Only the changes will be saved, and only those attributes will # be saved whose protection level is less or equal than the actual # protection level. # # Attributes with default values will get their new values calculated # # The modifications will be sent to server as one modification chunk, # but it depends on the LDAP server whether it will modify the # directory as an atomic transaction. If an error occurs you should # check whether the directory remained in a consistent state. See Net::LDAP#modify # for more information # # Before saving the attributes are loaded from the server to check what has changed. # Between the loading and the saving other threads may modify the directory so be aware of # this. # # TODO: some kind of locking system # # Returns false if an error occurs. def save save! rescue RecordNotSaved => e return false rescue ActiveRecord::RecordInvalid return false else return true end # AR saves the record but will raise a RecordNotSaved with the cause of the failure if unsuccesful. See save def save! create_or_update rescue RecordNotSaved => e @errors.add_to_base(e) raise end # AR updates a single attribute and saves the record. See ActiveRecord::Base#update_attribute def update_attribute(name, value) self[name] = value save end # AR updates multiple attributes and saves the record. See update_attribute. def update_attributes(attributes) update_attributes!(attributes) rescue RecordNotFound return false else return true end # AR see update_attributes. Uses save! instead of save def update_attributes!(attributes) self.attributes=(attributes) save! end ######### protected ######### class << self =begin ########################################################### # protected PassiveLDAP-only class methods ########################################################### =end # sets the connection and record attributes that are used. # The parameter is a hash with the following options. If there are parameters missing, then the default values will be # used instead of them. # # * :connection: The :connection is a hash that will be passed without modification to Net::LDAP. The default value is # to connect to localhost on port 389 as anonymous. # * :id_attribute: The :id_attribute is a symbol, that tells PassiveLDAP which attribute is used as the id of a record. This attribute must be an integer attribute # and it must be unique. (Although there are no constraint checkings yet) # * :multiple_record_filter: The :multiple_record_filter is a Proc object with one argument, that should return a Net::LDAP::Filter object that will return all # the appropriate records in the directory. The default value is a filter that filters out the object based whether their attribute that is sat in :id_attribute # is set. The first argument of the block will be set to the caller PassiveLDAP object. # * :single_record_filter: The :single_record_filter is a Proc object with two arguments: the caller PassiveLDAP object and an id number. The corresponding # block should return a filter that will filter out the record which has the appropriate id. The default value of this argument is # to check whether the attribute set with :id_attribute is equal to the specified id number. # * :record_base: The :record_base is a String that is set to the base of the records. The default value is "ou=users,dc=com" # * :record_scope: The :record_scope is a Net::LDAP::Scope object that sets the scope of the records according to the :record_base. The default value is Net::LDAP::SearchScope_SingleLevel # * :new_id: The :new_id is a Proc object that will return an integer which should be an id that is not present in the directory. The default value is 10000 + count*5 + rand(5) # which is not really safe # * :default_array_separator: sets the string that will separate the multi-valued attributes if they are converted to string. Set # to nil if you don't want this conversion. This separator may be set with array_separator in an instance too. If this attribute is # not nil every attribute setter/getter excluding get_attribute and set_attribute will use a converted string to set/get these attributes. # If the separator is \n then trailing \r characters will be chomped from the splitted strings. # * :default_protection_level: sets the default level. All attributes added after this is set wil have this default level number, unless # they explicit specify something else. Default is 0 # # example (as well as the default values): # passive_ldap :connection => {:host => "127.0.0.1", :port => "389", :auth => { :method => :anonymous } }, # :id_attribute => :id, # :multiple_record_filter => Proc.new { |s| Net::LDAP::Filter.eq(s.settings[:id_attribute].id2name,"*") }, # :single_record_filter => Proc.new { |s,id| Net::LDAP::Filter.eq(s.settings[:id_attribute].id2name,id) }, # :record_base => "ou=users,dc=com", # :record_scope => Net::LDAP::SearchScope_SingleLevel, # :new_id => Proc.new { |s| 10000 + s.class.count*5 + rand(5) }, # :default_array_separator => nil, # :default_protection_level => 0 def passive_ldap(connection_attributes) write_inheritable_hash(:connection, connection_attributes) end # Sets the attributes you would like to use. Only the attributes set here, the attribute of the id and the dn attribute # will be queried from the directory. The id_attribute and dn attributes are used automatically so they # must not be set here (unless you define the dn attribute hidden with a default_value). # The id attribute is always mapped to the name :id regardless of it's original name. # # All attributes will get a getter and a setter method with their respective name (unless a mapping is defined in attribute_map), # as well as a query method, that queries whether the attribute is set or not. They also get an instance variable with their mapped # name (although it is only used to write to. Some AR specific methods may read the attributes data from instance variables. PassiveLDAP # stores the attributes in the @attributes Hash) # # By default there are no attributes defined. Multiple calls of this method will result in the union of the attributes # # The attributes are set as a Hash, where the key is the name of the attribute and the value is a Hash with the following options: # * :type: defines a Hash with a :from, a :to and a :klass attribute, from wchich the :klass attribute must be "String". # Internally all data's are stored as Strings (or array-of-strings if multi-valued). :from describes a Proc that will convert the # internally represented String to the class defined in :klass (which is currently a String), and :to will define the inverse of this conversion. # The whole :type attribute may be nil, which means there are no conversions, and the attribute is a String (or an Array of Strings). # The default value is that the :from and :to attributes are Proc objects that will return their parameter back. The :klass is always String, # and can not be changed. This type conversion will be done with all attribute changing methods, except #get_attribute, #set_attribute. Besides # the value of the :default_value parameter won't be converted either. Array_separator conversions are done before using this conversion. # Some types are defined as constants in PassiveLDAP::Types # * :multi_valued: tells whether the attribute can be multi_valued or not. multi_valued attributes will be arrays of string # * :level: sets the protection level that is needed to update this attribute. Check set_protection_level for details. Default is 0 # * :name: sets the name/mapping of the attribute. By default it is the same as the attribute's name. When accessing the attribute # (using methods, [], get_variable, etc.) you have to reference it by it's new name. Internally the attributes will be stored with their # original attribute name. # * :default_value: the default value of the attribute, if the value of the attribute is empty when saving. # Must be a String/Array or a Proc object, that will return a String or an Array. The parameter of the proc object will # be the PassiveLDAP object itself. If nil there is no default value. Default is nil # * :hidden: if true, the object will be loaded from the directory, but it's not accessable using methods, [], and such, and # will be hidden from the columns too. The @attributes instance variable will still hold it's value, and it will be saved back to the directory # when changed. Useful for attributes like +objectclass+. Default is false. # * :always_update: if true, and there is a default value given, before save the attribute will always get it's default # value regardles of it's original value. Useful for timestamp or aggregate type attributes. Default is false. # * :read_only: sets the attribute to be read only. If a default value is given saving will update this attribute too if # it is empty. This is useful if the attribute needs a default value at creation but should be read-only otherwise. Default is false. # # TODO: more types # # TODO: name conflict checking for the mapped names # # Attributes must be lowercase symbols, because Net::LDAP treats them that way! # # example: # passive_ldap_attr :name => {}, :sn => {}, :cn => {} # passive_ldap_attr :name => {:level => 1}, :sn => {:level => 1}, :cn => {:level => 1} # passive_ldap_attr :mail => {:multi_valued => true, :level => 1}, :mobile => {:multi_valued => true, :level => 1} # passive_ldap_attr :roomnumber => {:level => 2} def passive_ldap_attr(attribs) mapto = {} mapfrom = {} nohidden = {} attribs.each { |key, value| value[:multi_valued] ||= false value[:level] ||= self.settings[:default_protection_level] value[:type] ||= nil if (value[:type]) then value[:type][:from] ||= Proc.new { |s| s } value[:type][:to] ||= Proc.new { |s| s } value[:type][:klass] = String end value[:name] ||= key value[:default_value] ||= nil value[:hidden] ||= false value[:always_update] ||= false value[:read_only] ||= false value[:read_only] = value[:read_only] or value[:hidden] raise DistinguishedNameException, "DN attribute can't have the always_update flag set" if key == :dn and value[:always_update] raise DistinguishedNameException, "DN attribute must be hidden" if key == :dn and !value[:hidden] raise DistinguishedNameException, "DN attribute must have a default_value" if key == :dn and value[:default_value].nil? unless value[:hidden] mapto[key] = value[:name] mapfrom[value[:name]] = key nohidden[key] = value end } write_inheritable_hash(:attr_orig, attribs) write_inheritable_hash(:attrs, nohidden) write_inheritable_hash(:mapto, mapto) write_inheritable_hash(:mapfrom, mapfrom) end # creates a new Net::LDAP object def initialize_ldap_con Net::LDAP.new( self.settings[:connection] ) end # validates the format of each value in a multi-valued attribute. See ActiveRecord::Validations#validates_format_of. # Only use this with multi-valued attributes! def validates_format_of_each(*attr_names) configuration = { :message => ActiveRecord::Errors.default_error_messages[:invalid], :on => :save, :with => nil } configuration.update(attr_names.extract_options!) raise(ArgumentError, "A regular expression must be supplied as the :with option of the configuration hash") unless configuration[:with].is_a?(Regexp) validates_each(attr_names, configuration) do |record, attr_name, value| if value.nil? then record.errors.add(attr_name, configuration[:message]) else if settings[:default_array_separator].nil? then value.each { |val| record.errors.add(attr_name, configuration[:message]) unless val.to_s =~ configuration[:with] } else value.split(settings[:default_array_separator]).each { |val| val.chomp!("\r") if settings[:default_array_separator] == "\n" record.errors.add(attr_name, configuration[:message]) unless val.to_s =~ configuration[:with] } end end end end =begin ########################################################### # protected ActiveRecord compatible class methods ########################################################### =end # AR Defines an "attribute" method (like #inheritance_column or # #table_name). A new (class) method will be created with the # given name. If a value is specified, the new method will # return that value (as a string). Otherwise, the given block # will be used to compute the value of the method. # # The original method will be aliased, with the new name being # prefixed with "original_". This allows the new method to # access the original value. # # Example: # # class A < ActiveRecord::Base # define_attr_method :primary_key, "sysid" # define_attr_method( :inheritance_column ) do # original_inheritance_column + "_id" # end # end def define_attr_method(name, value=nil, &block) sing = class << self; self; end sing.send :alias_method, "original_#{name}", name if block_given? sing.send :define_method, name, &block else # use eval instead of a block to work around a memory leak in dev # mode in fcgi sing.class_eval "def #{name}; #{value.to_s.inspect}; end" end end end =begin ########################################################### # protected PassiveLDAP-only instance methods ########################################################### =end # creates a new Net::LDAP object and sets the username and pasword to the current protection level def initialize_ldap_con ldap = self.class.initialize_ldap_con ldap.authenticate(dn,@protection_password) if @protection_level == 1 ldap.authenticate(@protection_username,@protection_password) if @protection_level >= 2 ldap end # reads the attribute (using the name of the attribute as parameter) def read_mapped_attribute(attribute) att = attribute.kind_of?(Symbol) ? attribute : attribute.to_sym return self.id if att == :id v = get_attribute(att) raise AttributeAssignmentError, "Attribute #{att} does not exist" unless self.class.attr_mapfrom.has_key?(att) set = self.class.attrs[self.class.attr_mapfrom[att]][:type] if @array_separator and v.kind_of?(Array) then if set then v.collect { |v| set[:from].call(v) }.join(@array_separator) else v.join(@array_separator) end else if set then set[:from].call(v) else v end end end # writes the attribute (using the name of the attribute as parameter). Checks type (Array or not Array) def write_mapped_attribute(attribute,value) att = attribute.kind_of?(Symbol) ? attribute : attribute.to_sym if att == :id then self.id=value return value end multi_valued = false multi_valued = true if self.class.attr_mapfrom.has_key?(att) and self.class.attrs[self.class.attr_mapfrom[att]][:multi_valued] raise AttributeAssignmentError, "Attribute #{att} does not exist" unless self.class.attr_mapfrom.has_key?(att) set = self.class.attrs[self.class.attr_mapfrom[att]][:type] if @array_separator and multi_valued then val = value.split(@array_separator) val.each { |v| v.chomp!("\r") if @array_separator == "\n" v = set[:to].call(v) if set } set_attribute(att,val) else if multi_valued then value.each { |v| v = set[:to].call(v) if set } set_attribute(att,value) else set_attribute(att,set ? set[:to].call(value) : value) end end value end # calculates the mandatory attributes and stores them in the @attributes variable def calculate_mandatory_attributes self.class.attrs_all.each { |key, value| defval = value[:default_value] unless defval.nil? if @attributes.has_key?(key) and !@attributes[key].nil? and @attributes[key] != "" and @attributes[key] != [] then if value[:always_update] then if defval.respond_to?(:call) then @attributes[key] = defval.call(self) else @attributes[key] = defval end end else if defval.respond_to?(:call) then @attributes[key] = defval.call(self) else @attributes[key] = defval end end if self.class.attrs.has_key?(key) or key == :dn then alt_name = :dn alt_name = self.class.attrs[key][:name] unless key == :dn eval "@#{alt_name.to_s} = @attributes[key]" end end } end ####### private ####### =begin ########################################################### # private PassiveLDAP-only instance methods ########################################################### =end # change the password of a user an ActiveDirectory compatible way. # # The password in AD is stored in a write-only attribute called unicodePwd. # To set the password one need to supply a string encoded in UCS-2 Little Endian # which is surrounded by double quotes. The changing of the password is a bit tricky: # # * If the user wants to change his password he needs to delete the old password # and add the new password, both converted to the format described above. # * If a superuser wants to change someones password he needs to send a replace # command to the server. # # set_password_ad will convert the strings given to the correct format (using iconv) # then it will connect to the server (using the dn/password set with set_protection_level) # and finally will do the password change. Only the password will be sent to the server. # # the options hash has the following keys: # * :oldpass: the old password. If unset, the password specified with set_protection_level # will be used as the old password # * :superuser: if true, then the :oldpass attribute will be discarded, and # set_password will user the replace method to change the password. This would only work with # a superuser account # * :encoding: sets the encoding format of the source strings. Defaults to UTF-8 # # Will raise RecordNotSaved with the result from the server if unsuccesful. # # Both newpass and oldpass may be a Proc object that would return a String. The block is called # with the record as parameter # # To change the password you need to use a secure (SSL with an at least 128-bit wide key) connection to the # server! def set_password_ad(newpass, options = nil) #:doc: options = {} if options.nil? options[:oldpass] ||= @protection_password options[:superuser] ||= false options[:encoding] ||= "UTF-8" if newpass.respond_to?(:call) then np = Iconv.conv("UCS-2LE",options[:encoding],"\"#{newpass.call(self)}\"") else np = Iconv.conv("UCS-2LE",options[:encoding],"\"#{newpass}\"") end ldap = initialize_ldap_con if options[:superuser] then ops = [] ops << [:replace, :unicodepwd, np] ldap.modify :dn => dn, :operations => ops else if options[:oldpass].respond_to?(:call) then op = Iconv.conv("UCS-2LE",options[:encoding],"\"#{options[:oldpass].call(self)}\"") else op = Iconv.conv("UCS-2LE",options[:encoding],"\"#{options[:oldpass]}\"") end ops = [] ops << [:delete, :unicodepwd, op] ops << [:add, :unicodepwd, np] ldap.modify :dn => dn, :operations => ops end raise RecordNotSaved, "LDAP error: #{ldap.get_operation_result.message}" unless ldap.get_operation_result.code == 0 return true end =begin ########################################################### # private ActiveRecord compatible instance methods ########################################################### =end # AR Initializes the attributes array with keys matching the columns from the linked table and # the values matching the corresponding default value of that column, so # that a new instance, or one populated from a passed-in Hash, still has all the attributes # that instances loaded from the database would. def attributes_from_column_definition self.class.columns.inject({}) do |attributes, column| attributes[column.name] = column.default unless column.name == self.class.primary_key attributes end end # ar def create_or_update if new_record? then create else update end end # ar def create calculate_mandatory_attributes raise RecordNotSaved, "distinguished name is missing" if @attributes[:dn].nil? ldap = initialize_ldap_con ops = {} @attributes.each { |key, value| if value.kind_of?(Integer) then value = value.to_s end if !value.nil? and value != "" and value != [] and key != :dn then if (self.class.attrs_all.has_key?(key) and self.class.attrs_all[key][:level] <= @protection_level) or (self.class.settings[:id_attribute] == key) then ops[key] = value end end } ldap.add :dn => dn, :attributes => ops raise RecordNotSaved, "ldap error: #{ldap.get_operation_result.message}" unless ldap.get_operation_result.code == 0 @oldattr = @attributes.clone true end # ar def update calculate_mandatory_attributes raise RecordNotSaved, "distinguished name is missing" if @attributes[:dn].nil? reload(:oldattr => true) addthis = {} deletethis = {} @attributes.each { |key, value| if !value.nil? and value != "" and value != [] then addthis[key] = value.duplicable? ? value.dup : value end } @oldattr.each { |key,value| if !value.nil? and value != "" and value != [] then if addthis.has_key?(key) then oval = value; oval = [oval] unless oval.kind_of?(Array) nval = addthis[key]; nval = [nval] unless nval.kind_of?(Array) oval.each { |val| if nval.include?(val) then # remove from the add list if the value existed when the record was loaded nval.delete(val) else # add to the delete list if the value doesn't exist deletethis[key] ||= [] deletethis[key] << val end } if nval==[] then addthis.delete(key) else addthis[key] = nval end else # add to the delete list if the attribute doesn't exist val = value val = [val] unless val.kind_of?(Array) deletethis[key] = val end end } ldap = initialize_ldap_con ops = [] deletethis.each { |key,value| if (self.class.attrs_all.has_key?(key) and self.class.attrs_all[key][:level] <= @protection_level) or (self.class.settings[:id_attribute] == key) then ops << [:delete, key, value] end if key != :dn } addthis.each { |key, value| if (self.class.attrs_all.has_key?(key) and self.class.attrs_all[key][:level] <= @protection_level) or (self.class.settings[:id_attribute] == key) then ops << [:add, key, value] end if key != :dn } if ops!=[] then ldap.modify :dn => dn, :operations => ops raise RecordNotSaved, "ldap error: #{ldap.get_operation_result.message}" unless ldap.get_operation_result.code == 0 end @oldattr = @attributes.clone true end # default values passive_ldap :connection => {:host => "127.0.0.1", :port => "389", :auth => { :method => :anonymous } }, :id_attribute => :id, :multiple_record_filter => Proc.new { |s| Net::LDAP::Filter.eq(s.settings[:id_attribute].id2name,"*") }, :single_record_filter => Proc.new { |s,id| Net::LDAP::Filter.eq(s.settings[:id_attribute].id2name,id) }, :record_base => "ou=users,dc=com", :record_scope => Net::LDAP::SearchScope_SingleLevel, :new_id => Proc.new { |s| 10000 + s.class.count*5 + rand(5) }, :default_array_separator => nil, :default_protection_level => 0 passive_ldap_attr({}) include ActiveRecord::Validations # some parts tested and they work include ActiveRecord::Locking::Optimistic # untested. likely to fail # include ActiveRecord::Locking::Pessimistic # sql specific include ActiveRecord::Callbacks # untested. likely to fail include ActiveRecord::Observing # untested. likely to fail include ActiveRecord::Timestamp # untested. likely to fail include ActiveRecord::Associations # untested. likely to fail include ActiveRecord::Aggregations # untested. likely to fail # include ActiveRecord::Transactions # sql specific include ActiveRecord::Reflection # untested. likely to fail. most of the reflection part is built-in # include ActiveRecord::Calculations # sql specific include ActiveRecord::Serialization # untested. likely to fail include ActiveRecord::AttributeMethods # untested. likely to fail end end