File: base.rb

package info (click to toggle)
ruby-activeldap 6.0.3-2
  • links: PTS, VCS
  • area: main
  • in suites: bookworm, bullseye
  • size: 1,588 kB
  • sloc: ruby: 18,143; sh: 12; makefile: 5
file content (726 lines) | stat: -rw-r--r-- 21,650 bytes parent folder | download
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
620
621
622
623
624
625
626
627
628
629
630
631
632
633
634
635
636
637
638
639
640
641
642
643
644
645
646
647
648
649
650
651
652
653
654
655
656
657
658
659
660
661
662
663
664
665
666
667
668
669
670
671
672
673
674
675
676
677
678
679
680
681
682
683
684
685
686
687
688
689
690
691
692
693
694
695
696
697
698
699
700
701
702
703
704
705
706
707
708
709
710
711
712
713
714
715
716
717
718
719
720
721
722
723
724
725
726
require 'timeout'

require 'active_ldap/schema'
require 'active_ldap/entry_attribute'
require 'active_ldap/ldap_error'
require 'active_ldap/supported_control'

module ActiveLdap
  module Adapter
    class Base
      include GetTextSupport

      VALID_ADAPTER_CONFIGURATION_KEYS = [
        :host,
        :port,
        :method,
        :tls_options,
        :timeout,
        :retry_on_timeout,
        :retry_limit,
        :retry_wait,
        :bind_dn,
        :password,
        :password_block,
        :try_sasl,
        :sasl_mechanisms,
        :sasl_quiet,
        :allow_anonymous,
        :store_password,
        :scope,
        :sasl_options,
        :follow_referrals,
        :use_paged_results,
        :page_size,
      ]

      @@row_even = true

      def initialize(configuration={})
        @connection = nil
        @disconnected = false
        @bound = false
        @bind_tried = false
        @entry_attributes = {}
        @follow_referrals = nil
        @page_size = nil
        @configuration = configuration.dup
        @logger = @configuration.delete(:logger)
        @configuration.assert_valid_keys(VALID_ADAPTER_CONFIGURATION_KEYS)
        VALID_ADAPTER_CONFIGURATION_KEYS.each do |name|
          instance_variable_set("@#{name}", configuration[name])
        end
        @follow_referrals = true if @follow_referrals.nil?
        @page_size ||= Configuration::DEFAULT_CONFIG[:page_size]
        @instrumenter = ActiveSupport::Notifications.instrumenter
      end

      def connect(options={})
        host = options[:host] || @host
        method = options[:method] || @method || :plain
        port = options[:port] || @port || ensure_port(method)
        method = ensure_method(method)
        @disconnected = false
        @bound = false
        @bind_tried = false
        @connection, @uri, @with_start_tls = yield(host, port, method)
        prepare_connection(options)
        bind(options)
      end

      def disconnect!(options={})
        unbind(options)
        @connection = @uri = @with_start_tls = nil
        @disconnected = true
      end

      def rebind(options={})
        unbind(options) if bound?
        connect(options)
      end

      def bind(options={})
        @bind_tried = true

        bind_dn = ensure_dn_string(options[:bind_dn] || @bind_dn)
        try_sasl = options.has_key?(:try_sasl) ? options[:try_sasl] : @try_sasl
        if options.has_key?(:allow_anonymous)
          allow_anonymous = options[:allow_anonymous]
        else
          allow_anonymous = @allow_anonymous
        end
        options = options.merge(:allow_anonymous => allow_anonymous)

        # Rough bind loop:
        # Attempt 1: SASL if available
        # Attempt 2: SIMPLE with credentials if password block
        # Attempt 3: SIMPLE ANONYMOUS if 1 and 2 fail (or pwblock returns '')
        if try_sasl and sasl_bind(bind_dn, options)
          @logger.info {_('Bound to %s by SASL as %s') % [target, bind_dn]}
        elsif simple_bind(bind_dn, options)
          @logger.info {_('Bound to %s by simple as %s') % [target, bind_dn]}
        elsif allow_anonymous and bind_as_anonymous(options)
          @logger.info {_('Bound to %s as anonymous') % target}
        else
          message = yield if block_given?
          message ||= _('All authentication methods for %s exhausted.') % target
          raise AuthenticationError, message
        end

        @bound = true
        @bound
      end

      def unbind(options={})
        yield if @connection and (@bind_tried or bound?)
        @bind_tried = @bound = false
      end

      def bind_as_anonymous(options={})
        yield
      end

      def connecting?
        !@connection.nil? and !@disconnected
      end

      def bound?
        connecting? and @bound
      end

      def schema(options={})
        @schema ||= operation(options) do
          base = options[:base]
          attrs = options[:attributes]

          attrs ||= [
            'objectClasses',
            'attributeTypes',
            'matchingRules',
            'matchingRuleUse',
            'dITStructureRules',
            'dITContentRules',
            'nameForms',
            'ldapSyntaxes',
            #'extendedAttributeInfo', # if we need RANGE-LOWER/UPPER.
          ]
          base ||= root_dse_values('subschemaSubentry', options)[0]
          base ||= 'cn=schema'
          schema = nil
          search(:base => base,
                 :scope => :base,
                 :filter => '(objectClass=subschema)',
                 :attributes => attrs,
                 :limit => 1) do |dn, attributes|
            schema = Schema.new(attributes)
          end
          schema || Schema.new([])
        end
      end

      def naming_contexts
        root_dse_values('namingContexts')
      end

      def supported_control
        @supported_control ||=
          SupportedControl.new(root_dse_values("supportedControl"))
      end

      def entry_attribute(object_classes)
        @entry_attributes[object_classes.uniq.sort] ||=
          EntryAttribute.new(schema, object_classes)
      end

      def search(options={})
        base = options[:base]
        base = ensure_dn_string(base)
        attributes = options[:attributes] || []
        attributes = attributes.to_a # just in case
        limit = options[:limit] || 0
        limit = nil if limit <= 0
        use_paged_results = options[:use_paged_results]
        if use_paged_results or use_paged_results.nil?
          use_paged_results = limit != 1 && supported_control.paged_results?
        end
        search_options = {
          base: base,
          scope: ensure_scope(options[:scope] || @scope),
          filter: parse_filter(options[:filter]) || 'objectClass=*',
          attributes: attributes,
          limit: limit,
          use_paged_results: use_paged_results,
          page_size: options[:page_size] || @page_size,
        }

        begin
          operation(options) do
            yield(search_options)
          end
        rescue LdapError::NoSuchObject, LdapError::InvalidDnSyntax => error
          # Do nothing on failure
          @logger.info do
            args = [
              error.class.class,
              error.message,
              search_options[:filter],
              search_options[:attributes].inspect,
            ]
            _("Ignore error %s(%s): filter %s: attributes: %s") % args
          end
        end
      end

      def delete(targets, options={})
        targets = [targets] unless targets.is_a?(Array)
        return if targets.empty?
        begin
          operation(options) do
            targets.each do |target|
              target = ensure_dn_string(target)
              begin
                yield(target)
              rescue LdapError::UnwillingToPerform, LdapError::InsufficientAccess
                raise OperationNotPermitted, _("%s: %s") % [$!.message, target]
              end
            end
          end
        rescue LdapError::NoSuchObject
          raise EntryNotFound, _("No such entry: %s") % target
        end
      end

      def add(dn, entries, options={})
        dn = ensure_dn_string(dn)
        begin
          operation(options) do
            yield(dn, entries)
          end
        rescue LdapError::NoSuchObject
          raise EntryNotFound, _("No such entry: %s") % dn
        rescue LdapError::InvalidDnSyntax
          raise DistinguishedNameInvalid.new(dn)
        rescue LdapError::AlreadyExists
          raise EntryAlreadyExist, _("%s: %s") % [$!.message, dn]
        rescue LdapError::StrongAuthRequired
          raise StrongAuthenticationRequired, _("%s: %s") % [$!.message, dn]
        rescue LdapError::ObjectClassViolation
          raise RequiredAttributeMissed, _("%s: %s") % [$!.message, dn]
        rescue LdapError::UnwillingToPerform
          raise OperationNotPermitted, _("%s: %s") % [$!.message, dn]
        end
      end

      def modify(dn, entries, options={})
        dn = ensure_dn_string(dn)
        begin
          operation(options) do
            begin
              yield(dn, entries)
            rescue LdapError::UnwillingToPerform, LdapError::InsufficientAccess
              raise OperationNotPermitted, _("%s: %s") % [$!.message, target]
            end
          end
        rescue LdapError::UndefinedType
          raise
        rescue LdapError::ObjectClassViolation
          raise RequiredAttributeMissed, _("%s: %s") % [$!.message, dn]
        end
      end

      def modify_rdn(dn, new_rdn, delete_old_rdn, new_superior, options={})
        dn = ensure_dn_string(dn)
        operation(options) do
          yield(dn, new_rdn, delete_old_rdn, new_superior)
        end
      end

      private
      def ensure_port(method)
        if method == :ssl
          URI::LDAPS::DEFAULT_PORT
        else
          URI::LDAP::DEFAULT_PORT
        end
      end

      def follow_referrals?(options={})
        option_follow_referrals = options[:follow_referrals]
        if option_follow_referrals.nil?
          @follow_referrals
        else
          option_follow_referrals
        end
      end

      def prepare_connection(options)
      end

      def operation(options)
        retried = false
        options = options.dup
        options[:try_reconnect] = true unless options.has_key?(:try_reconnect)
        try_reconnect = false
        begin
          reconnect_if_need(options)
          try_reconnect = options[:try_reconnect]
          with_timeout(try_reconnect, options) do
            yield
          end
        rescue ConnectionError
          if try_reconnect and !retried
            retried = true
            @disconnected = true
            retry
          else
            raise
          end
        end
      end

      def need_credential_sasl_mechanism?(mechanism)
        not %(GSSAPI EXTERNAL ANONYMOUS).include?(mechanism)
      end

      def password(bind_dn, options={})
        passwd = options[:password] || @password
        return passwd if passwd

        password_block = options[:password_block] || @password_block
        # TODO: Give a warning to reconnect users with password clearing
        # Get the passphrase for the first time, or anew if we aren't storing
        if password_block.respond_to?(:call)
          passwd = password_block.call(bind_dn)
        else
          @logger.error {_('password_block not nil or Proc object. Ignoring.')}
          return nil
        end

        # Store the password for quick reference later
        if options.has_key?(:store_password)
          store_password = options[:store_password]
        else
          store_password = @store_password
        end
        @password = store_password ? passwd : nil

        passwd
      end

      def with_timeout(try_reconnect=true, options={}, &block)
        n_retries = 0
        retry_limit = options[:retry_limit] || @retry_limit
        begin
          Timeout.timeout(@timeout, &block)
        rescue Timeout::Error => e
          @logger.error {_('Requested action timed out.')}
          if @retry_on_timeout and (retry_limit < 0 or n_retries <= retry_limit)
            n_retries += 1
            if connecting?
              retry
            elsif try_reconnect
              retry if with_timeout(false, options) {reconnect(options)}
            end
          end
          @logger.error {e.message}
          raise TimeoutError, e.message
        end
      end

      def sasl_bind(bind_dn, options={})
        # Get all SASL mechanisms
        mechanisms = operation(options) do
          root_dse_values("supportedSASLMechanisms", options)
        end

        if options.has_key?(:sasl_quiet)
          sasl_quiet = options[:sasl_quiet]
        else
          sasl_quiet = @sasl_quiet
        end

        sasl_mechanisms = options[:sasl_mechanisms] || @sasl_mechanisms
        sasl_mechanisms.each do |mechanism|
          next unless mechanisms.include?(mechanism)
          return true if yield(bind_dn, mechanism, sasl_quiet)
        end
        false
      end

      def simple_bind(bind_dn, options={})
        return false unless bind_dn

        passwd = password(bind_dn, options)
        return false unless passwd

        if passwd.empty?
          if options[:allow_anonymous]
            @logger.info {_("Skip simple bind with empty password.")}
            return false
          else
            raise AuthenticationError,
                  _("Can't use empty password for simple bind.")
          end
        end

        begin
          yield(bind_dn, passwd)
        rescue LdapError::InvalidDnSyntax
          raise DistinguishedNameInvalid.new(bind_dn)
        rescue LdapError::InvalidCredentials
          false
        end
      end

      def parse_filter(filter, operator=nil)
        return nil if filter.nil?
        if !filter.is_a?(String) and !filter.respond_to?(:collect)
          filter = filter.to_s
        end

        case filter
        when String
          parse_filter_string(filter)
        when Hash
          components = filter.sort_by {|k, v| k.to_s}.collect do |key, value|
            construct_component(key, value, operator)
          end
          construct_filter(components, operator)
        else
          operator, components = normalize_array_filter(filter, operator)
          components = construct_components(components, operator)
          construct_filter(components, operator)
        end
      end

      def parse_filter_string(filter)
        if /\A\s*\z/.match(filter)
          nil
        else
          if filter[0, 1] == "("
            filter
          else
            "(#{filter})"
          end
        end
      end

      def normalize_array_filter(filter, operator=nil)
        filter_operator, *components = filter
        if filter_logical_operator?(filter_operator)
          operator = filter_operator
        else
          components.unshift(filter_operator)
          components = [components] unless filter_operator.is_a?(Array)
        end
        [operator, components]
      end

      def extract_filter_value_options(value)
        options = {}
        if value.is_a?(Array)
          case value[0]
          when Hash
            options = value[0]
            value = value[1]
          when "=", "~=", "<=", ">="
            options[:operator] = value[0]
            if value.size > 2
              value = value[1..-1]
            else
              value = value[1]
            end
          end
        end
        [value, options]
      end

      def construct_components(components, operator)
        components.collect do |component|
          if component.is_a?(Array)
            if filter_logical_operator?(component[0])
              parse_filter(component)
            elsif component.size == 2
              key, value = component
              if value.is_a?(Hash)
                parse_filter(value, key)
              else
                construct_component(key, value, operator)
              end
            else
              construct_component(component[0], component[1..-1], operator)
            end
          elsif component.is_a?(Symbol)
            assert_filter_logical_operator(component)
            nil
          else
            parse_filter(component, operator)
          end
        end
      end

      def construct_component(key, value, operator=nil)
        value, options = extract_filter_value_options(value)
        comparison_operator = options[:operator] || "="
        if collection?(value)
          return nil if value.empty?
          operator, value = normalize_array_filter(value, operator)
          values = []
          value.each do |val|
            if collection?(val)
              values.concat(val.collect {|v| [key, comparison_operator, v]})
            else
              values << [key, comparison_operator, val]
            end
          end
          values[0] = values[0][1] if filter_logical_operator?(values[0][1])
          parse_filter(values, operator)
        else
          [
           "(",
           escape_filter_key(key),
           comparison_operator,
           escape_filter_value(value, options),
           ")"
          ].join
        end
      end

      def escape_filter_key(key)
        escape_filter_value(key.to_s)
      end

      def escape_filter_value(value, options={})
        case value
	when Numeric, DN
          value = value.to_s
        when Time
          value = Schema::GeneralizedTime.new.normalize_value(value)
        end
        value.gsub(/(?:[:()\\\0]|\*\*?)/) do |s|
          if s == "*"
            s
          else
            s = "*" if s == "**"
            if s.respond_to?(:getbyte)
              "\\%02X" % s.getbyte(0)
            else
              "\\%02X" % s[0]
            end
          end
        end
      end

      def construct_filter(components, operator=nil)
        operator = normalize_filter_logical_operator(operator)
        components = components.compact
        case components.size
        when 0
          nil
        when 1
          filter = components[0]
          filter = "(!#{filter})" if operator == :not
          filter
        else
          "(#{operator == :and ? '&' : '|'}#{components.join})"
        end
      end

      def collection?(object)
        !object.is_a?(String) and object.respond_to?(:each)
      end

      LOGICAL_OPERATORS = [:and, :or, :not, :&, :|]
      def filter_logical_operator?(operator)
        LOGICAL_OPERATORS.include?(operator)
      end

      def normalize_filter_logical_operator(operator)
        assert_filter_logical_operator(operator)
        case (operator || :and)
        when :and, :&
          :and
        when :or, :|
          :or
        else
          :not
        end
      end

      def assert_filter_logical_operator(operator)
        return if operator.nil?
        unless filter_logical_operator?(operator)
          raise ArgumentError,
                _("invalid logical operator: %s: available operators: %s") %
                  [operator.inspect, LOGICAL_OPERATORS.inspect]
        end
      end

      # Attempts to reconnect up to the number of times allowed
      # If forced, try once then fail with ConnectionError if not connected.
      def reconnect(options={})
        options = options.dup
        force = options[:force]
        retry_limit = options[:retry_limit] || @retry_limit
        retry_wait = options[:retry_wait] || @retry_wait
        options[:reconnect_attempts] ||= 0

        loop do
          @logger.debug {_('Attempting to reconnect')}
          disconnect!

          # Reset the attempts if this was forced.
          options[:reconnect_attempts] = 0 if force
          options[:reconnect_attempts] += 1 if retry_limit >= 0
          begin
            options[:try_reconnect] = false
            connect(options)
            break
          rescue AuthenticationError, Timeout::Error
            raise
          rescue => detail
            @logger.error do
              _("Reconnect to server failed: %s: %s\n" \
                "Reconnect to server failed backtrace:\n" \
                "%s") % [
                detail.class,
                detail.message,
                detail.backtrace.join("\n"),
              ]
            end
            # Do not loop if forced
            raise ConnectionError, detail.message if force
          end

          unless can_reconnect?(options)
            raise ConnectionError,
                  _('Giving up trying to reconnect to LDAP server.')
          end

          # Sleep before looping
          sleep retry_wait
        end

        true
      end

      def reconnect_if_need(options={})
        return if connecting?
        with_timeout(false, options) do
          reconnect(options)
        end
      end

      # Determine if we have exceed the retry limit or not.
      # True is reconnecting is allowed - False if not.
      def can_reconnect?(options={})
        retry_limit = options[:retry_limit] || @retry_limit
        reconnect_attempts = options[:reconnect_attempts] || 0

        retry_limit < 0 or reconnect_attempts <= retry_limit
      end

      def root_dse_values(key, options={})
        dse = root_dse([key], options)
        return [] if dse.nil?
        normalized_key = key.downcase
        dse.each do |_key, _value|
          return _value if _key.downcase == normalized_key
        end
        []
      end

      def root_dse(attrs, options={})
        found_attributes = nil
        if options.has_key?(:try_reconnect)
           try_reconnect = options[:try_reconnect]
        else
           try_reconnect = true
        end

        search(:base => "",
               :scope => :base,
               :attributes => attrs,
               :limit => 1,
               :try_reconnect => try_reconnect) do |dn, attributes|
          found_attributes = attributes
        end
        found_attributes
      end

      def construct_uri(host, port, ssl)
        protocol = ssl ? "ldaps" : "ldap"
        URI.parse("#{protocol}://#{host}:#{port}").to_s
      end

      def target
        return nil if @uri.nil?
        if @with_start_tls
          "#{@uri}(StartTLS)"
        else
          @uri
        end
      end

      def log(name, info=nil)
        result = nil
        payload = {
          :name => name,
          :info => info || {},
        }
        @instrumenter.instrument("log_info.active_ldap", payload) do
          result = yield if block_given?
        end
        result
      end

      def ensure_dn_string(dn)
        if dn.is_a?(DN)
          dn.to_s
        else
          dn
        end
      end
    end
  end
end