ruby-activeldap 0.4.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,1094 @@
1
+ # === ActiveLDAP - an OO-interface to LDAP objects inspired by ActiveRecord
2
+ # Author: Will Drewry <will@alum.bu.edu>
3
+ # License: See LICENSE and COPYING.txt
4
+ # Copyright 2004 Will Drewry <will@alum.bu.edu>
5
+ #
6
+ # == Summary
7
+ # ActiveLDAP lets you read and update LDAP entries in a completely object
8
+ # oriented fashion, even handling attributes with multiple names seamlessly.
9
+ # It was inspired by ActiveRecord so extending it to deal with custom
10
+ # LDAP schemas is as effortless as knowing the 'ou' of the objects, and the
11
+ # primary key. (fix this up some)
12
+ #
13
+ # == Example
14
+ # irb> require 'activeldap'
15
+ # > true
16
+ # irb> user = ActiveLDAP::User.new("drewry")
17
+ # > #<ActiveLDAP::User:0x402e...
18
+ # irb> user.cn
19
+ # > "foo"
20
+ # irb> user.commonname
21
+ # > "foo"
22
+ # irb> user.cn = "Will Drewry"
23
+ # > "Will Drewry"
24
+ # irb> user.cn
25
+ # > "Will Drewry"
26
+ # irb> user.validate
27
+ # > nil
28
+ # irb> user.write
29
+ #
30
+ #
31
+
32
+ require 'ldap'
33
+ require 'ldap/schema'
34
+ require 'log4r'
35
+
36
+ module ActiveLDAP
37
+ # OO-interface to LDAP assuming pam/nss_ldap-style comanization with Active specifics
38
+ # Each subclass does a ldapsearch for the matching entry.
39
+ # If no exact match, raise an error.
40
+ # If match, change all LDAP attributes in accessor attributes on the object.
41
+ # -- these are ACTUALLY populated from schema - see subschema.rb example
42
+ # -- @conn.schema().each{|k,vs| vs.each{|v| print("#{k}: #{v}\n")}}
43
+ # -- extract objectClasses from match and populate
44
+ # Multiple entries become lists.
45
+ # If this isn't read-only then lists become multiple entries, etc.
46
+
47
+ # AttributeEmpty
48
+ #
49
+ # An exception raised when a required attribute is found to be empty
50
+ class AttributeEmpty < RuntimeError
51
+ end
52
+
53
+ # DeleteError
54
+ #
55
+ # An exception raised when an ActiveLDAP delete action fails
56
+ class DeleteError < RuntimeError
57
+ end
58
+
59
+ # WriteError
60
+ #
61
+ # An exception raised when an ActiveLDAP write action fails
62
+ class WriteError < RuntimeError
63
+ end
64
+
65
+ # AuthenticationError
66
+ #
67
+ # An exception raised when user authentication fails
68
+ class AuthenticationError < RuntimeError
69
+ end
70
+
71
+ # ConnectionError
72
+ #
73
+ # An exception raised when the LDAP conenction fails
74
+ class ConnectionError < RuntimeError
75
+ end
76
+
77
+
78
+ # Base
79
+ #
80
+ # Base is the primary class which contains all of the core
81
+ # ActiveLDAP functionality. It is meant to only ever be subclassed
82
+ # by extension classes.
83
+ class Base
84
+ # Parsed schema structures
85
+ attr_reader :must, :may
86
+ attr_accessor :logger
87
+
88
+ # All class-wide variables
89
+ @@config = nil # Container for current connection settings
90
+ @@schema = nil # LDAP server's schema
91
+ @@conn = nil # LDAP connection
92
+
93
+ # Driver generator
94
+ #
95
+ # TODO add type checking
96
+ # This let's you call this method to create top-level extension object. This
97
+ # is really just a proof of concept and has not truly useful purpose.
98
+ # example: Base.create_object(:class => "user", :dnattr => "uid", :classes => ['top'])
99
+ #
100
+ def Base.create_object(config={})
101
+ # Just upcase the first letter of the new class name
102
+ str = config[:class]
103
+ class_name = str[0].chr.upcase + str[1..-1]
104
+
105
+ attr = config[:dnattr] # "uid"
106
+ prefix = config[:base] # "ou=People"
107
+ # [ 'top', 'posixAccount' ]
108
+ classes_array = config[:classes] || []
109
+ # [ [ :groups, {:class_name => "Group", :foreign_key => "memberUid"}] ]
110
+ belongs_to_array = config[:belongs_to] || []
111
+ # [ [ :members, {:class_name => "User", :foreign_key => "uid", :local_key => "memberUid"}] ]
112
+ has_many_array = config[:has_many] || []
113
+
114
+ raise TypeError, ":objectclasses must be an array" unless classes_array.respond_to? :size
115
+ raise TypeError, ":belongs_to must be an array" unless belongs_to_array.respond_to? :size
116
+ raise TypeError, ":has_many must be an array" unless has_many_array.respond_to? :size
117
+
118
+ # Build classes array
119
+ classes = '['
120
+ classes_array.map! {|x| x = "'#{x}'"}
121
+ classes << classes_array.join(', ')
122
+ classes << ']'
123
+
124
+ # Build belongs_to
125
+ belongs_to = []
126
+ if belongs_to_array.size > 0
127
+ belongs_to_array.each do |bt|
128
+ line = [ "belongs_to :#{bt[0]}" ]
129
+ bt[1].keys.each do |key|
130
+ line << ":#{key} => '#{bt[1][key]}'"
131
+ end
132
+ belongs_to << line.join(', ')
133
+ end
134
+ end
135
+
136
+ # Build has_many
137
+ has_many = []
138
+ if has_many_array.size > 0
139
+ has_many_array.each do |hm|
140
+ line = [ "has_many :#{hm[0]}" ]
141
+ hm[1].keys.each do |key|
142
+ line << ":#{key} => '#{hm[1][key]}'"
143
+ end
144
+ has_many << line.join(', ')
145
+ end
146
+ end
147
+
148
+ self.class.module_eval <<-"end_eval"
149
+ class ::#{class_name} < ActiveLDAP::Base
150
+ ldap_mapping :dnattr => "#{attr}, :prefix => "#{prefix}", :classes => "#{classes}"
151
+ #{belongs_to.join("\n")}
152
+ #{has_many.join("\n")}
153
+ end
154
+ end_eval
155
+ end
156
+
157
+ # Connect and bind to LDAP creating a class variable for use by all ActiveLDAP
158
+ # objects.
159
+ #
160
+ # == +config+
161
+ # +config+ must be a hash that may contain any of the following fields:
162
+ # :user, :password_block, :logger, :host, :port, :base, :bind_format, :try_sasl, :allow_anonymous
163
+ # :user specifies the username to bind with.
164
+ # :bind_format specifies the string to substitute the username into on bind. e.g. uid=%s,ou=People,dc=example,dc=com. Overrides @@bind_format.
165
+ # :password_block specifies a Proc object that will yield a String to be used as the password when called.
166
+ # :logger specifies a preconfigured Log4r::Logger to be used for all logging
167
+ # :host overrides the configuration.rb @@host setting with the LDAP server hostname
168
+ # :port overrides the configuration.rb @@port setting for the LDAP server port
169
+ # :base overwrites Base.base - this affects EVERYTHING
170
+ # :try_sasl indicates that a SASL bind should be attempted when binding to the server (default: false)
171
+ # :allow_anonymous indicates that a true anonymous bind is allowed when trying to bind to the server (default: true)
172
+ def Base.connect(config={}) # :user, :password_block, :logger
173
+ # Process config
174
+ # Class options
175
+ ## These will be replace by configuration.rb defaults if defined
176
+ @@config = {}
177
+ @@config[:host] = config[:host] || @@host
178
+ @@config[:port] = config[:port] || @@port
179
+ if config[:base]
180
+ Base.class_eval <<-"end_eval"
181
+ def Base.base
182
+ '#{config[:base]}'
183
+ end
184
+ end_eval
185
+ end
186
+ @@config[:bind_format] = config[:bind_format] || @@bind_format
187
+
188
+ @@logger = config[:logger] || nil
189
+ # Setup default logger to console
190
+ if @@logger.nil?
191
+ @@logger = Log4r::Logger.new('activeldap')
192
+ @@logger.level = Log4r::FATAL
193
+ Log4r::StderrOutputter.new 'console'
194
+ @@logger.add('console')
195
+ end
196
+
197
+ # Method options
198
+ user = nil
199
+ password_block = nil
200
+ @@config[:allow_anonymous] = true
201
+ @@config[:try_sasl] = false
202
+
203
+ @@config[:user] = config[:user] || user
204
+ @@config[:allow_anonymous] = config[:allow_anonymous] if config.has_key? :allow_anonymous
205
+ @@config[:try_sasl] = config[:try_sasl]
206
+ @@config[:password_block] = config[:password_block] if config.has_key? :password_block
207
+
208
+ # Setup bind credentials
209
+ @@config[:user] = ENV['USER'] unless @@config[:user]
210
+
211
+ # Connect to LDAP
212
+ begin
213
+ # SSL using START_TLS
214
+ @@conn = LDAP::SSLConn.new(@@config[:host], @@config[:port], true)
215
+ rescue
216
+ @@logger.warn "Warning: Failed to connect using TLS!"
217
+ begin
218
+ @@logger.warn "Warning: Attempting SSL connection . . ."
219
+ @@conn = LDAP::SSLConn.new(@@config[:host], @@config[:port], false)
220
+ # HACK: Load the schema here because otherwise you can't tell if the
221
+ # HACK: SSLConn is a real SSL connection.
222
+ @@schema = @@conn.schema() if @@schema.nil?
223
+ rescue
224
+ @@logger.warn "Warning: Attempting unencrypted connection . . ."
225
+ @@conn = LDAP::Conn.new(@@config[:host], @@config[:port])
226
+ end
227
+ end
228
+ @@logger.debug "Connected to #{@@config[:host]}:#{@@config[:port]}."
229
+
230
+ # Enforce LDAPv3
231
+ @@conn.set_option(LDAP::LDAP_OPT_PROTOCOL_VERSION, 3)
232
+
233
+ # Authenticate
234
+ do_bind
235
+
236
+ # Load Schema (if not straight SSL...)
237
+ begin
238
+ @@schema = @@conn.schema() if @@schema.nil?
239
+ rescue => detail
240
+ raise ConnectionError, "#{detail.exception} - LDAP connection failure, or server does not support schema queries."
241
+ end
242
+
243
+
244
+ # Cleanly return
245
+ return nil
246
+ end # Base.connect
247
+
248
+ # Base.close
249
+ # This method deletes the LDAP connection object.
250
+ # This does NOT reset any overridden values from a Base.connect call.
251
+ def Base.close
252
+ @@conn = nil
253
+ end
254
+
255
+ # Return the LDAP connection object currently in use
256
+ def Base.connection
257
+ return @@conn
258
+ end
259
+
260
+ # Set the LDAP connection avoiding Base.connect or multiplexing connections
261
+ def Base.connection=(conn)
262
+ @@conn = conn
263
+ end
264
+
265
+ # Return the schema object
266
+ def Base.schema
267
+ @@schema
268
+ end
269
+
270
+ # search
271
+ #
272
+ # Wraps Ruby/LDAP connection.search to make it easier to search for specific
273
+ # data without cracking open Base.connection
274
+ def Base.search(config={})
275
+ unless Base.connection
276
+ if @@config
277
+ ActiveLDAP::Base.connect(@@config)
278
+ else
279
+ ActiveLDAP::Base.connect
280
+ end
281
+ end
282
+
283
+ config[:filter] = 'objectClass=*' unless config.has_key? :filter
284
+ config[:attrs] = [] unless config.has_key? :attrs
285
+ config[:scope] = LDAP::LDAP_SCOPE_SUBTREE unless config.has_key? :scope
286
+ config[:base] = base() unless config.has_key? :base
287
+
288
+ values = []
289
+ config[:attrs] = config[:attrs].to_a # just in case
290
+
291
+ begin
292
+ @@conn.search(config[:base], config[:scope], config[:filter], config[:attrs]) do |m|
293
+ res = {}
294
+ res['dn'] = [m.dn.dup] # For consistency with the below
295
+ m.attrs.each do |attr|
296
+ if config[:attrs].member? attr or config[:attrs].empty?
297
+ res[attr] = m.vals(attr).dup
298
+ end
299
+ end
300
+ values.push(res)
301
+ end
302
+ rescue RuntimeError => detail
303
+ @@logger.debug "No matches for #{config[:filter]} and attrs #{config[:attrs]}"
304
+ # Do nothing on failure
305
+ end
306
+ return values
307
+ end
308
+
309
+ # find
310
+ #
311
+ # Finds the first match for value where |value| is the value of some
312
+ # |field|, or the wildcard match. This is only useful for derived classes.
313
+ # usage: Subclass.find(:attribute => "cn", :value => "some*val", :objects => true)
314
+ # Subclass.find('some*val')
315
+ #
316
+ def Base.find(config = {})
317
+ unless Base.connection
318
+ if @@config
319
+ ActiveLDAP::Base.connect(@@config)
320
+ else
321
+ ActiveLDAP::Base.connect
322
+ end
323
+ end
324
+
325
+ if self.class == Class
326
+ klass = self.ancestors[0].to_s.split(':').last
327
+ real_klass = self.ancestors[0]
328
+ else
329
+ klass = self.class.to_s.split(':').last
330
+ real_klass = self.class
331
+ end
332
+
333
+ # Allow a single string argument
334
+ attr = dnattr()
335
+ objects = false
336
+ val = config
337
+ # Or a hash
338
+ if config.respond_to?"has_key?"
339
+ attr = config[:attribute] || dnattr()
340
+ val = config[:value] || '*'
341
+ objects = config[:objects]
342
+ end
343
+
344
+ matches = []
345
+
346
+ begin
347
+ # Get some attributes
348
+ @@conn.search(base(), LDAP::LDAP_SCOPE_SUBTREE, "(#{attr}=#{val})") do |m|
349
+ # Extract the dnattr value
350
+ dnval = m.dn.split(/,/)[0].split(/=/)[1]
351
+
352
+ if objects
353
+ return eval("#{real_klass}.new(dnval)")
354
+ else
355
+ return dnval
356
+ end
357
+ end
358
+ rescue RuntimeError => detail
359
+ @@logger.debug "No matches for #{attr}=#{val}"
360
+ # Do nothing on failure
361
+ end
362
+ return nil
363
+ end
364
+ private_class_method :find
365
+
366
+
367
+ # find_all
368
+ #
369
+ # Finds all matches for value where |value| is the value of some
370
+ # |field|, or the wildcard match. This is only useful for derived classes.
371
+ def Base.find_all(config = {})
372
+ unless Base.connection
373
+ if @@config
374
+ ActiveLDAP::Base.connect(@@config)
375
+ else
376
+ ActiveLDAP::Base.connect
377
+ end
378
+ end
379
+
380
+ if self.class == Class
381
+ real_klass = self.ancestors[0]
382
+ else
383
+ real_klass = self.class
384
+ end
385
+
386
+ # Allow a single string argument
387
+ attr = dnattr()
388
+ objects = false
389
+ val = config
390
+ # Or a hash
391
+ if config.respond_to?"has_key?"
392
+ attr = config[:attribute] || dnattr()
393
+ val = config[:value] || '*'
394
+ objects = config[:objects]
395
+ end
396
+
397
+ matches = []
398
+
399
+ begin
400
+ # Get some attributes
401
+ @@conn.search(base(), LDAP::LDAP_SCOPE_SUBTREE, "(#{attr}=#{val})") do |m|
402
+ # Extract the dnattr value
403
+ dnval = m.dn.split(/,/)[0].split(/=/)[1]
404
+
405
+ if objects
406
+ matches.push(eval("#{real_klass}.new(dnval)"))
407
+ else
408
+ matches.push(dnval)
409
+ end
410
+ end
411
+ rescue RuntimeError => detail
412
+ #p @@conn.err2string(@@conn.err)
413
+ @@logger.debug "No matches for #{attr}=#{val}"
414
+ # Do nothing on failure
415
+ end
416
+ return matches
417
+ end
418
+ private_class_method :find_all
419
+
420
+ # Base.base
421
+ #
422
+ # This method when included into Base provides
423
+ # an inheritable, overwritable configuration setting
424
+ #
425
+ # This should be a string with the base of the
426
+ # ldap server such as 'dc=example,dc=com', and
427
+ # it should be overwritten by including
428
+ # configuration.rb into this class.
429
+ # When subclassing, the specified prefix will be concatenated.
430
+ def Base.base
431
+ 'dc=example,dc=com'
432
+ end
433
+
434
+ # Base.dnattr
435
+ #
436
+ # This is a placeholder for the class method that will
437
+ # be overridden on calling ldap_mapping in a subclass.
438
+ # Using a class method allows for clean inheritance from
439
+ # classes that already have a ldap_mapping.
440
+ def Base.dnattr
441
+ ''
442
+ end
443
+
444
+ # Base.required_classes
445
+ #
446
+ # This method when included into Base provides
447
+ # an inheritable, overwritable configuration setting
448
+ #
449
+ # The value should be the minimum required objectClasses
450
+ # to make an object in the LDAP server, or an empty array [].
451
+ # This should be overwritten by configuration.rb.
452
+ # Note that subclassing does not cause concatenation of
453
+ # arrays to occurs.
454
+ def Base.required_classes
455
+ []
456
+ end
457
+
458
+ ### All instance methods, etc
459
+
460
+ # new
461
+ #
462
+ # Creates a new instance of Base initializing all class and
463
+ # instance variables. initialize2() must be called to complete
464
+ # all initialization.
465
+ # defines local defaults. See examples
466
+ # If multiple values exist for dnattr, the first one put here will be authoritative
467
+ # TODO: Add support for relative distinguished names
468
+ def initialize(val='')
469
+ if val.class != String
470
+ raise TypeError, "Object key must be a String"
471
+ end
472
+ # Try a default connection if none made explicitly
473
+ unless Base.connection
474
+ # Use @@config if it has been prepopulated and the conn is down.
475
+ if @@config
476
+ ActiveLDAP::Base.connect(@@config)
477
+ else
478
+ ActiveLDAP::Base.connect
479
+ end
480
+ end
481
+
482
+ @data = {} # where the r/w entry data is stored
483
+ @data.default = []
484
+ @ldap_data = {} # original ldap entry data
485
+ @ldap_data.default = []
486
+ @attr_methods = {} # list of valid method calls for attributes used for dereferencing
487
+ @must = {} # list of schema required attributes
488
+ @may = {} # list of schema optional attributes
489
+ @sup = {} # list of schema supplemental attributes
490
+
491
+ # Break val apart if it is a dn
492
+ if val.match(/^#{dnattr()}=([^,=]+),#{base()}$/i)
493
+ val = $1
494
+ elsif val.match(/[=,]/)
495
+ @@logger.info "initialize: Changing val from '#{val}' to '' because it doesn't match the DN."
496
+ val = ''
497
+ end
498
+
499
+ # Do a search - if it exists, pull all data and parse schema, if not, just set the hierarchical data
500
+ if val.empty?
501
+ @exists = false
502
+ # Setup what should eb authoritative
503
+ @dn = "#{dnattr()}=#{val},#{base()}"
504
+ send(:objectClass=, required_classes())
505
+ else # do a search then
506
+ # Search for the existing entry
507
+ begin
508
+ # Get some attributes
509
+ Base.connection.search("#{dnattr()}=#{val},#{base()}", LDAP::LDAP_SCOPE_SUBTREE, "objectClass=*") do |m|
510
+ # Save DN
511
+ @dn = m.dn
512
+ # Load up data into tmp
513
+ m.attrs.each do |attr|
514
+ # Load with subtypes just like @data
515
+ safe_attr, value = make_subtypes(attr, m.vals(attr).dup)
516
+ @ldap_data[safe_attr] = value
517
+ end
518
+ end
519
+ @exists = true
520
+ # Populate schema data
521
+ send(:objectClass=, @ldap_data['objectClass'])
522
+
523
+ # Populate real data now that we have the schema with aliases
524
+ @ldap_data.each do |pair|
525
+ send(:attribute_method=, pair[0], pair[1])
526
+ end
527
+
528
+ rescue LDAP::ResultError
529
+ @exists = false
530
+ # Create what should be the authoritative DN
531
+ @dn = "#{dnattr()}=#{val},#{base()}"
532
+ send(:objectClass=, required_classes())
533
+
534
+ # Setup dn attribute (later rdn too!)
535
+ attr_sym = "#{dnattr()}=".to_sym
536
+ send(attr_sym, val)
537
+ end
538
+ end
539
+ end # initialize2
540
+
541
+ # Hide new in Base
542
+ private_class_method :new
543
+
544
+ # attributes
545
+ #
546
+ # Return attribute methods so that a program can determine available
547
+ # attributes dynamically without schema awareness
548
+ def attributes
549
+ return @attr_methods.keys
550
+ end
551
+
552
+ # objectClass=
553
+ #
554
+ # objectClass= special case for updating appropriately
555
+ # This updates the objectClass entry in @data. It also
556
+ # updating all required and allowed attributes while
557
+ # removing defined attributes that are no longer valid
558
+ # given the new objectclasses.
559
+ def objectClass=(val)
560
+ if val.class != Array
561
+ raise TypeError, 'objectClass must be an Array'
562
+ end
563
+
564
+ val.each do |klass|
565
+ unless klass.class == String
566
+ raise TypeError, "Value in array is not a String. (#{klass.class})"
567
+ end
568
+ unless Base.schema.names("objectClasses").member? klass
569
+ # TODO MAKE OWN EXCEPTION
570
+ raise RuntimeError, "objectClass '#{klass}' unknown to LDAP server"
571
+ end
572
+ end
573
+
574
+ # make sure this doesn't drop any of the required objectclasses
575
+ required_classes().each do |oc|
576
+ unless val.member? oc
577
+ raise "'#{oc}' must be a defined objectClass for class '#{self.class}'"
578
+ end
579
+ end
580
+
581
+ # Set the actual objectClass data
582
+ define_attribute_methods('objectClass')
583
+ @data['objectClass'] = val.uniq
584
+
585
+ # Build |data| from schema
586
+ # clear attr_method mapping first
587
+ @attr_methods = {}
588
+ @must = {}
589
+ @may = {}
590
+ @sup = {}
591
+ val.each do |objc|
592
+ # Setup dependencies for validation pre-save
593
+ @must[objc] = Base.schema.attr('objectClasses', objc, 'MUST')
594
+ @may[objc] = Base.schema.attr('objectClasses', objc, 'MAY')
595
+ @sup[objc] = Base.schema.attr('objectClasses', objc, 'SUP')
596
+ @must[objc] = [] if @must[objc].nil?
597
+ @may[objc] = [] if @may[objc].nil?
598
+ @sup[objc] = [] if @sup[objc].nil?
599
+
600
+ # setup the alias/attr method mapping for @must
601
+ @must[objc].each do |attr|
602
+ # Update attr_method with appropriate
603
+ define_attribute_methods(attr)
604
+ end
605
+
606
+ # setup the alias/attr method mapping for @may
607
+ @may[objc].each do |attr|
608
+ define_attribute_methods(attr)
609
+ end
610
+ end
611
+
612
+ # Delete all now invalid attributes given the new objectClasses
613
+ @data.keys.each do |key|
614
+ # If it's not a proper aliased attribute, drop it
615
+ unless @attr_methods.has_key? key
616
+ @data.delete(key)
617
+ end
618
+ end
619
+ end
620
+
621
+ # exists?
622
+ #
623
+ # Return whether the entry exists in LDAP or not
624
+ def exists?
625
+ return @exists
626
+ end
627
+
628
+ # dn
629
+ #
630
+ # Return the authoritative dn
631
+ def dn
632
+ return @dn.dup
633
+ end
634
+
635
+ # validate
636
+ #
637
+ # Basic validation:
638
+ # - Verify that every 'MUST' specified in the schema has a value defined
639
+ # - Enforcement of undefined attributes is handled in the objectClass= method
640
+ def validate
641
+ @must.each do |oc|
642
+ oc[1].each do |req_attr|
643
+ # TODO: should I ever check if key exists? bug if it doesnt...
644
+ deref = @attr_methods[req_attr]
645
+ if @data[deref] == []
646
+ raise AttributeEmpty,
647
+ "objectClass '#{oc[0]}' requires attribute '#{Base.schema.attribute_aliases(req_attr).join(', ')}'"
648
+ end
649
+ end
650
+ end
651
+ end
652
+
653
+
654
+ # delete
655
+ #
656
+ # Delete this entry from LDAP
657
+ def delete
658
+ begin
659
+ @@conn.delete(@dn)
660
+ @exists = false
661
+ rescue LDAP::ResultError => detail
662
+ raise DeleteError, "Failed to delete LDAP entry: '#{@dn}'"
663
+ end
664
+ end
665
+
666
+
667
+ # write
668
+ #
669
+ # Write and validate this object into LDAP
670
+ # either adding or replacing attributes
671
+ # TODO: Binary data support
672
+ # TODO: Relative DN support
673
+ def write
674
+ # Validate against the objectClass requirements
675
+ validate
676
+
677
+ # Put all changes into one change entry to ensure
678
+ # automatic rollback upon failure.
679
+ entry = []
680
+ if @exists
681
+ # Cycle through all attrs to determine action
682
+ action = {}
683
+
684
+ replaceable = []
685
+
686
+ # Expand subtypes to real ldap_data entries
687
+ # We can't reuse @ldap_data because an exception would leave
688
+ # an object in an unknown state
689
+ ldap_data = @ldap_data.dup
690
+ ldap_data.keys.each do |key|
691
+ ldap_data[key].each do |value|
692
+ if value.class == Hash
693
+ suffix, real_value = extract_subtypes(value)
694
+ if ldap_data.has_key? key + suffix
695
+ ldap_data[key + suffix].push(real_value)
696
+ else
697
+ ldap_data[key + suffix] = real_value
698
+ end
699
+ ldap_data[key].delete(value)
700
+ end
701
+ end
702
+ end
703
+
704
+ # Expand subtypes to real data entries, but leave @data alone
705
+ data = @data.dup
706
+ data.keys.each do |key|
707
+ data[key].each do |value|
708
+ if value.class == Hash
709
+ suffix, real_value = extract_subtypes(value)
710
+ if data.has_key? key + suffix
711
+ data[key + suffix].push(real_value)
712
+ else
713
+ data[key + suffix] = real_value
714
+ end
715
+ data[key].delete(value)
716
+ end
717
+ end
718
+ end
719
+
720
+ # Now that all the subtypes will be treated as unique attributes
721
+ # we can see what's changed and add anything that is brand-spankin'
722
+ # new.
723
+ ldap_data.each do |pair|
724
+ suffix = ''
725
+ binary = 0
726
+
727
+ name, *suffix_a = pair[0].split(/;/)
728
+ suffix = ';'+ suffix_a.join(';') if suffix_a.size > 0
729
+ name = @attr_methods[name]
730
+ name = pair[0].split(/;/)[0] if name.nil? # for objectClass, or removed vals
731
+ value = data[name+suffix]
732
+
733
+ # Detect subtypes and account for them
734
+ binary = LDAP::LDAP_MOD_BVALUES if suffix.match(/;binary(;|$)/)
735
+
736
+ replaceable.push(name+suffix)
737
+ if pair[1] != value
738
+ # Create mod entries
739
+ if not value.empty?
740
+ # Ditched delete then replace because attribs with no equality match rules
741
+ # will fails
742
+ @@logger.debug("updating attribute of existing entry: #{name+suffix}: #{value.inspect}")
743
+ entry.push(LDAP.mod(LDAP::LDAP_MOD_REPLACE|binary, name + suffix, value))
744
+ else
745
+ # Since some types do not have equality matching rules, delete doesn't work
746
+ # Replacing with nothing is equivalent.
747
+ @@logger.debug("removing attribute from existing entry: #{name+suffix}")
748
+ entry.push(LDAP.mod(LDAP::LDAP_MOD_REPLACE|binary, name + suffix, []))
749
+ end
750
+ end
751
+ end
752
+ data.each do |pair|
753
+ suffix = ''
754
+ binary = 0
755
+
756
+ name, *suffix_a = pair[0].split(/;/)
757
+ suffix = ';' + suffix_a.join(';') if suffix_a.size > 0
758
+ name = @attr_methods[name]
759
+ name = pair[0].split(/;/)[0] if name.nil? # for obj class or removed vals
760
+ value = pair[1]
761
+
762
+ if not replaceable.member? name+suffix
763
+ # Detect subtypes and account for them
764
+ binary = LDAP::LDAP_MOD_BVALUES if suffix.match(/;binary(;|$)/)
765
+ @@logger.debug("adding attribute to existing entry: #{name+suffix}: #{value.inspect}")
766
+ entry.push(LDAP.mod(LDAP::LDAP_MOD_ADD|binary, name + suffix, value)) unless value.empty?
767
+ end
768
+ end
769
+ begin
770
+ @@conn.modify(@dn, entry)
771
+ rescue => detail
772
+ raise WriteError, "Could not update LDAP entry: #{detail}"
773
+ end
774
+ else # add everything!
775
+ entry.push(LDAP.mod(LDAP::LDAP_MOD_ADD, @attr_methods[dnattr()],
776
+ data[@attr_methods[dnattr()]]))
777
+ entry.push(LDAP.mod(LDAP::LDAP_MOD_ADD, 'objectClass',
778
+ data[@attr_methods['objectClass']]))
779
+ @data.each do |pair|
780
+ if pair[1].size > 0 and pair[0] != 'objectClass' and pair[0] != @attr_methods[dnattr()]
781
+ # Detect subtypes and account for them
782
+ binary = LDAP::LDAP_MOD_BVALUES if pair[0].match(/;binary(;|$)/)
783
+ @@logger.debug("adding attribute to new entry: #{name+suffix}: #{value.inspect}")
784
+ entry.push(LDAP.mod(LDAP::LDAP_MOD_ADD|binary, pair[0], pair[1]))
785
+ end
786
+ end
787
+ begin
788
+ @@conn.add(@dn, entry)
789
+ @exists = true
790
+ rescue LDAP::ResultError => detail
791
+ raise WriteError, "Could not add LDAP entry: #{detail}"
792
+ end
793
+ end
794
+ @ldap_data = @data.dup
795
+ end
796
+
797
+
798
+ # method_missing
799
+ #
800
+ # If a given method matches an attribute or an attribute alias
801
+ # then call the appropriate method.
802
+ # TODO: Determine if it would be better to define each allowed method
803
+ # using class_eval instead of using method_missing. This would
804
+ # give tab completion in irb.
805
+ def method_missing(name, *args)
806
+ key = name.to_s
807
+ case key
808
+ when /^(\S+)=$/
809
+ real_key = $1
810
+ if @attr_methods.has_key? real_key
811
+ raise ArgumentError, "wrong number of arguments (#{args.size} for 1)" if args.size != 1
812
+ return send(:attribute_method=, real_key, args[0])
813
+ end
814
+ else
815
+ if @attr_methods.has_key? key
816
+ raise ArgumentError, "wrong number of arguments (#{args.size} for 1)" if args.size > 1
817
+ return attribute_method(key, *args)
818
+ end
819
+ end
820
+ raise NoMethodError, "undefined method `#{key}' for #{self}"
821
+ end
822
+
823
+
824
+ private
825
+
826
+ # Enforce typing:
827
+ # Hashes are for subtypes
828
+ # Arrays are for multiple entries
829
+ def attribute_input_handler(attr, value)
830
+ if attr.nil?
831
+ raise RuntimeError, 'attr argument must not be nil.'
832
+ end
833
+ binary = Base.schema.binary? attr
834
+ single = Base.schema.single_value? attr
835
+ case value.class.to_s
836
+ when 'Array'
837
+ if single and value.size > 1
838
+ raise TypeError, "This attribute can only have a single value"
839
+ end
840
+ value.map! do |entry|
841
+ entry = entry.to_s unless entry.class == Hash
842
+ entry = attribute_input_handler(attr, entry)[0]
843
+ end
844
+ when 'Hash'
845
+ if value.keys.size > 1
846
+ raise TypeError, "Hashes must have one key-value pair only."
847
+ end
848
+ unless value.keys[0].match(/^(lang-[a-z][a-z]*)|(binary)$/)
849
+ @@logger.warn("unknown subtype did not match lang-* or binary: #{value.keys[0]}")
850
+ end
851
+ # Contents MUST be a String or an Array
852
+ if value.keys[0] != 'binary' and binary
853
+ suffix, real_value = extract_subtypes(value)
854
+ value = make_subtypes(name + suffix + ';binary', real_value)
855
+ end
856
+ value = [value]
857
+ when 'String'
858
+ if binary
859
+ value = {'binary' => value}
860
+ end
861
+ return [value]
862
+ else
863
+ value = [value.to_s]
864
+ end
865
+ return value
866
+ end
867
+
868
+ # make_subtypes
869
+ #
870
+ # Makes the Hashized value from the full attributename
871
+ # e.g. userCertificate;binary => "some_bin"
872
+ # becomes userCertificate => {"binary" => "some_bin"}
873
+ def make_subtypes(attr, value)
874
+ return [attr, value] unless attr.match(/;/)
875
+
876
+ ret_attr, *subtypes = attr.split(/;/)
877
+ return [ret_attr, [make_subtypes_helper(subtypes, value)]]
878
+ end
879
+
880
+ # make_subtypes_helper
881
+ #
882
+ # This is a recursive function for building
883
+ # nested hashed from multi-subtyped values
884
+ def make_subtypes_helper(subtypes, value)
885
+ return value if subtypes.size == 0
886
+ return {subtypes[0] => make_subtypes_helper(subtypes[1..-1], value)}
887
+ end
888
+
889
+ # extract_subtypes
890
+ #
891
+ # Extracts all of the subtypes from a given set of nested hashes
892
+ # and returns the attribute suffix and the final true value
893
+ def extract_subtypes(value)
894
+ subtype = ''
895
+ ret_val = value
896
+ if value.class == Hash
897
+ subtype = ';' + value.keys[0]
898
+ ret_val = value[value.keys[0]]
899
+ subsubtype = ''
900
+ if ret_val.class == Hash
901
+ subsubtype, ret_val = extract_subtypes(ret_val)
902
+ end
903
+ subtype += subsubtype
904
+ end
905
+ ret_val = [ret_val] unless ret_val.class == Array
906
+ return subtype, ret_val
907
+ end
908
+
909
+
910
+ # Wrapper all bind activity
911
+ def Base.do_bind()
912
+ bind_dn = @@config[:bind_format] % [@@config[:user]]
913
+ if @@config[:password_block]
914
+ password = @@config[:password_block].call
915
+ @@config[:password_block] = Proc.new { password }
916
+ end
917
+
918
+ # Rough bind loop:
919
+ # Attempt 1: SASL if available
920
+ # Attempt 2: SIMPLE with credentials if password block
921
+ # Attempt 3: SIMPLE ANONYMOUS if 1 and 2 fail (or pwblock returns '')
922
+ auth = false
923
+ auth = do_sasl_bind(bind_dn) if @@config[:try_sasl]
924
+ auth = do_simple_bind(bind_dn) unless auth
925
+ auth = do_anonymous_bind(bind_dn) if not auth and @@config[:allow_anonymous]
926
+
927
+ unless auth
928
+ raise AuthenticationError, "All authentication mechanisms failed"
929
+ end
930
+ return auth
931
+ end
932
+
933
+
934
+ # Base.do_anonymous_bind
935
+ #
936
+ # Bind to LDAP with the given DN, but with no password. (anonymous!)
937
+ def Base.do_anonymous_bind(bind_dn)
938
+ @@logger.info "Attempting anonymous authentication"
939
+ begin
940
+ @@conn.bind()
941
+ return true
942
+ rescue
943
+ @@logger.debug "LDAP Error: #{@@conn.err2string(@@conn.err)}"
944
+ @@logger.warn "Warning: Anonymous authentication failed."
945
+ return false
946
+ end
947
+ end
948
+
949
+ # Base.do_simple_bind
950
+ #
951
+ # Bind to LDAP with the given DN and password_block.call()
952
+ def Base.do_simple_bind(bind_dn)
953
+ return false unless @@config[:password_block].respond_to? :call
954
+ begin
955
+ @@conn.bind(bind_dn, @@config[:password_block].call())
956
+ return true
957
+ rescue
958
+ @@logger.debug "LDAP Error: #{@@conn.err2string(@@conn.err)}"
959
+ @@logger.warn "Warning: SIMPLE authentication failed."
960
+ return false
961
+ end
962
+ end
963
+
964
+ # Base.do_sasl_bind
965
+ #
966
+ # Bind to LDAP with the given DN using any available SASL methods
967
+ def Base.do_sasl_bind(bind_dn)
968
+ # Get all SASL mechanisms
969
+ mechanisms = @@conn.root_dse[0]['supportedSASLMechanisms']
970
+ # Use GSSAPI if available
971
+ # Currently only GSSAPI is supported with Ruby/LDAP from
972
+ # http://caliban.com/files/redhat/RPMS/i386/ruby-ldap-0.8.2-4.i386.rpm
973
+ # TODO: Investigate further SASL support
974
+ if mechanisms.respond_to? :member? and mechanisms.member? 'GSSAPI'
975
+ begin
976
+ @@conn.sasl_bind(bind_dn, 'GSSAPI')
977
+ return true
978
+ rescue
979
+ @@logger.debug "LDAP Error: #{@@conn.err2string(@@conn.err)}"
980
+ @@logger.warn "Warning: SASL GSSAPI authentication failed."
981
+ return false
982
+ end
983
+ end
984
+ return false
985
+ end
986
+
987
+ # base
988
+ #
989
+ # Returns the value of self.class.base
990
+ # This is just syntactic sugar
991
+ def base
992
+ self.class.base
993
+ end
994
+
995
+ # required_classes
996
+ #
997
+ # Returns the value of self.class.required_classes
998
+ # This is just syntactic sugar
999
+ def required_classes
1000
+ self.class.required_classes
1001
+ end
1002
+
1003
+ # dnattr
1004
+ #
1005
+ # Returns the value of self.class.dnattr
1006
+ # This is just syntactic sugar
1007
+ def dnattr
1008
+ self.class.dnattr
1009
+ end
1010
+
1011
+ # attribute_method
1012
+ #
1013
+ # Return the value of the attribute called by method_missing?
1014
+ def attribute_method(method, arrays = false)
1015
+ attr = @attr_methods[method]
1016
+
1017
+ # Return a copy of the stored data
1018
+ return @data[attr].dup if arrays
1019
+ return array_of(@data[attr].dup, false)
1020
+ end
1021
+
1022
+
1023
+ # attribute_method=
1024
+ #
1025
+ # Set the value of the attribute called by method_missing?
1026
+ def attribute_method=(method, arg)
1027
+ # Copy input
1028
+ begin
1029
+ value = arg.dup
1030
+ rescue TypeError
1031
+ value = arg.to_s
1032
+ end
1033
+
1034
+ # Get the attr and clean up the input
1035
+ attr = @attr_methods[method]
1036
+ value = attribute_input_handler(attr, value)
1037
+
1038
+ # Assign the value
1039
+ @data[attr] = value
1040
+
1041
+ # Return a copy of what got saved
1042
+ return @data[attr].dup
1043
+ end
1044
+
1045
+
1046
+ # define_attribute_methods
1047
+ #
1048
+ # Make a method entry for _every_ alias of a valid attribute and map it
1049
+ # onto the first attribute passed in.
1050
+ def define_attribute_methods(attr)
1051
+ if @attr_methods.has_key? attr
1052
+ return
1053
+ end
1054
+ aliases = Base.schema.attribute_aliases(attr)
1055
+ aliases.each do |ali|
1056
+ @attr_methods[ali] = attr
1057
+ end
1058
+ end
1059
+
1060
+ # array_of
1061
+ #
1062
+ # Returns the array form of a value, or not an array if
1063
+ # false is passed in.
1064
+ def array_of(value, to_a = true)
1065
+ if to_a
1066
+ case value.class.to_s
1067
+ when 'Array'
1068
+ return value
1069
+ when 'Hash'
1070
+ return [value]
1071
+ else
1072
+ return [value.to_s]
1073
+ end
1074
+ else
1075
+ case value.class.to_s
1076
+ when 'Array'
1077
+ return nil if value.size == 0
1078
+ return value[0] if value.size == 1
1079
+ return value
1080
+ when 'Hash'
1081
+ return value
1082
+ else
1083
+ return value.to_s
1084
+ end
1085
+ end
1086
+ end
1087
+
1088
+ end # Base
1089
+
1090
+ end # ActiveLDAP
1091
+
1092
+
1093
+
1094
+