ruby-activeldap 0.4.1

Sign up to get free protection for your applications and to get access to all the features.
@@ -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
+