ruby-activeldap 0.7.4 → 0.8.0

Sign up to get free protection for your applications and to get access to all the features.
Files changed (74) hide show
  1. data/CHANGES +375 -0
  2. data/COPYING +340 -0
  3. data/LICENSE +58 -0
  4. data/Manifest.txt +33 -0
  5. data/README +63 -0
  6. data/Rakefile +37 -0
  7. data/TODO +31 -0
  8. data/benchmark/bench-al.rb +152 -0
  9. data/lib/{activeldap.rb → active_ldap.rb} +280 -263
  10. data/lib/active_ldap/adaptor/base.rb +29 -0
  11. data/lib/active_ldap/adaptor/ldap.rb +466 -0
  12. data/lib/active_ldap/association/belongs_to.rb +38 -0
  13. data/lib/active_ldap/association/belongs_to_many.rb +40 -0
  14. data/lib/active_ldap/association/collection.rb +80 -0
  15. data/lib/active_ldap/association/has_many.rb +48 -0
  16. data/lib/active_ldap/association/has_many_wrap.rb +56 -0
  17. data/lib/active_ldap/association/proxy.rb +89 -0
  18. data/lib/active_ldap/associations.rb +162 -0
  19. data/lib/active_ldap/attributes.rb +199 -0
  20. data/lib/active_ldap/base.rb +1343 -0
  21. data/lib/active_ldap/callbacks.rb +19 -0
  22. data/lib/active_ldap/command.rb +46 -0
  23. data/lib/active_ldap/configuration.rb +96 -0
  24. data/lib/active_ldap/connection.rb +137 -0
  25. data/lib/{activeldap → active_ldap}/ldap.rb +1 -1
  26. data/lib/active_ldap/object_class.rb +70 -0
  27. data/lib/active_ldap/schema.rb +258 -0
  28. data/lib/{activeldap → active_ldap}/timeout.rb +0 -0
  29. data/lib/{activeldap → active_ldap}/timeout_stub.rb +0 -0
  30. data/lib/active_ldap/user_password.rb +92 -0
  31. data/lib/active_ldap/validations.rb +78 -0
  32. data/rails/plugin/active_ldap/README +54 -0
  33. data/rails/plugin/active_ldap/init.rb +6 -0
  34. data/test/TODO +2 -0
  35. data/test/al-test-utils.rb +337 -0
  36. data/test/command.rb +62 -0
  37. data/test/config.yaml +8 -0
  38. data/test/config.yaml.sample +6 -0
  39. data/test/run-test.rb +17 -0
  40. data/test/test-unit-ext.rb +2 -0
  41. data/test/test_associations.rb +334 -0
  42. data/test/test_attributes.rb +71 -0
  43. data/test/test_base.rb +345 -0
  44. data/test/test_base_per_instance.rb +32 -0
  45. data/test/test_bind.rb +53 -0
  46. data/test/test_callback.rb +35 -0
  47. data/test/test_connection.rb +38 -0
  48. data/test/test_connection_per_class.rb +50 -0
  49. data/test/test_find.rb +36 -0
  50. data/test/test_groupadd.rb +50 -0
  51. data/test/test_groupdel.rb +46 -0
  52. data/test/test_groupls.rb +107 -0
  53. data/test/test_groupmod.rb +51 -0
  54. data/test/test_lpasswd.rb +75 -0
  55. data/test/test_object_class.rb +32 -0
  56. data/test/test_reflection.rb +173 -0
  57. data/test/test_schema.rb +166 -0
  58. data/test/test_user.rb +209 -0
  59. data/test/test_user_password.rb +93 -0
  60. data/test/test_useradd-binary.rb +59 -0
  61. data/test/test_useradd.rb +55 -0
  62. data/test/test_userdel.rb +48 -0
  63. data/test/test_userls.rb +86 -0
  64. data/test/test_usermod-binary-add-time.rb +62 -0
  65. data/test/test_usermod-binary-add.rb +61 -0
  66. data/test/test_usermod-binary-del.rb +64 -0
  67. data/test/test_usermod-lang-add.rb +57 -0
  68. data/test/test_usermod.rb +56 -0
  69. data/test/test_validation.rb +38 -0
  70. metadata +94 -21
  71. data/lib/activeldap/associations.rb +0 -170
  72. data/lib/activeldap/base.rb +0 -1456
  73. data/lib/activeldap/configuration.rb +0 -59
  74. data/lib/activeldap/schema2.rb +0 -217
@@ -0,0 +1,29 @@
1
+ module ActiveLdap
2
+ module Adaptor
3
+ class Base
4
+ def initialize(config={})
5
+ @connection = nil
6
+ @config = config.dup
7
+ @logger = @config.delete(:logger)
8
+ %w(host port method timeout retry_on_timeout
9
+ retry_limit retry_wait bind_dn password
10
+ password_block try_sasl allow_anonymous
11
+ store_password).each do |name|
12
+ instance_variable_set("@#{name}", config[name.to_sym])
13
+ end
14
+ end
15
+
16
+ private
17
+ def with_timeout(try_reconnect=true, &block)
18
+ begin
19
+ Timeout.alarm(@timeout, &block)
20
+ rescue Timeout::Error => e
21
+ @logger.error {'Requested action timed out.'}
22
+ retry if try_reconnect and @retry_on_timeout and reconnect
23
+ @logger.error {e.message}
24
+ raise TimeoutError, e.message
25
+ end
26
+ end
27
+ end
28
+ end
29
+ end
@@ -0,0 +1,466 @@
1
+ require 'ldap'
2
+ require 'ldap/ldif'
3
+ require 'ldap/schema'
4
+
5
+ require 'active_ldap/ldap'
6
+ require 'active_ldap/schema'
7
+
8
+ require 'active_ldap/adaptor/base'
9
+
10
+ class LDAP::Mod
11
+ unless instance_method(:to_s).arity.zero?
12
+ def to_s
13
+ inspect
14
+ end
15
+ end
16
+
17
+ alias_method :_initialize, :initialize
18
+ def initialize(op, type, vals)
19
+ if (LDAP::VERSION.split(/\./).collect {|x| x.to_i} <=> [0, 9, 7]) <= 0
20
+ @op, @type, @vals = op, type, vals # to protect from GC
21
+ end
22
+ _initialize(op, type, vals)
23
+ end
24
+ end
25
+
26
+ module ActiveLdap
27
+ module Adaptor
28
+ class Ldap < Base
29
+ module Method
30
+ class SSL
31
+ def connect(host, port)
32
+ LDAP::SSLConn.new(host, port, false)
33
+ end
34
+ end
35
+
36
+ class TLS
37
+ def connect(host, port)
38
+ LDAP::SSLConn.new(host, port, true)
39
+ end
40
+ end
41
+
42
+ class Plain
43
+ def connect(host, port)
44
+ LDAP::Conn.new(host, port)
45
+ end
46
+ end
47
+ end
48
+
49
+ SCOPE = {
50
+ :base => LDAP::LDAP_SCOPE_BASE,
51
+ :sub => LDAP::LDAP_SCOPE_SUBTREE,
52
+ :one => LDAP::LDAP_SCOPE_ONELEVEL,
53
+ }
54
+
55
+ def connect(options={})
56
+ method = ensure_method(options[:method] || @method)
57
+ host = options[:host] || @host
58
+ port = options[:port] || @port
59
+
60
+ @connection = method.connect(host, port)
61
+ operation(options) do
62
+ @connection.set_option(LDAP::LDAP_OPT_PROTOCOL_VERSION, 3)
63
+ end
64
+ bind(options)
65
+ end
66
+
67
+ def schema(options={})
68
+ @schema ||= operation(options) do
69
+ base = options[:base]
70
+ attrs = options[:attrs]
71
+ sec = options[:sec] || 0
72
+ usec = options[:usec] || 0
73
+
74
+ attrs ||= [
75
+ 'objectClasses',
76
+ 'attributeTypes',
77
+ 'matchingRules',
78
+ 'matchingRuleUse',
79
+ 'dITStructureRules',
80
+ 'dITContentRules',
81
+ 'nameForms',
82
+ 'ldapSyntaxes',
83
+ ]
84
+ key = 'subschemaSubentry'
85
+ base ||= @connection.root_dse([key], sec, usec)[0][key][0]
86
+ base ||= 'cn=schema'
87
+ result = @connection.search2(base, LDAP::LDAP_SCOPE_BASE,
88
+ '(objectClass=subschema)', attrs, false,
89
+ sec, usec).first
90
+ Schema.new(result)
91
+ end
92
+ # rescue
93
+ # raise ConnectionError.new("Unable to retrieve schema from " +
94
+ # "server (#{@method.class.downcase})")
95
+ end
96
+
97
+ def disconnect!(options={})
98
+ return if @connection.nil?
99
+ begin
100
+ unbind(options)
101
+ #rescue
102
+ end
103
+ @connection = nil
104
+ # Make sure it is cleaned up
105
+ # This causes Ruby/LDAP memory corruption.
106
+ # GC.start
107
+ end
108
+
109
+ def unbind(options={})
110
+ return unless bound?
111
+ operation(options) do
112
+ @connection.unbind
113
+ end
114
+ end
115
+
116
+ def rebind(options={})
117
+ unbind(options) if bound?
118
+ connect(options)
119
+ end
120
+
121
+ def bind(options={})
122
+ bind_dn = options[:bind_dn] || @bind_dn
123
+ try_sasl = options.has_key?(:try_sasl) ? options[:try_sasl] : @try_sasl
124
+ if options.has_key?(:allow_anonymous)
125
+ allow_anonymous = options[:allow_anonymous]
126
+ else
127
+ allow_anonymous = @allow_anonymous
128
+ end
129
+
130
+ # Rough bind loop:
131
+ # Attempt 1: SASL if available
132
+ # Attempt 2: SIMPLE with credentials if password block
133
+ # Attempt 3: SIMPLE ANONYMOUS if 1 and 2 fail (or pwblock returns '')
134
+ if try_sasl and sasl_bind(bind_dn, options)
135
+ @logger.info {'Bound SASL'}
136
+ elsif simple_bind(bind_dn, options)
137
+ @logger.info {'Bound simple'}
138
+ elsif allow_anonymous and bind_as_anonymous(options)
139
+ @logger.info {'Bound anonymous'}
140
+ else
141
+ if @connection.err.zero?
142
+ message = 'All authentication methods exhausted.'
143
+ else
144
+ message = LDAP.err2string(@connection.err)
145
+ end
146
+ raise AuthenticationError, message
147
+ end
148
+
149
+ bound?
150
+ end
151
+
152
+ def bind_as_anonymous(options={})
153
+ @logger.info {"Attempting anonymous authentication"}
154
+ operation(options) do
155
+ @connection.bind
156
+ true
157
+ end
158
+ end
159
+
160
+ def connecting?
161
+ not @connection.nil?
162
+ end
163
+
164
+ def bound?
165
+ connecting? and @connection.bound?
166
+ end
167
+
168
+ # search
169
+ #
170
+ # Wraps Ruby/LDAP connection.search to make it easier to search for
171
+ # specific data without cracking open Base.connection
172
+ def search(options={})
173
+ filter = options[:filter] || 'objectClass=*'
174
+ attrs = options[:attributes] || []
175
+ scope = ensure_scope(options[:scope])
176
+ base = options[:base]
177
+ limit = options[:limit] || 0
178
+ limit = nil if limit <= 0
179
+
180
+ values = []
181
+ attrs = attrs.to_a # just in case
182
+
183
+ begin
184
+ operation(options) do
185
+ i = 0
186
+ @connection.search(base, scope, filter, attrs) do |m|
187
+ i += 1
188
+ attributes = {}
189
+ m.attrs.each do |attr|
190
+ attributes[attr] = m.vals(attr)
191
+ end
192
+ value = [m.dn, attributes]
193
+ value = yield(value) if block_given?
194
+ values.push(value)
195
+ break if limit and limit >= i
196
+ end
197
+ end
198
+ rescue LDAP::Error
199
+ # Do nothing on failure
200
+ @logger.debug {"Ignore error #{$!.class}(#{$!.message}) " +
201
+ "for #{filter} and attrs #{attrs.inspect}"}
202
+ rescue RuntimeError
203
+ if $!.message == "no result returned by search"
204
+ @logger.debug {"No matches for #{filter} and attrs " +
205
+ "#{attrs.inspect}"}
206
+ else
207
+ raise
208
+ end
209
+ end
210
+
211
+ values
212
+ end
213
+
214
+ def to_ldif(dn, attributes)
215
+ ldif = LDAP::LDIF.to_ldif("dn", [dn.dup])
216
+ attributes.sort_by do |key, value|
217
+ key
218
+ end.each do |key, values|
219
+ ldif << LDAP::LDIF.to_ldif(key, values)
220
+ end
221
+ ldif
222
+ end
223
+
224
+ def load(ldifs, options={})
225
+ operation(options) do
226
+ ldifs.split(/(?:\r?\n){2,}/).each do |ldif|
227
+ LDAP::LDIF.parse_entry(ldif).send(@connection)
228
+ end
229
+ end
230
+ end
231
+
232
+ def delete(targets, options={})
233
+ targets = [targets] unless targets.is_a?(Array)
234
+ return if targets.empty?
235
+ target = nil
236
+ begin
237
+ operation(options) do
238
+ targets.each do |target|
239
+ @connection.delete(target)
240
+ end
241
+ end
242
+ rescue LDAP::NoSuchObject
243
+ raise EntryNotFound, "No such entry: #{target}"
244
+ end
245
+ end
246
+
247
+ def add(dn, entries, options={})
248
+ begin
249
+ operation(options) do
250
+ @connection.add(dn, parse_entries(entries))
251
+ end
252
+ rescue LDAP::NoSuchObject
253
+ raise EntryNotFound, "No such entry: #{dn}"
254
+ rescue LDAP::InvalidDnSyntax
255
+ raise DistinguishedNameInvalid.new(dn)
256
+ rescue LDAP::AlreadyExists
257
+ raise EntryAlreadyExist, "#{$!.message}: #{dn}"
258
+ rescue LDAP::StrongAuthRequired
259
+ raise StrongAuthenticationRequired, "#{$!.message}: #{dn}"
260
+ rescue LDAP::ObjectClassViolation
261
+ raise RequiredAttributeMissed, "#{$!.message}: #{dn}"
262
+ rescue LDAP::UnwillingToPerform
263
+ raise UnwillingToPerform, "#{$!.message}: #{dn}"
264
+ end
265
+ end
266
+
267
+ def modify(dn, entries, options={})
268
+ begin
269
+ operation(options) do
270
+ @connection.modify(dn, parse_entries(entries))
271
+ end
272
+ rescue LDAP::UndefinedType
273
+ raise
274
+ rescue LDAP::ObjectClassViolation
275
+ raise RequiredAttributeMissed, "#{$!.message}: #{dn}"
276
+ end
277
+ end
278
+
279
+ private
280
+ def operation(options={}, &block)
281
+ reconnect_if_need
282
+ try_reconnect = !options.has_key?(:try_reconnect) ||
283
+ options[:try_reconnect]
284
+ with_timeout(try_reconnect) do
285
+ begin
286
+ block.call
287
+ rescue LDAP::ResultError
288
+ raise *LDAP::err2exception(@connection.err) if @connection.err != 0
289
+ raise
290
+ end
291
+ end
292
+ end
293
+
294
+ def with_timeout(try_reconnect=true, &block)
295
+ begin
296
+ super
297
+ rescue LDAP::ServerDown => e
298
+ @logger.error {"#{e.class} exception occurred in with_timeout block"}
299
+ @logger.error {"Exception message: #{e.message}"}
300
+ @logger.error {"Exception backtrace: #{e.backtrace}"}
301
+ retry if try_reconnect and reconnect
302
+ raise ConnectionError.new(e.message)
303
+ end
304
+ end
305
+
306
+ def ensure_method(method)
307
+ Method.constants.each do |name|
308
+ if method.to_s.downcase == name.downcase
309
+ return Method.const_get(name).new
310
+ end
311
+ end
312
+
313
+ available_methods = Method.constants.collect do |name|
314
+ name.downcase.to_sym.inspect
315
+ end.join(", ")
316
+ raise ConfigurationError,
317
+ "#{method.inspect} is not one of the available connect " +
318
+ "methods #{available_methods}"
319
+ end
320
+
321
+ def ensure_scope(scope)
322
+ value = SCOPE[scope || :sub]
323
+ if value.nil?
324
+ available_scopes = SCOPE.keys.collect {|s| s.inspect}
325
+ raise ArgumentError, "#{scope.inspect} is not one of the available " +
326
+ "LDAP scope #{available_scopes}"
327
+ end
328
+ value
329
+ end
330
+
331
+ # Bind to LDAP with the given DN using any available SASL methods
332
+ def sasl_bind(bind_dn, options={})
333
+ # Get all SASL mechanisms
334
+ #
335
+ mechanisms = nil
336
+ exc = ConnectionError.new('Root DSE query failed')
337
+ mechanisms = operation do
338
+ @connection.root_dse[0]['supportedSASLMechanisms']
339
+ end
340
+
341
+ # Use GSSAPI if available
342
+ # Currently only GSSAPI is supported with Ruby/LDAP from
343
+ # http://caliban.org/files/redhat/RPMS/i386/ruby-ldap-0.8.2-4.i386.rpm
344
+ # TODO: Investigate further SASL support
345
+ return false unless (mechanisms || []).include?('GSSAPI')
346
+ operation do
347
+ @connection.sasl_quiet = @sasl_quiet unless @sasl_quit.nil?
348
+ @connection.sasl_bind(bind_dn, 'GSSAPI')
349
+ true
350
+ end
351
+ end
352
+
353
+ # Bind to LDAP with the given DN and password
354
+ def simple_bind(bind_dn, options={})
355
+ # Bail if we have no password or password block
356
+ if @password.nil? and @password_block.nil?
357
+ @logger.error {'Skipping simple bind: ' +
358
+ '@password_block and @password options are empty.'}
359
+ return false
360
+ end
361
+
362
+ if @password
363
+ password = @password
364
+ else
365
+ # TODO: Give a warning to reconnect users with password clearing
366
+ # Get the passphrase for the first time, or anew if we aren't storing
367
+ unless @password_block.respond_to?(:call)
368
+ @logger.error {'Skipping simple bind: ' +
369
+ '@password_block not nil or Proc object. Ignoring.'}
370
+ return false
371
+ end
372
+ password = @password_block.call(bind_dn)
373
+ end
374
+
375
+ # Store the password for quick reference later
376
+ @password = @store_password ? password : nil
377
+
378
+ begin
379
+ operation do
380
+ @connection.bind(bind_dn, password)
381
+ true
382
+ end
383
+ rescue LDAP::InvalidDnSyntax
384
+ @logger.debug {"DN is invalid: #{bind_dn}"}
385
+ raise DistinguishedNameInvalid.new(bind_dn)
386
+ rescue LDAP::InvalidCredentials
387
+ false
388
+ end
389
+ end
390
+
391
+ def parse_entries(entries)
392
+ result = []
393
+ entries.each do |type, key, attributes|
394
+ mod_type = ensure_mod_type(type)
395
+ binary = schema.binary?(key)
396
+ mod_type |= LDAP::LDAP_MOD_BVALUES if binary
397
+ attributes.each do |name, values|
398
+ result << LDAP.mod(mod_type, name, values)
399
+ end
400
+ end
401
+ result
402
+ end
403
+
404
+ def ensure_mod_type(type)
405
+ case type
406
+ when :replace, :add
407
+ LDAP.const_get("LDAP_MOD_#{type.to_s.upcase}")
408
+ else
409
+ raise ArgumentError, "unknown type: #{type}"
410
+ end
411
+ end
412
+
413
+ # Attempts to reconnect up to the number of times allowed
414
+ # If forced, try once then fail with ConnectionError if not connected.
415
+ def reconnect(options={})
416
+ options = options.dup
417
+ force = options[:force]
418
+ retry_limit = options[:retry_limit] || @retry_limit
419
+ retry_wait = options[:retry_wait] || @retry_wait
420
+ options[:reconnect_attempts] ||= 0
421
+
422
+ loop do
423
+ unless can_reconnect?(options)
424
+ raise ConnectionError,
425
+ 'Giving up trying to reconnect to LDAP server.'
426
+ end
427
+
428
+ @logger.debug {'Attempting to reconnect'}
429
+ disconnect!
430
+
431
+ # Reset the attempts if this was forced.
432
+ options[:reconnect_attempts] = 0 if force
433
+ options[:reconnect_attempts] += 1 if retry_limit >= 0
434
+ begin
435
+ connect(options)
436
+ break
437
+ rescue => detail
438
+ @logger.error {"Reconnect to server failed: #{detail.exception}"}
439
+ @logger.error {"Reconnect to server failed backtrace: " +
440
+ detail.backtrace.join("\n")}
441
+ # Do not loop if forced
442
+ raise ConnectionError, detail.message if force
443
+ end
444
+
445
+ # Sleep before looping
446
+ sleep retry_wait
447
+ end
448
+
449
+ true
450
+ end
451
+
452
+ def reconnect_if_need(options={})
453
+ reconnect(options) if !connecting? and can_reconnect?(options)
454
+ end
455
+
456
+ # Determine if we have exceed the retry limit or not.
457
+ # True is reconnecting is allowed - False if not.
458
+ def can_reconnect?(options={})
459
+ retry_limit = options[:retry_limit] || @retry_limit
460
+ reconnect_attempts = options[:reconnect_attempts] || 0
461
+
462
+ retry_limit < 0 or reconnect_attempts < (retry_limit - 1)
463
+ end
464
+ end
465
+ end
466
+ end