#!/usr/bin/env ruby
require 'forwardable'
require 'ldap'
require 'ldap/ldif'
require 'treequel'
require 'treequel/mixins'
require 'treequel/constants'
require 'treequel/branchset'
require 'treequel/branchcollection'
# The object in Treequel that wraps an entry. It knows how to construct other branches
# for the entries below itself, and how to search for those entries.
class Treequel::Branch
include Comparable,
Treequel::Constants,
Treequel::Constants::Patterns,
Treequel::HashUtilities
extend Loggability,
Treequel::Delegation,
Treequel::AttributeDeclarations
# Loggability API -- Log to the Treequel module's logger
log_to :treequel
# The default width of LDIF output
DEFAULT_LDIF_WIDTH = 70
# The characters to use to fold an LDIF line (newline + a space)
LDIF_FOLD_SEPARATOR = "\n "
#################################################################
### C L A S S M E T H O D S
#################################################################
# [Boolean] Whether or not to include operational attributes by default.
@include_operational_attrs = false
# [Boolean] Whether or not to freeze values cached in @values. This helps
# prevent you from accidentally doing branch[:attr] << 'value', which
# modifies the cached values, but not the entry.
@freeze_converted_values = true
# Whether or not to include operational attributes when fetching the
# entry for branches.
class << self
extend Treequel::AttributeDeclarations
predicate_attr :include_operational_attrs
predicate_attr :freeze_converted_values
end
### Create a new Treequel::Branch from the given +entry+ hash from the specified +directory+.
def self::new_from_entry( entry, directory )
entry = Treequel::HashUtilities.stringify_keys( entry )
dnvals = entry.delete( 'dn' ) or
raise ArgumentError, "no 'dn' attribute for entry"
Treequel.logger.debug "Creating Branch from entry: %p in directory: %s" %
[ dnvals.first, directory ]
return self.new( directory, dnvals.first, entry )
end
#################################################################
### I N S T A N C E M E T H O D S
#################################################################
### Create a new Treequel::Branch with the given +directory+, +dn+, and an optional +entry+.
### If the optional +entry+ object is given, it will be used to fetch values from the
### directory; if it isn't provided, it will be fetched from the +directory+ the first
### time it is needed.
def initialize( directory, dn, entry=nil )
raise ArgumentError, "nil DN" unless dn
raise ArgumentError, "invalid DN" unless
dn.match( Patterns::DISTINGUISHED_NAME ) || dn.empty?
raise ArgumentError, "can't cast a %s to an LDAP::Entry" % [entry.class.name] unless
entry.nil? || entry.is_a?( Hash )
@directory = directory
@dn = dn
@entry = entry ? stringify_keys( entry ) : nil
@values = {}
@include_operational_attrs = self.class.include_operational_attrs?
self.log.debug "New branch (%s): entry = %p, directory = %p" % [ @dn, @entry, @directory ]
end
######
public
######
# Delegate some other methods to a new Branchset via the #branchset method
def_method_delegators :branchset, :filter, :scope, :select, :limit, :timeout, :as, :from
# Delegate some methods to the Branch's directory via its accessor
def_method_delegators :directory, :controls, :referrals
# The directory the branch's entry lives in
attr_reader :directory
# The DN of the branch.
attr_reader :dn
alias_method :to_s, :dn
# Whether or not to include operational attributes when fetching the Branch's entry
predicate_attr :include_operational_attrs
alias_method :include_operational_attributes?, :include_operational_attrs?
### Change the DN the Branch uses to look up its entry to +newdn+.
def dn=( newdn )
self.clear_caches
@dn = newdn
end
### Enable (if +new_setting+ is true) or disable fetching of operational attributes (RC4512,
### section 3.4).
def include_operational_attrs=( new_setting )
self.clear_caches
@include_operational_attrs = new_setting ? true : false
end
alias_method :include_operational_attributes=, :include_operational_attrs=
### Return the attribute/s which make up this Branch's RDN as a Hash.
def rdn_attributes
return make_rdn_hash( self.rdn )
end
### Return the LDAP::Entry associated with the receiver, fetching it from the
### directory if necessary. Returns +nil+ if the entry doesn't exist in the
### directory.
def entry
@entry ||= self.lookup_entry
end
### Returns true if there is an entry currently in the directory with the
### branch's DN.
def exists?
return self.entry ? true : false
end
### Returns +true+ if the Branch's entry has been fetched from the directory.
def loaded?
return @entry ? true : false
end
### Return the RDN of the branch.
def rdn
return self.split_dn( 2 ).first
end
### Return the receiver's DN as an Array of attribute=value pairs. If the optional +limit+ is
### non-zero, only the limit-1
first pairs are split from the DN, and the
### remainder will be returned as the last element.
def split_dn( limit=0 )
return self.dn.split( /\s*,\s*/, limit )
end
### Return the LDAP URI for this branch
def uri
uri = self.directory.uri
uri.dn = self.dn
return uri
end
### Return the DN of this entry's parent, or nil if it doesn't have one.
def parent_dn
return nil if self.dn == self.directory.base_dn
return '' if self.dn.index( ',' ).nil?
return self.split_dn( 2 ).last
end
### Return the Branch's immediate parent node.
def parent
pardn = self.parent_dn or return nil
return self.class.new( self.directory, pardn )
end
### Perform a search with the specified +scope+, +filter+, and +parameters+ using the
### receiver as the base. See Trequel::Directory#search for details. Returns an Array of
### Treequel::Branch objects.
def search( scope=:subtree, filter='(objectClass=*)', parameters={}, &block )
return self.directory.search( self, scope, filter, parameters, &block )
end
### Return the Branch's immediate children as Treeque::Branch objects.
def children
return self.search( :one, '(objectClass=*)' )
end
### Return a Treequel::Branchset that will use the receiver as its base.
def branchset
return Treequel::Branchset.new( self )
end
### Returns a human-readable representation of the object suitable for
### debugging.
def inspect
return "#<%s:0x%0x %s @ %s entry=%p>" % [
self.class.name,
self.object_id * 2,
self.dn,
self.directory,
@entry,
]
end
### Return the entry's DN as an RFC1781-style UFN (User-Friendly Name).
def to_ufn
if LDAP.respond_to?( :dn2ufn )
return LDAP.dn2ufn( self.dn )
# An implementation for LDAP libraries with no
# dn2ufn
else
ufn = ''
tuples = self.split_dn
# Separate the trailing domainComponents
dcs = []
dcs << tuples.pop while tuples.last =~ /^dc\s*=/i
# Append the non-dc tuples with their attributes stripped first
ufn << tuples.collect do |rdn|
rdn.
gsub(/\b#{ATTRIBUTE_TYPE}\s*=/, '').
gsub(/\s*\+\s*/, ' + ')
end.join( ', ' )
# Now append the DCs joined with dots
unless dcs.empty?
ufn << ', '
ufn << dcs.reverse.map {|rdn| rdn.sub(/dc\s*=\s*/i, '') }.join( '.' )
end
return ufn
end
end
### Return the Branch as an LDAP::LDIF::Entry.
def to_ldif( width=DEFAULT_LDIF_WIDTH )
ldif = "dn: %s\n" % [ self.dn ]
entry = self.entry || self.valid_attributes_hash
self.log.debug " making LDIF from an entry: %p" % [ entry ]
entry.keys.reject {|k| k == 'dn' }.each do |attribute|
Array( entry[attribute] ).each do |val|
ldif << ldif_for_attr( attribute, val, width )
end
end
return LDAP::LDIF::Entry.new( ldif )
end
### Return the Branch as a Hash.
def to_hash
entry = self.entry || self.valid_attributes_hash
self.log.debug " making a Hash from an entry: %p" % [ entry ]
return entry.keys.inject({}) do |hash, attribute|
if attribute == 'dn'
hash[ attribute ] = self.dn
else
hash[ attribute ] = self[ attribute ]
end
hash
end
end
### Fetch the value/s associated with the given +attrname+ from the underlying entry.
def []( attrname )
attrsym = attrname.to_sym
if @values.key?( attrsym )
# self.log.debug " value for %p is cached (%p)." % [ attrname, @values[attrsym] ]
else
self.log.debug " value for %p is NOT cached." % [ attrsym ]
value = self.get_converted_object( attrsym )
self.log.debug " converted value is: %p" % [ value ]
value.freeze if
self.class.freeze_converted_values? &&
value.respond_to?( :freeze )
@values[ attrsym ] = value if value
end
return @values[ attrsym ]
end
### Fetch one or more values for the specified +attributes+ from the entry.
###
### branch.values_at( :cn, :objectClass )
### => [["sysadmin"], ["top", "posixGroup", "apple-group"]]
def values_at( *attributes )
return attributes.collect do |attribute|
self[ attribute ]
end
end
### Set attribute +attrname+ to a new +value+.
def []=( attrname, value )
value = [ value ] unless value.is_a?( Array )
value.collect! {|val| self.get_converted_attribute(attrname, val) }
self.log.debug "Modifying %s to %p" % [ attrname, value ]
self.directory.modify( self, attrname.to_s => value )
@values.delete( attrname.to_sym )
self.entry[ attrname.to_s ] = value
end
### Make the changes to the entry specified by the given +attributes+.
###
### branch.merge( :description => ['The syadmin group'], :cn => ['sysadmin'] )
def merge( attributes )
self.directory.modify( self, attributes )
self.clear_caches
return true
end
alias_method :modify, :merge
### Delete the specified +attributes+, which are the attributes to delete either as
### attribute names (in which case all values of the attribute are deleted), or
### Hashes of attributes and the Array of value/s which should be deleted.
###
### # Delete all 'description' attributes
### branch.delete( :description )
###
### # Delete the 'inetOrgPerson' and 'posixAccount' objectClasses from the entry
### branch.delete( :objectClass => [:inetOrgPerson, :posixAccount] )
###
### # Delete any blank 'description' or 'cn' attributes:
### branch.delete( :description => '', :cn => '' )
###
def delete( *attributes )
# If no attributes are given, delete the whole entry
if attributes.empty?
self.log.info "No attributes specified; deleting entire entry for %s" % [ self.dn ]
self.directory.delete( self )
# Otherwise, gather up the LDAP::Mod objects that will delete the given attributes
else
self.log.debug "Deleting attributes: %p" % [ attributes ]
mods = attributes.flatten.collect do |attribute|
# Delete particular values of the attribute
if attribute.is_a?( Hash )
attribute.collect do |key,vals|
vals = [ vals ] unless vals.is_a?( Array )
vals.collect! {|val| self.get_converted_attribute(key, val) }
LDAP::Mod.new( LDAP::LDAP_MOD_DELETE, key.to_s, vals )
end
# Delete all values of the attribute
else
LDAP::Mod.new( LDAP::LDAP_MOD_DELETE, attribute.to_s, [] )
end
end
self.directory.modify( self, mods.flatten )
end
self.clear_caches
return true
end
### Create the entry for this Branch with the specified +attributes+. The +attributes+ should,
### at a minimum, contain the pair `:objectClass => [:someStructuralObjectClass]`.
###
### groups = dir.ou( :groups )
### newgroup = groups.cn( :staff )
### newgroup.create( :objectClass => ['posixGroup'], :gidNumber => 2100 )
### # => #
def create( attributes={} )
self.directory.create( self, attributes )
self.clear_caches
return self
end
### Copy the entry for this Branch to a new entry with the given +newdn+ and merge in the
### specified +attributes+.
def copy( newdn, attributes={} )
# Fully-qualify RDNs
newdn = newdn + ',' + self.parent_dn unless newdn.index(',')
self.log.debug "Creating a copy of %p at %p" % [ self.dn, newdn ]
newbranch = self.class.new( self.directory, newdn )
attributes = self.entry.merge( stringify_keys(attributes) )
self.log.debug " merged attributes: %p" % [ attributes ]
self.directory.create( newbranch, attributes )
return newbranch
end
### Move the entry associated with this branch to a new entry indicated by +rdn+. If
### any +attributes+ are given, also replace the corresponding attributes on the new
### entry with them.
def move( rdn )
self.log.debug "Asking the directory to move me to an entry called %p" % [ rdn ]
self.directory.move( self, rdn )
self.clear_caches
return self
end
### Comparison-by-value method -- returns +true+ if the receiver has the same DN as
### +other+.
def eql?( other )
return false unless other.class.eql?( self.class )
return self.hash == other.hash
end
### Generates a Fixnum hash for the receiver.
def hash
return [ self.class, self.dn ].hash
end
### Comparable interface: Returns -1 if other_branch is less than, 0 if +other_branch+ is
### equal to, and +1 if +other_branch+ is greater than the receiving Branch.
def <=>( other_branch )
# Try the easy cases first
return nil unless other_branch.respond_to?( :dn ) &&
other_branch.respond_to?( :split_dn )
return 0 if other_branch.dn == self.dn
# Try comparing reversed attribute pairs
rval = nil
pairseq = self.split_dn.reverse.zip( other_branch.split_dn.reverse )
pairseq.each do |a,b|
comparison = (a <=> b)
return comparison if !comparison.nil? && comparison.nonzero?
end
# The branches are related, so directly comparing DN strings will work
return self.dn <=> other_branch.dn
end
### Fetch a new Treequel::Branch object for the child of the receiver with the specified
### +rdn+.
def get_child( rdn )
self.log.debug "Getting child %p from base = %p" % [ rdn, self.dn ]
newdn = [ rdn, self.dn ].reject {|part| part.empty? }.join( ',' )
return self.class.new( self.directory, newdn )
end
### Addition operator: return a Treequel::BranchCollection that contains both the receiver
### and +other_branch+.
def +( other_branch )
return Treequel::BranchCollection.new( self.branchset, other_branch.branchset )
end
### Return Treequel::Schema::ObjectClass instances for each of the receiver's
### objectClass attributes. If any +additional_classes+ are given,
### merge them with the current list of the current objectClasses for the lookup.
def object_classes( *additional_classes )
# self.log.debug "Fetching object classes for %s" % [ self.dn ]
schema = self.directory.schema
oc_oids = self[:objectClass] || []
oc_oids |= additional_classes.collect {|str| str.to_sym }
oc_oids << 'top' if oc_oids.empty?
oclasses = []
oc_oids.each do |oid|
oc = schema.object_classes[ oid.to_sym ] or
raise Treequel::Error, "schema doesn't have a %p objectClass" % [ oid ]
oclasses << oc
end
# self.log.debug " found %d objectClasses: %p" % [ oclasses.length, oclasses.map(&:name) ]
return oclasses.uniq
end
### Return the receiver's operational attributes as attributeType schema objects.
def operational_attribute_types
return self.directory.schema.operational_attribute_types
end
### Return OIDs (numeric OIDs as Strings, named OIDs as Symbols) for each of the
### receiver's operational attributes.
def operational_attribute_oids
return self.operational_attribute_types.inject([]) do |oids, attrtype|
oids.push( *attrtype.names )
oids << attrtype.oid
end
end
### Return Treequel::Schema::AttributeType instances for each of the receiver's
### objectClass's MUST attributeTypes. If any +additional_object_classes+ are given,
### include the MUST attributeTypes for them as well. This can be used to predict what
### attributes would need to be present for the entry to be saved if it added the
### +additional_object_classes+ to its own.
def must_attribute_types( *additional_object_classes )
oclasses = self.object_classes( *additional_object_classes )
types = oclasses.map( &:must ).flatten.uniq
return types
end
### Return OIDs (numeric OIDs as Strings, named OIDs as Symbols) for each of the receiver's
### objectClass's MUST attributeTypes. If any +additional_object_classes+ are given,
### include the OIDs of the MUST attributes for them as well. This can be used to predict
### what attributes would need to be present for the entry to be saved if it added the
### +additional_object_classes+ to its own.
def must_oids( *additional_object_classes )
return self.object_classes( *additional_object_classes ).
collect {|oc| oc.must_oids }.flatten.uniq.reject {|val| val == '' }
end
### Return a Hash of the attributes required by the Branch's objectClasses. If
### any +additional_object_classes+ are given, include the attributes that would be
### necessary for the entry to be saved with them.
def must_attributes_hash( *additional_object_classes )
attrhash = {}
self.must_attribute_types( *additional_object_classes ).each do |attrtype|
# self.log.debug " adding attrtype %p to the MUST attributes hash" % [ attrtype.name ]
if attrtype.name == :objectClass
attrhash[ :objectClass ] = ['top'] | additional_object_classes
elsif attrtype.single?
attrhash[ attrtype.name ] = ''
else
attrhash[ attrtype.name ] = ['']
end
end
return attrhash
end
### Return Treequel::Schema::AttributeType instances for each of the receiver's
### objectClass's MAY attributeTypes. If any +additional_object_classes+ are given,
### include the MAY attributeTypes for them as well. This can be used to predict what
### optional attributes could be added to the entry if the +additional_object_classes+
### were added to it.
def may_attribute_types( *additional_object_classes )
return self.object_classes( *additional_object_classes ).
collect {|oc| oc.may }.flatten.uniq
end
### Return OIDs (numeric OIDs as Strings, named OIDs as Symbols) for each of the receiver's
### objectClass's MAY attributeTypes. If any +additional_object_classes+ are given,
### include the OIDs of the MAY attributes for them as well. This can be used to predict
### what optional attributes could be added to the entry if the +additional_object_classes+
### were added to it.
def may_oids( *additional_object_classes )
return self.object_classes( *additional_object_classes ).
collect {|oc| oc.may_oids }.flatten.uniq
end
### Return a Hash of the optional attributes allowed by the Branch's objectClasses. If
### any +additional_object_classes+ are given, include the attributes that would be
### available for the entry if it had them.
def may_attributes_hash( *additional_object_classes )
entry = self.entry
attrhash = {}
self.may_attribute_types( *additional_object_classes ).each do |attrtype|
# self.log.debug " adding attrtype %p to the MAY attributes hash" % [ attrtype.named ]
if attrtype.single?
attrhash[ attrtype.name ] = nil
else
attrhash[ attrtype.name ] = []
end
end
# :FIXME: Does the resulting hash need the additional objectClasses? objectClass is
# MUST via 'top', so it should already exist in that hash when merged with
# this one...
# attrhash[ :objectClass ] |= additional_object_classes
return attrhash
end
### Return Treequel::Schema::AttributeType instances for the set of all of the receiver's
### MUST and MAY attributeTypes plus the operational attributes.
def valid_attribute_types
return self.must_attribute_types |
self.may_attribute_types |
self.operational_attribute_types
end
### Return a uniqified Array of OIDs (numeric OIDs as Strings, named OIDs as Symbols) for
### the set of all of the receiver's MUST and MAY attributeTypes plus the operational
### attributes.
def valid_attribute_oids
return self.must_oids | self.may_oids
end
### If the attribute associated with the given +attroid+ is in the list of valid
### attributeTypes for the receiver given its objectClasses, return the
### AttributeType object that corresponds with it. If it isn't valid, return nil.
### Includes operational attributes.
def valid_attribute_type( attroid )
return self.valid_attribute_types.find {|attr_type| attr_type.valid_name?(attroid) }
end
### Return +true+ if the specified +attrname+ is a valid attributeType given the
### receiver's current objectClasses. Does not include operational attributes.
def valid_attribute?( attroid )
return !self.valid_attribute_type( attroid ).nil?
end
### Return a Hash of all the attributes allowed by the Branch's objectClasses. If
### any +additional_object_classes+ are given, include the attributes that would be
### available for the entry if it had them.
def valid_attributes_hash( *additional_object_classes )
self.log.debug "Gathering a hash of all valid attributes:"
must = self.must_attributes_hash( *additional_object_classes )
self.log.debug " MUST attributes: %p" % [ must ]
may = self.may_attributes_hash( *additional_object_classes )
self.log.debug " MAY attributes: %p" % [ may ]
return may.merge( must )
end
#########
protected
#########
### Proxy method: call #traverse_branch if +attribute+ is a valid attribute
### and +value+ isn't +nil+.
def method_missing( attribute, value=nil, additional_attributes={} )
return super( attribute ) if value.nil?
return self.traverse_branch( attribute, value, additional_attributes )
end
### If +attribute+ matches a valid attribute type in the directory's
### schema, return a new Branch for the RDN of +attribute+ and +value+ and
### +additional_attributes+ (if it's a multi-value RDN).
###
### # (Called via #method_missing)
### branch = Treequel::Branch.new( directory, 'ou=people,dc=acme,dc=com' )
### branch.uid( :chester ).dn
### # => 'uid=chester,ou=people,dc=acme,dc=com'
### branch.uid( :chester, :employeeType => 'admin' ).dn
### # => 'uid=chester+employeeType=admin,ou=people,dc=acme,dc=com'
###
### Raises a NoMethodError if the +attribute+ or any +additional_attributes+ are
### not valid attributeTypes.
def traverse_branch( attribute, value, additional_attributes={} )
valid_types = self.directory.schema.attribute_types
# Raise if either the primary attribute or any secondary attributes are invalid
if !valid_types.key?( attribute )
raise NoMethodError, "undefined method `%s' for %p" % [ attribute, self ]
elsif invalid = additional_attributes.keys.find {|ex_attr| !valid_types.key?(ex_attr) }
raise NoMethodError, "invalid secondary attribute `%s' for %p" %
[ invalid, self ]
end
# Make a normalized RDN from the arguments and return the Branch for it
rdn = rdn_from_pair_and_hash( attribute, value, additional_attributes )
return self.get_child( rdn )
end
### Fetch the entry from the Branch's directory.
def lookup_entry
self.log.debug "Looking up entry for %s" % [ self.dn ]
entry = nil
if self.include_operational_attrs?
self.log.debug " including operational attributes."
entry = self.directory.get_extended_entry( self )
else
self.log.debug " not including operational attributes."
entry = self.directory.get_entry( self )
end
entry.delete( 'dn' ) if entry
self.log.debug " entry is: %p" % [ entry ]
return entry
end
### Get the value associated with +attrsym+, convert it to a Ruby object if the Branch's
### directory has a conversion rule, and return it.
def get_converted_object( attrsym )
value = self.entry ? self.entry[ attrsym.to_s ] : nil
if attribute = self.directory.schema.attribute_types[ attrsym ]
syntax = attribute.syntax
syntax_oid = syntax.oid if syntax
if attribute.single?
value = self.directory.convert_to_object( syntax_oid, value.first ) if value
else
value = Array( value ).collect do |raw|
self.directory.convert_to_object( syntax_oid, raw )
end
end
else
self.log.info "no attributeType for %p" % [ attrsym ]
end
return value
end
### Convert the specified +object+ according to the Branch's directory's conversion rules,
### and return it.
def get_converted_attribute( attrsym, object )
if attribute = self.directory.schema.attribute_types[ attrsym ]
self.log.debug "converting %p object (a %p) to a %s attribute" %
[ attrsym, object.class, attribute.syntax.desc ]
return self.directory.convert_to_attribute( attribute.syntax_oid, object )
else
self.log.info "no attributeType for %p" % [ attrsym ]
return object.to_s
end
end
### Clear any cached values when the structural state of the object changes.
def clear_caches
self.log.debug "Clearing entry and values caches."
@entry = nil
@values.clear
end
#######
private
#######
### Make an RDN string (RFC 4514) from the primary +attribute+ and +value+ pair plus any
### +additional_attributes+ (for multivalue RDNs).
def rdn_from_pair_and_hash( attribute, value, additional_attributes={} )
additional_attributes.merge!( attribute => value )
return additional_attributes.sort_by {|k,v| k.to_s }.
collect {|pair| pair.join('=') }.
join('+')
end
### Split the given +rdn+ into an Array of the iniital RDN attribute and value, and a
### Hash containing any additional pairs.
def pair_and_hash_from_rdn( rdn )
initial, *trailing = rdn.split( '+' )
initial_pair = initial.split( /\s*=\s*/ )
trailing_pairs = trailing.inject({}) do |hash,pair|
k,v = pair.split( /\s*=\s*/ )
hash[ k ] = v
hash
end
return initial_pair + [ trailing_pairs ]
end
### Given an +RDN+, return a Hash of the key/value pairs which make it up.
def make_rdn_hash( rdn )
return rdn.split( /\s*\+\s*/ ).inject({}) do |attributes, pair|
attrname, value = pair.split(/\s*=\s*/)
attributes[ attrname ] = [ value ]
attributes
end
end
### Make LDIF for the given +attribute+ and its +values+, wrapping at the given
### +width+.
def ldif_for_attr( attribute, value, width )
unsplit_line = "#{attribute}:"
if value.empty? || value =~ /\A#{LDIF_SAFE_STRING}\Z/
unsplit_line << ' ' << value.to_s
else
unsplit_line << ': ' << [ value ].pack( 'm' ).chomp
end
unsplit_line.gsub!( /\n/, '' )
ldif = ''
ldif << unsplit_line.slice!( 0, width ) << LDIF_FOLD_SEPARATOR until
unsplit_line.empty?
ldif.rstrip!
ldif << "\n"
return ldif
end
end # class Treequel::Branch