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