rom-ldap 0.2.2

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.
Files changed (104) hide show
  1. checksums.yaml +7 -0
  2. data/CHANGELOG.md +251 -0
  3. data/CONTRIBUTING.md +18 -0
  4. data/README.md +172 -0
  5. data/TODO.md +33 -0
  6. data/config/responses.yml +328 -0
  7. data/lib/dry/monitor/ldap/colorizers/default.rb +17 -0
  8. data/lib/dry/monitor/ldap/colorizers/rouge.rb +31 -0
  9. data/lib/dry/monitor/ldap/logger.rb +58 -0
  10. data/lib/rom-ldap.rb +1 -0
  11. data/lib/rom/ldap.rb +22 -0
  12. data/lib/rom/ldap/alias.rb +30 -0
  13. data/lib/rom/ldap/associations.rb +6 -0
  14. data/lib/rom/ldap/associations/core.rb +23 -0
  15. data/lib/rom/ldap/associations/many_to_many.rb +18 -0
  16. data/lib/rom/ldap/associations/many_to_one.rb +22 -0
  17. data/lib/rom/ldap/associations/one_to_many.rb +32 -0
  18. data/lib/rom/ldap/associations/one_to_one.rb +19 -0
  19. data/lib/rom/ldap/associations/self_ref.rb +35 -0
  20. data/lib/rom/ldap/attribute.rb +327 -0
  21. data/lib/rom/ldap/client.rb +185 -0
  22. data/lib/rom/ldap/client/authentication.rb +118 -0
  23. data/lib/rom/ldap/client/operations.rb +233 -0
  24. data/lib/rom/ldap/commands.rb +6 -0
  25. data/lib/rom/ldap/commands/create.rb +41 -0
  26. data/lib/rom/ldap/commands/delete.rb +17 -0
  27. data/lib/rom/ldap/commands/update.rb +35 -0
  28. data/lib/rom/ldap/constants.rb +193 -0
  29. data/lib/rom/ldap/dataset.rb +286 -0
  30. data/lib/rom/ldap/dataset/conversion.rb +62 -0
  31. data/lib/rom/ldap/dataset/dsl.rb +299 -0
  32. data/lib/rom/ldap/dataset/persistence.rb +44 -0
  33. data/lib/rom/ldap/directory.rb +126 -0
  34. data/lib/rom/ldap/directory/capabilities.rb +71 -0
  35. data/lib/rom/ldap/directory/entry.rb +200 -0
  36. data/lib/rom/ldap/directory/env.rb +155 -0
  37. data/lib/rom/ldap/directory/operations.rb +282 -0
  38. data/lib/rom/ldap/directory/password.rb +122 -0
  39. data/lib/rom/ldap/directory/root.rb +187 -0
  40. data/lib/rom/ldap/directory/tokenization.rb +66 -0
  41. data/lib/rom/ldap/directory/transactions.rb +31 -0
  42. data/lib/rom/ldap/directory/vendors/active_directory.rb +129 -0
  43. data/lib/rom/ldap/directory/vendors/apache_ds.rb +27 -0
  44. data/lib/rom/ldap/directory/vendors/e_directory.rb +16 -0
  45. data/lib/rom/ldap/directory/vendors/open_directory.rb +12 -0
  46. data/lib/rom/ldap/directory/vendors/open_dj.rb +25 -0
  47. data/lib/rom/ldap/directory/vendors/open_ldap.rb +35 -0
  48. data/lib/rom/ldap/directory/vendors/three_eight_nine.rb +16 -0
  49. data/lib/rom/ldap/directory/vendors/unknown.rb +22 -0
  50. data/lib/rom/ldap/dsl.rb +76 -0
  51. data/lib/rom/ldap/errors.rb +47 -0
  52. data/lib/rom/ldap/expression.rb +77 -0
  53. data/lib/rom/ldap/expression_encoder.rb +174 -0
  54. data/lib/rom/ldap/extensions.rb +50 -0
  55. data/lib/rom/ldap/extensions/active_support_notifications.rb +26 -0
  56. data/lib/rom/ldap/extensions/compatibility.rb +11 -0
  57. data/lib/rom/ldap/extensions/dsml.rb +165 -0
  58. data/lib/rom/ldap/extensions/msgpack.rb +23 -0
  59. data/lib/rom/ldap/extensions/optimised_json.rb +25 -0
  60. data/lib/rom/ldap/extensions/rails_log_subscriber.rb +38 -0
  61. data/lib/rom/ldap/formatter.rb +26 -0
  62. data/lib/rom/ldap/functions.rb +207 -0
  63. data/lib/rom/ldap/gateway.rb +145 -0
  64. data/lib/rom/ldap/ldif.rb +74 -0
  65. data/lib/rom/ldap/ldif/exporter.rb +77 -0
  66. data/lib/rom/ldap/ldif/importer.rb +95 -0
  67. data/lib/rom/ldap/mapper_compiler.rb +19 -0
  68. data/lib/rom/ldap/matchers.rb +69 -0
  69. data/lib/rom/ldap/message_queue.rb +7 -0
  70. data/lib/rom/ldap/oid.rb +101 -0
  71. data/lib/rom/ldap/parsers/abstract_syntax.rb +91 -0
  72. data/lib/rom/ldap/parsers/attribute.rb +290 -0
  73. data/lib/rom/ldap/parsers/filter_syntax.rb +133 -0
  74. data/lib/rom/ldap/pdu.rb +285 -0
  75. data/lib/rom/ldap/plugin/pagination.rb +145 -0
  76. data/lib/rom/ldap/plugins.rb +7 -0
  77. data/lib/rom/ldap/projection_dsl.rb +38 -0
  78. data/lib/rom/ldap/relation.rb +135 -0
  79. data/lib/rom/ldap/relation/exporting.rb +72 -0
  80. data/lib/rom/ldap/relation/reading.rb +461 -0
  81. data/lib/rom/ldap/relation/writing.rb +64 -0
  82. data/lib/rom/ldap/responses.rb +17 -0
  83. data/lib/rom/ldap/restriction_dsl.rb +45 -0
  84. data/lib/rom/ldap/schema.rb +123 -0
  85. data/lib/rom/ldap/schema/attributes_inferrer.rb +59 -0
  86. data/lib/rom/ldap/schema/dsl.rb +13 -0
  87. data/lib/rom/ldap/schema/inferrer.rb +50 -0
  88. data/lib/rom/ldap/schema/type_builder.rb +133 -0
  89. data/lib/rom/ldap/scope.rb +19 -0
  90. data/lib/rom/ldap/search_request.rb +249 -0
  91. data/lib/rom/ldap/socket.rb +210 -0
  92. data/lib/rom/ldap/tasks/ldap.rake +103 -0
  93. data/lib/rom/ldap/tasks/ldif.rake +80 -0
  94. data/lib/rom/ldap/transaction.rb +29 -0
  95. data/lib/rom/ldap/type_map.rb +88 -0
  96. data/lib/rom/ldap/types.rb +158 -0
  97. data/lib/rom/ldap/version.rb +17 -0
  98. data/lib/rom/plugins/relation/ldap/active_directory.rb +182 -0
  99. data/lib/rom/plugins/relation/ldap/auto_restrictions.rb +69 -0
  100. data/lib/rom/plugins/relation/ldap/e_directory.rb +27 -0
  101. data/lib/rom/plugins/relation/ldap/instrumentation.rb +35 -0
  102. data/lib/rouge/lexers/ldap.rb +72 -0
  103. data/lib/rouge/themes/ldap.rb +49 -0
  104. metadata +231 -0
@@ -0,0 +1,71 @@
1
+ # frozen_string_literal: true
2
+
3
+ module ROM
4
+ module LDAP
5
+ class Directory
6
+
7
+ #
8
+ # Convenience predicates
9
+ #
10
+ module Capabilities
11
+ # Named capabilities
12
+ #
13
+ # @see rom/ldap/constants.rb
14
+ #
15
+ # @return [Array<Symbol>]
16
+ #
17
+ # @api public
18
+ def capabilities
19
+ @capabilities ||= OID.invert.values_at(*supported_controls).compact.freeze
20
+ end
21
+
22
+ # Is the server able to order the entries.
23
+ #
24
+ # @return [Boolean]
25
+ #
26
+ # @api public
27
+ def sortable?
28
+ capabilities.include?(:sort_response)
29
+ end
30
+
31
+ # @return [Boolean]
32
+ #
33
+ # @api public
34
+ def pageable?
35
+ capabilities.include?(:paged_results)
36
+ end
37
+
38
+ # @return [Boolean]
39
+ #
40
+ # @api public
41
+ def chainable?
42
+ capabilities.include?(:matching_rule_in_chain)
43
+ end
44
+
45
+ # @return [Boolean]
46
+ #
47
+ # @api public
48
+ def pruneable?
49
+ capabilities.include?(:delete_tree)
50
+ end
51
+
52
+ # @return [Boolean]
53
+ #
54
+ # @api public
55
+ def bitwise?
56
+ capabilities.include?(:matching_rule_bit_and) &&
57
+ capabilities.include?(:matching_rule_bit_or)
58
+ end
59
+
60
+ # @return [Boolean]
61
+ #
62
+ # @api public
63
+ def i18n?
64
+ capabilities.include?(:language_tag_options) &&
65
+ capabilities.include?(:language_range_options)
66
+ end
67
+ end
68
+
69
+ end
70
+ end
71
+ end
@@ -0,0 +1,200 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'dry/core/cache'
4
+ require 'dry/equalizer'
5
+ require 'rom/initializer'
6
+ require 'rom/support/memoizable'
7
+ require 'rom/ldap/functions'
8
+
9
+ module ROM
10
+ module LDAP
11
+ class Directory
12
+
13
+ # A Hash-like object wrapping the DN and attributes returned by the server.
14
+ # Contains the canonical attributes hash and a formatted version.
15
+ # BER format converted to primitive String ensures clean output in #to_yaml.
16
+ # Accessed when iterating over dataset during #modify and #delete.
17
+ # Exposes methods #fetch, #first, #each_value and #include?.
18
+ # All other method calls are forwarded to the formatted tuple.
19
+ #
20
+ # @see Directory#query
21
+ #
22
+ # @api public
23
+ class Entry
24
+
25
+ extend Initializer
26
+ extend Dry::Core::Cache
27
+
28
+ # Uses Dry::Equalizer
29
+ # @!parse
30
+ # include Dry::Equalizer
31
+ include Dry::Equalizer(:dn, :attributes, :canonical, :formatted)
32
+
33
+ include Memoizable
34
+
35
+ # @see Dataset::Persistence
36
+ #
37
+ # @!attribute [r] dn
38
+ # @return [String] Distinguished Name
39
+ #
40
+ # @api public
41
+ param :dn, proc(&:to_s), type: Types::Strict::String
42
+
43
+ # @!attribute [r] attributes
44
+ # @return [Array<Array>]
45
+ #
46
+ # @api private
47
+ param :attributes, type: Types::Strict::Array, reader: :private
48
+
49
+ # Retrieve values for a given attribute.
50
+ #
51
+ # @see Directory::Root
52
+ #
53
+ # @return [Array<String>]
54
+ #
55
+ # @param key [String, Symbol]
56
+ #
57
+ def fetch(key)
58
+ formatted.fetch(rename(key), canonical[key])
59
+ end
60
+ alias_method :[], :fetch
61
+
62
+ # Find the first (only) value for an attribute.
63
+ #
64
+ # @see Directory::Root
65
+ #
66
+ # @param [Symbol, String] key Attribute name.
67
+ #
68
+ # @return [String]
69
+ #
70
+ def first(key)
71
+ fetch(key)&.first
72
+ end
73
+
74
+ # Iterate over the values of a given attribute.
75
+ #
76
+ # @param key [Symbol] canonical attribute name
77
+ #
78
+ # @example
79
+ #
80
+ # entry.each_value(:object_class, &:to_sym)
81
+ # entry.each_value(:object_class) { |o| o.to_sym }
82
+ #
83
+ def each_value(key, &block)
84
+ fetch(key).map(&block)
85
+ end
86
+
87
+ # Mostly used by the test suite.
88
+ #
89
+ # @example
90
+ #
91
+ # expect(relation.first).to include(attr: %w[val1 val2])
92
+ #
93
+ # @param tuple [Hash] keys and array of values
94
+ #
95
+ # @return [Boolean]
96
+ #
97
+ def include?(tuple)
98
+ tuple.flat_map { |attr, vals| vals.map { |v| fetch(attr).include?(v) } }.all?
99
+ rescue NoMethodError
100
+ false
101
+ end
102
+
103
+ # Compatibility method with Ruby < 2.5
104
+ #
105
+ # @see Relation::Reading#pluck
106
+ #
107
+ # @return [Hash]
108
+ #
109
+ def slice(*keys)
110
+ formatted.select { |k, _v| keys.include?(k) }
111
+ end
112
+
113
+ # Defer to enumerable hash methods before entry values.
114
+ #
115
+ def method_missing(meth, *args, &block)
116
+ formatted.send(meth, *args, &block) if formatted.respond_to?(meth) || super
117
+ end
118
+
119
+ # @return [String]
120
+ #
121
+ def inspect
122
+ %(#<#{self.class} #{dn.empty? ? 'rootDSE' : dn} />)
123
+ end
124
+
125
+ private
126
+
127
+ # @param meth [Symbol]
128
+ #
129
+ # @return [Boolean]
130
+ #
131
+ # @api private
132
+ def respond_to_missing?(meth, include_private = false)
133
+ formatted.respond_to?(meth) || super
134
+ end
135
+
136
+ # Cache renamed key to improve performance two fold in benchmarks.
137
+ #
138
+ # @param key [String, Symbol]
139
+ #
140
+ # @api private
141
+ def rename(key)
142
+ fetch_or_store(key) { LDAP.formatter[key] }
143
+ end
144
+
145
+ # Convert keys of the canonical tuple using the chosen formatting proc.
146
+ #
147
+ # @api private
148
+ def formatted
149
+ Functions[:map_keys, LDAP.formatter][canonical]
150
+ end
151
+
152
+ # DN combined with attributes array
153
+ #
154
+ # @return [Array]
155
+ #
156
+ # @api private
157
+ def with_dn
158
+ attributes.dup.sort.unshift(['dn', dn])
159
+ end
160
+
161
+ # Create canonical tuple
162
+ #
163
+ # @example
164
+ #
165
+ # # => { 'dn' => [''], 'objectClass' => ['', ''] }
166
+ #
167
+ # @return [Hash] canonical camelCase keys ordered alphabetically
168
+ #
169
+ # @see Dataset#export
170
+ #
171
+ # @api private
172
+ def canonical
173
+ stringify_keys[stringify_values[with_dn]]
174
+ end
175
+
176
+ # Covert hash keys to strings.
177
+ #
178
+ # @return [Proc]
179
+ #
180
+ # @api private
181
+ def stringify_keys
182
+ Functions[:map_keys, Functions[:to_string]]
183
+ end
184
+
185
+ # Convert hash whose values are arrays of strings.
186
+ #
187
+ # @return [Proc]
188
+ #
189
+ # @api private
190
+ def stringify_values
191
+ Functions[:map_values, Functions[:map_array, Functions[:to_string]]]
192
+ end
193
+
194
+ memoize :canonical, :formatted
195
+
196
+ end
197
+
198
+ end
199
+ end
200
+ end
@@ -0,0 +1,155 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'rom/initializer'
4
+ require 'uri'
5
+
6
+ module ROM
7
+ module LDAP
8
+ class Directory
9
+
10
+ # Parse uri and options received by the gateway configuration.
11
+ #
12
+ # @example
13
+ # scheme:// binddn : passwd @ host : port / base
14
+ # ldap://uid=admin,ou=system:secret@localhost:1389/ou=users,dc=rom,dc=ldap
15
+ #
16
+ # @see https://ldapwiki.com/wiki/LDAP%20URL
17
+ # @see https://docs.oracle.com/cd/E19957-01/816-6402-10/url.htm
18
+ #
19
+ # rubocop:disable Lint/UriEscapeUnescape
20
+ #
21
+ # @api private
22
+ class ENV
23
+
24
+ extend Initializer
25
+
26
+ param :connection,
27
+ reader: true,
28
+ type: Types::URI,
29
+ default: -> { ::ENV.fetch('LDAPURI', default_connection) }
30
+
31
+ param :config,
32
+ reader: :private,
33
+ type: Types::Strict::Hash,
34
+ default: -> { EMPTY_OPTS }
35
+
36
+ # Build LDAP URL with encoded spaces.
37
+ #
38
+ # @return [URI::LDAP, URI::LDAPS]
39
+ #
40
+ # @raise URI::InvalidURIError
41
+ def uri
42
+ URI(connection.gsub(SPACE, PERCENT_SPACE))
43
+ end
44
+
45
+ # Global search base. The value is derived in this order:
46
+ # 1. Gateway Options
47
+ # 2. ENVs
48
+ # 3. URI (unless this is a socket) defaults ""
49
+ # 4. an empty string
50
+ #
51
+ # @example
52
+ # 'ldap://localhost/ou=users,dc=rom,dc=ldap' => ou=users,dc=rom,dc=ldap
53
+ #
54
+ # @return [String]
55
+ #
56
+ def base
57
+ config.fetch(:base) do
58
+ ::ENV['LDAPBASE'] || (path ? EMPTY_STRING : uri.dn.to_s)
59
+ end
60
+ end
61
+
62
+ # Username and password.
63
+ #
64
+ # @return [Hash, NilClass]
65
+ #
66
+ def auth
67
+ { username: bind_dn, password: bind_pw } if bind_dn
68
+ end
69
+
70
+ # @return [Hash, NilClass]
71
+ #
72
+ def ssl
73
+ config[:ssl] if uri.scheme.eql?('ldaps')
74
+ end
75
+
76
+ # @return [Hash]
77
+ #
78
+ def to_h
79
+ { host: host, port: port, path: path, ssl: ssl, auth: auth }
80
+ end
81
+
82
+ # @return [String]
83
+ #
84
+ def inspect
85
+ "<#{self.class.name} #{connection} />"
86
+ end
87
+
88
+ private
89
+
90
+ # @return [String, NilClass]
91
+ #
92
+ def path
93
+ uri.path unless uri.host
94
+ end
95
+
96
+ # @return [String, NilClass]
97
+ #
98
+ def host
99
+ uri.host unless path
100
+ end
101
+
102
+ # @return [Integer, NilClass]
103
+ #
104
+ def port
105
+ uri.port unless path
106
+ end
107
+
108
+ # Override LDAPURI user with options or LDAPBINDDN.
109
+ # Percent decode the URI's user value.
110
+ #
111
+ # @return [String, NilClass]
112
+ #
113
+ def bind_dn
114
+ dn = config.fetch(:username, ::ENV['LDAPBINDDN']) || uri.user
115
+ dn.gsub(PERCENT_SPACE, SPACE) if dn
116
+ end
117
+ # rubocop:enable Lint/UriEscapeUnescape
118
+
119
+ # Override LDAPURI password with options or LDAPBINDPW
120
+ #
121
+ # @return [String, NilClass]
122
+ #
123
+ def bind_pw
124
+ config.fetch(:password, ::ENV['LDAPBINDPW']) || uri.password
125
+ end
126
+
127
+ # LDAPHOST or localhost
128
+ #
129
+ # @return [String]
130
+ #
131
+ def default_host
132
+ ::ENV.fetch('LDAPHOST', 'localhost')
133
+ end
134
+
135
+ # LDAPPORT or 389
136
+ #
137
+ # @return [Integer]
138
+ #
139
+ def default_port
140
+ ::ENV.fetch('LDAPPORT', 389)
141
+ end
142
+
143
+ # Fallback connection scheme is "ldap://"
144
+ #
145
+ # @return [String]
146
+ #
147
+ def default_connection
148
+ "ldap://#{default_host}:#{default_port}"
149
+ end
150
+
151
+ end
152
+
153
+ end
154
+ end
155
+ end
@@ -0,0 +1,282 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'dry/core/cache'
4
+
5
+ module ROM
6
+ module LDAP
7
+ class Directory
8
+
9
+ module Operations
10
+ def self.included(klass)
11
+ klass.class_eval do
12
+ extend Dry::Core::Cache
13
+ end
14
+ end
15
+
16
+ # Use connection to communicate with server.
17
+ # If :base is passed, it overwrites the default base keyword.
18
+ #
19
+ # @option :filter [String, Array] AST or LDAP string.
20
+ # Defaults to class attribute.
21
+ #
22
+ # @param options [Hash] @see Connection::SearchRequest
23
+ #
24
+ # @return [Array<Entry>] Formatted hash like objects.
25
+ #
26
+ # @api public
27
+ #
28
+ def query(filter: DEFAULT_FILTER, **options)
29
+ set, counter = [], 0
30
+
31
+ # TODO: pageable and search referrals
32
+ params = {
33
+ base: base,
34
+ expression: to_expression(filter),
35
+ **options
36
+ # paged: pageable?
37
+
38
+ # return_refs: true
39
+ # https://tools.ietf.org/html/rfc4511#section-4.5.3
40
+ }
41
+
42
+ # pdu = client.search(params) do |search_referrals: |
43
+ pdu = client.search(params) do |dn, attributes|
44
+ counter += 1
45
+ logger.debug("#{counter}: #{dn}") if ::ENV['DEBUG']
46
+
47
+ set << entity = Entry.new(dn, attributes)
48
+ yield(entity) if block_given?
49
+ end
50
+
51
+ debug(pdu)
52
+
53
+ set
54
+ end
55
+
56
+ def debug(pdu)
57
+ return unless ::ENV['DEBUG']
58
+
59
+ logger.debug(pdu.advice) if pdu&.advice
60
+ logger.debug(pdu.message) if pdu&.message
61
+ logger.debug(pdu.info) if pdu&.failure?
62
+ end
63
+
64
+ # Return all attributes for a distinguished name.
65
+ #
66
+ # @param dn [String]
67
+ #
68
+ # @return [Array<Entry>]
69
+ #
70
+ # @api public
71
+ def by_dn(dn)
72
+ raise(DistinguishedNameError, 'DN is required') unless dn
73
+
74
+ query(base: dn, max: 1, attributes: ALL_ATTRS)
75
+ end
76
+
77
+ #
78
+ #
79
+ # @option :filter [String]
80
+ # @option :password [String]
81
+ #
82
+ # @return [Boolean]
83
+ #
84
+ # @api public
85
+ def bind_as(filter:, password:)
86
+ if (entity = query(filter: filter, max: 1).first)
87
+ password = password.call if password.respond_to?(:call)
88
+
89
+ pdu = client.bind(username: entity.dn, password: password)
90
+ pdu.success?
91
+ else
92
+ false
93
+ end
94
+ rescue BindError
95
+ false
96
+ end
97
+
98
+ # Used by gateway[filter] to infer schema at boot.
99
+ # Limited to 1000 and cached.
100
+ #
101
+ # @param filter [String] dataset schema filter
102
+ #
103
+ # @return [Array<Entry>]
104
+ #
105
+ # @api public
106
+ def query_attributes(filter)
107
+ fetch_or_store(base, filter) do
108
+ query(
109
+ filter: filter,
110
+ base: base,
111
+ max: 1_000, # attribute sample size
112
+ attributes_only: true
113
+ # paged: false
114
+ )
115
+ end
116
+ end
117
+
118
+ # Count all entries under the search base.
119
+ #
120
+ # @return [Integer]
121
+ #
122
+ # @api public
123
+ def base_total
124
+ query(base: base, attributes: %w[objectClass], attributes_only: true).count
125
+ end
126
+
127
+ #
128
+ # @param tuple [Hash] tuple using formatted attribute names.
129
+ #
130
+ # @return [Entry, FalseClass] created LDAP entry or false.
131
+ #
132
+ # @api public
133
+ def add(tuple)
134
+ dn = tuple.delete(:dn)
135
+ attrs = canonicalise(tuple)
136
+ raise(DistinguishedNameError, 'DN is required') unless dn
137
+
138
+ log(__callee__, dn)
139
+
140
+ pdu = client.add(dn: dn, attrs: attrs)
141
+
142
+ pdu.success? ? find(dn) : pdu.success?
143
+ end
144
+
145
+ # client#rename > client#password_modify > client#update
146
+ #
147
+ # @param dn [String] distinguished name.
148
+ #
149
+ # @param tuple [Hash] tuple using formatted attribute names.
150
+ #
151
+ # @return [Entry, FalseClass] updated LDAP entry or false.
152
+ #
153
+ # @api public
154
+ def modify(dn, tuple)
155
+ log(__callee__, dn)
156
+
157
+ # entry = find(dn)
158
+
159
+ new_dn = tuple.delete(:dn)
160
+ attrs = canonicalise(tuple)
161
+
162
+ rdn_attr, rdn_val = get_rdn(dn).split('=')
163
+
164
+ # 1. Move rename
165
+ if new_dn
166
+ new_rdn = get_rdn(new_dn)
167
+ parent = get_parent_dn(new_dn)
168
+
169
+ new_rdn_attr, new_rdn_val = new_rdn.split('=')
170
+
171
+ replace = rdn_attr.eql?(new_rdn_attr)
172
+
173
+ pdu = client.rename(dn: dn, rdn: new_rdn, replace: replace, superior: parent)
174
+
175
+ if pdu.success?
176
+ dn, rdn_attr, rdn_val = new_dn, new_rdn_attr, new_rdn_val
177
+ end
178
+ end
179
+
180
+ # 2. Change password
181
+ if attrs.key?('userPassword')
182
+ new_pwd = attrs.delete('userPassword')
183
+ entry = find(dn)
184
+ old_pwd = entry['userPassword']
185
+
186
+ pdu = client.password_modify(dn, old_pwd: old_pwd, new_pwd: new_pwd)
187
+ end
188
+
189
+ # 3. Edit attributes
190
+ unless attrs.empty?
191
+
192
+ # Adding to RDN values?
193
+ if attrs.key?(rdn_attr) && !attrs.key?(rdn_val)
194
+ attrs[rdn_attr] = Array(attrs[rdn_attr]).unshift(rdn_val)
195
+ end
196
+
197
+ pdu = client.update(dn: dn, ops: attrs.to_a)
198
+ end
199
+
200
+ pdu.success? ? find(dn) : pdu.success?
201
+ end
202
+
203
+ # Tuple(s) by dn
204
+ #
205
+ # @param dn [String] distinguished name
206
+ #
207
+ # @return [Array<Hash>,Hash]
208
+ #
209
+ # @raise [DistinguishedNameError] DN not found
210
+ #
211
+ def find(dn)
212
+ entry = by_dn(dn)
213
+ raise(DistinguishedNameError, 'DN not found') unless entry
214
+
215
+ entry.one? ? entry.first : entry
216
+ end
217
+
218
+ #
219
+ # @param dn [String] distinguished name.
220
+ #
221
+ # @return [Entry, FalseClass] deleted LDAP entry or false.
222
+ #
223
+ # @api public
224
+ def delete(dn)
225
+ log(__callee__, dn)
226
+ entry = find(dn)
227
+
228
+ pdu = if pruneable?
229
+ controls = [OID[:delete_tree]]
230
+ client.delete(dn: dn, controls: controls)
231
+ else
232
+ client.delete(dn: dn)
233
+ end
234
+
235
+ pdu.success? ? entry : pdu.success?
236
+ end
237
+
238
+ private
239
+
240
+ # RDN - relative distinguished name
241
+ #
242
+ # @return [String]
243
+ #
244
+ # @api private
245
+ def get_rdn(dn)
246
+ dn.split(',')[0]
247
+ end
248
+
249
+ # Parent DN
250
+ #
251
+ # @return [String]
252
+ #
253
+ # @api private
254
+ def get_parent_dn(dn)
255
+ dn.split(',')[1..-1].join(',')
256
+ end
257
+
258
+ # Log operation attempt
259
+ #
260
+ def log(method, dn)
261
+ logger.debug("#{self.class}##{method} '#{dn}'")
262
+ end
263
+
264
+ # Rename the formatted keys of the incoming tuple to their original
265
+ # server-side format.
266
+ #
267
+ # @note Used by Directory#add and Directory#modify
268
+ #
269
+ # @param tuple [Hash]
270
+ #
271
+ # @example
272
+ # # => canonicalise(population_count: 0) => { 'populationCount' => 0 }
273
+ #
274
+ # @api private
275
+ def canonicalise(tuple)
276
+ Functions[:tuplify].call(tuple, key_map)
277
+ end
278
+ end
279
+
280
+ end
281
+ end
282
+ end