ldaptic 0.2.0

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 (40) hide show
  1. data/LICENSE +20 -0
  2. data/README.rdoc +104 -0
  3. data/Rakefile +41 -0
  4. data/lib/ldaptic.rb +151 -0
  5. data/lib/ldaptic/active_model.rb +37 -0
  6. data/lib/ldaptic/adapters.rb +90 -0
  7. data/lib/ldaptic/adapters/abstract_adapter.rb +123 -0
  8. data/lib/ldaptic/adapters/active_directory_adapter.rb +78 -0
  9. data/lib/ldaptic/adapters/active_directory_ext.rb +12 -0
  10. data/lib/ldaptic/adapters/ldap_conn_adapter.rb +262 -0
  11. data/lib/ldaptic/adapters/net_ldap_adapter.rb +173 -0
  12. data/lib/ldaptic/adapters/net_ldap_ext.rb +24 -0
  13. data/lib/ldaptic/attribute_set.rb +283 -0
  14. data/lib/ldaptic/dn.rb +365 -0
  15. data/lib/ldaptic/entry.rb +646 -0
  16. data/lib/ldaptic/error_set.rb +34 -0
  17. data/lib/ldaptic/errors.rb +136 -0
  18. data/lib/ldaptic/escape.rb +110 -0
  19. data/lib/ldaptic/filter.rb +282 -0
  20. data/lib/ldaptic/methods.rb +387 -0
  21. data/lib/ldaptic/railtie.rb +9 -0
  22. data/lib/ldaptic/schema.rb +246 -0
  23. data/lib/ldaptic/syntaxes.rb +319 -0
  24. data/test/core.schema +582 -0
  25. data/test/ldaptic_active_model_test.rb +40 -0
  26. data/test/ldaptic_adapters_test.rb +35 -0
  27. data/test/ldaptic_attribute_set_test.rb +57 -0
  28. data/test/ldaptic_dn_test.rb +110 -0
  29. data/test/ldaptic_entry_test.rb +22 -0
  30. data/test/ldaptic_errors_test.rb +23 -0
  31. data/test/ldaptic_escape_test.rb +47 -0
  32. data/test/ldaptic_filter_test.rb +53 -0
  33. data/test/ldaptic_hierarchy_test.rb +90 -0
  34. data/test/ldaptic_schema_test.rb +44 -0
  35. data/test/ldaptic_syntaxes_test.rb +66 -0
  36. data/test/mock_adapter.rb +47 -0
  37. data/test/rbslapd1.rb +111 -0
  38. data/test/rbslapd4.rb +172 -0
  39. data/test/test_helper.rb +2 -0
  40. metadata +146 -0
@@ -0,0 +1,123 @@
1
+ require 'ldaptic/escape'
2
+ require 'ldaptic/errors'
3
+
4
+ module Ldaptic
5
+ module Adapters
6
+ # Subclasse must implement search, add, modify, delete, and rename. These
7
+ # methods should return 0 on success and non-zero on failure. The failure
8
+ # code is intended to be the server error code. If this is unavailable,
9
+ # return -1.
10
+ class AbstractAdapter
11
+
12
+ # When implementing an adapter, +register_as+ must be called to associate
13
+ # the adapter with a name. The adapter name must mimic the filename.
14
+ # The following might be found in ldaptic/adapters/some_adapter.rb.
15
+ #
16
+ # class SomeAdapter < AbstractAdapter
17
+ # register_as(:some)
18
+ # end
19
+ def self.register_as(name)
20
+ require 'ldaptic/adapters'
21
+ Ldaptic::Adapters.register(name, self)
22
+ end
23
+
24
+ def initialize(options)
25
+ @options = options
26
+ end
27
+
28
+ # The server's RootDSE. +attrs+ is an array specifying which attributes
29
+ # to return.
30
+ def root_dse(attrs = nil)
31
+ result = search(
32
+ :base => "",
33
+ :scope => Ldaptic::SCOPES[:base],
34
+ :filter => "(objectClass=*)",
35
+ :attributes => attrs && [attrs].flatten.map {|a| Ldaptic.encode(a)},
36
+ :disable_pagination => true
37
+ ) { |x| break x }
38
+ return if result.kind_of?(Fixnum)
39
+ if attrs.kind_of?(Array) || attrs.nil?
40
+ result
41
+ else
42
+ result[attrs]
43
+ end
44
+ end
45
+
46
+ def schema(attrs = nil)
47
+ @subschema_dn ||= root_dse(['subschemaSubentry'])['subschemaSubentry'].first
48
+ search(
49
+ :base => @subschema_dn,
50
+ :scope => Ldaptic::SCOPES[:base],
51
+ :filter => "(objectClass=subschema)",
52
+ :attributes => attrs
53
+ ) { |x| return x }
54
+ nil
55
+ end
56
+
57
+ # Returns the first of the +namingContexts+ found in the RootDSE.
58
+ def server_default_base_dn
59
+ unless defined?(@naming_contexts)
60
+ @naming_contexts = root_dse(%w(namingContexts))
61
+ end
62
+ if @naming_contexts
63
+ @naming_contexts["namingContexts"].to_a.first
64
+ end
65
+ end
66
+
67
+ alias default_base_dn server_default_base_dn
68
+
69
+ # Returns a hash of attribute types, keyed by both OID and name.
70
+ def attribute_types
71
+ @attribute_types ||= construct_schema_hash('attributeTypes',
72
+ Ldaptic::Schema::AttributeType)
73
+ end
74
+
75
+ def attribute_type(key = nil)
76
+ if key
77
+ attribute_types[key] || attribute_types.values.detect do |at|
78
+ at.names.map {|n| n.downcase}.include?(key.downcase)
79
+ end
80
+ else
81
+ attribute_types.values.uniq
82
+ end
83
+ end
84
+
85
+ # Returns a hash of DIT content rules, keyed by both OID and name.
86
+ def dit_content_rules
87
+ @dit_content_rules ||= construct_schema_hash('dITContentRules',
88
+ Ldaptic::Schema::DITContentRule)
89
+ end
90
+
91
+ # Returns a hash of object classes, keyed by both OID and name.
92
+ def object_classes
93
+ @object_classes ||= construct_schema_hash('objectClasses',
94
+ Ldaptic::Schema::ObjectClass)
95
+ end
96
+
97
+ # Default compare operation, emulated with a search.
98
+ def compare(dn, attr, value)
99
+ search(:base => dn, :scope => Ldaptic::SCOPES[:base], :filter => "(#{attr}=#{Ldaptic.escape(value)})") { return true }
100
+ false
101
+ end
102
+
103
+ def logger
104
+ @logger || Ldaptic.logger
105
+ end
106
+
107
+ private
108
+
109
+ def construct_schema_hash(element, klass)
110
+ @schema_hash ||= schema(['attributeTypes', 'dITContentRules', 'objectClasses'])
111
+ @schema_hash[element.to_s].to_a.inject({}) do |hash, val|
112
+ object = klass.new(val)
113
+ hash[object.oid] = object
114
+ Array(object.name).each do |name|
115
+ hash[name] = object
116
+ end
117
+ hash
118
+ end
119
+ end
120
+
121
+ end
122
+ end
123
+ end
@@ -0,0 +1,78 @@
1
+ require 'ldaptic/adapters/ldap_conn_adapter'
2
+ require 'ldaptic/adapters/active_directory_ext'
3
+
4
+ module Ldaptic
5
+ module Adapters
6
+ # ActiveDirectoryAdapter is a LDAPConnAdapter with some Active Directory
7
+ # specific behaviors. To help mitigate server timeout issues, this adapter
8
+ # binds on each request and unbinds afterwards. For search requests, the
9
+ # adapter connects to the global catalog on port 3268 instead of the usual
10
+ # port 389. The global catalog is read-only but is a bit more flexible
11
+ # when it comes to searching.
12
+ #
13
+ # Active Directory servers can also be connected to with the Net::LDAP
14
+ # adapter.
15
+ class ActiveDirectoryAdapter < LDAPConnAdapter
16
+ register_as(:active_directory)
17
+
18
+ def initialize(options)
19
+ super
20
+ if @connection
21
+ @options[:connection] = @connection = nil
22
+ end
23
+ end
24
+
25
+ # Returns either the +defaultNamingContext+ (Active Directory specific)
26
+ # or the first of the +namingContexts+ found in the RootDSE.
27
+ def server_default_base_dn
28
+ unless defined?(@naming_contexts)
29
+ @naming_contexts = root_dse(%w(defaultNamingContext namingContexts))
30
+ end
31
+ if @naming_contexts
32
+ @naming_contexts["defaultNamingContext"].to_a.first ||
33
+ @naming_contexts["namingContexts"].to_a.first
34
+ end
35
+ end
36
+
37
+ private
38
+
39
+ def full_username(username)
40
+ if username.kind_of?(Hash)
41
+ super
42
+ elsif username && username !~ /[\\=@]/
43
+ if @options[:domain].include?(".")
44
+ username = [username, @options[:domain]].join("@")
45
+ elsif @options[:domain]
46
+ username = [@options[:domain], username].join("\\")
47
+ else
48
+ conn = new_connection(3268)
49
+ dn = conn.search2("", 0, "(objectClass=*", ['defaultNamingContext']).first['defaultNamingContext']
50
+ if dn
51
+ domain = Ldaptic::DN(dn).rdns.map {|rdn| rdn[:dc]}.compact
52
+ unless domain.empty?
53
+ username = [username, domain.join(".")].join("@")
54
+ end
55
+ end
56
+ end
57
+ end
58
+ username
59
+ end
60
+
61
+ def with_port(port, &block)
62
+ conn = new_connection(port)
63
+ bind_connection(conn, @options[:username], @options[:password]) do
64
+ with_conn(conn, &block)
65
+ end
66
+ end
67
+
68
+ def with_reader(&block)
69
+ with_port(3268, &block)
70
+ end
71
+
72
+ def with_writer(&block)
73
+ with_port(389, &block)
74
+ end
75
+
76
+ end
77
+ end
78
+ end
@@ -0,0 +1,12 @@
1
+ # Converts an integer representing the number of microseconds since January 1,
2
+ # 1600 to a DateTime.
3
+ def DateTime.microsoft(tinies)
4
+ new(1601,1,1).new_offset(Time.now.utc_offset/60/60/24.0) + tinies/1e7/60/60/24
5
+ end
6
+
7
+ # Converts an integer representing the number of microseconds since January 1,
8
+ # 1600 to a Time.
9
+ def Time.microsoft(tinies)
10
+ dt = DateTime.microsoft(tinies)
11
+ Time.local(dt.year,dt.mon,dt.day,dt.hour,dt.min,dt.sec,dt.sec_fraction*60*60*24*1e6)
12
+ end
@@ -0,0 +1,262 @@
1
+ require 'ldaptic/adapters/abstract_adapter'
2
+
3
+ module Ldaptic
4
+ module Adapters
5
+ class LDAPConnAdapter < AbstractAdapter
6
+ register_as(:ldap_conn)
7
+
8
+ def initialize(options)
9
+ require 'ldap'
10
+ if defined?(::LDAP::Conn) && options.kind_of?(::LDAP::Conn)
11
+ options = {:adapter => :ldap_conn, :connection => options}
12
+ else
13
+ options = options.dup
14
+ end
15
+ options[:version] ||= 3
16
+ @options = options
17
+ if @connection = @options.delete(:connection)
18
+ begin
19
+ host, port = @connection.get_option(::LDAP::LDAP_OPT_HOST_NAME).split(':')
20
+ @options[:host] ||= host
21
+ @options[:port] ||= port.to_i if port
22
+ rescue
23
+ end
24
+ else
25
+ if username = @options.delete(:username)
26
+ @options[:username] = full_username(username)
27
+ end
28
+ if @options[:username]
29
+ connection = new_connection
30
+ bind_connection(connection, @options[:username], @options[:password])
31
+ connection.unbind
32
+ end
33
+ end
34
+ @logger = @options.delete(:logger)
35
+ super(@options)
36
+ end
37
+
38
+ def add(dn, attributes)
39
+ with_writer do |conn|
40
+ conn.add(dn, attributes)
41
+ end
42
+ end
43
+
44
+ def modify(dn, attributes)
45
+ if attributes.kind_of?(Array)
46
+ attributes = attributes.map do |(op, key, vals)|
47
+ LDAP::Mod.new(mod(op) | LDAP::LDAP_MOD_BVALUES, key, vals)
48
+ end
49
+ end
50
+ with_writer do |conn|
51
+ conn.modify(dn, attributes)
52
+ end
53
+ end
54
+
55
+ def delete(dn)
56
+ with_writer do |conn|
57
+ conn.delete(dn)
58
+ end
59
+ end
60
+
61
+ def rename(dn, new_rdn, delete_old, new_superior = nil)
62
+ with_writer do |conn|
63
+ if new_superior
64
+ # This is from a patch I hope to get accepted upstream.
65
+ if conn.respond_to?(:rename)
66
+ conn.rename(dn, new_rdn, new_superior, delete_old)
67
+ else
68
+ Ldaptic::Errors.raise(NotImplementedError.new("rename unsupported"))
69
+ end
70
+ else
71
+ conn.modrdn(dn, new_rdn, delete_old)
72
+ end
73
+ end
74
+ end
75
+
76
+ def compare(dn, attr, value)
77
+ with_reader do |conn|
78
+ conn.compare(dn, attr, value)
79
+ end
80
+ rescue Ldaptic::Errors::CompareFalse
81
+ false
82
+ rescue Ldaptic::Errors::CompareTrue
83
+ true
84
+ end
85
+
86
+ def search(options = {}, &block)
87
+ parameters = search_parameters(options)
88
+ with_reader do |conn|
89
+ begin
90
+ if options[:limit]
91
+ # Some servers don't support this option. If that happens, the
92
+ # higher level interface will simulate it.
93
+ conn.set_option(LDAP::LDAP_OPT_SIZELIMIT, options[:limit]) rescue nil
94
+ end
95
+ cookie = ""
96
+ while cookie
97
+ ctrl = paged_results_control(cookie)
98
+ if !options[:disable_pagination] && paged_results?
99
+ conn.set_option(LDAP::LDAP_OPT_SERVER_CONTROLS, [ctrl])
100
+ end
101
+ params = parameters
102
+ result = conn.search2(*params, &block)
103
+ ctrl = conn.controls.detect {|c| c.oid == ctrl.oid}
104
+ cookie = ctrl && ctrl.decode.last
105
+ cookie = nil if cookie.to_s.empty?
106
+ end
107
+ ensure
108
+ conn.set_option(LDAP::LDAP_OPT_SERVER_CONTROLS, []) rescue nil
109
+ conn.set_option(LDAP::LDAP_OPT_SIZELIMIT, 0) rescue nil
110
+ end
111
+ end
112
+ end
113
+
114
+ def authenticate(dn, password)
115
+ conn = new_connection
116
+ bind_connection(conn, full_username(dn) || "", password)
117
+ true
118
+ rescue ::LDAP::ResultError => exception
119
+ message = exception.message
120
+ err = error_for_message(message)
121
+ unless err == 49 # Invalid credentials
122
+ Ldaptic::Errors.raise_unless_zero(err, message)
123
+ end
124
+ false
125
+ ensure
126
+ conn.unbind rescue nil
127
+ end
128
+
129
+ def default_base_dn
130
+ @options[:base] || server_default_base_dn
131
+ end
132
+
133
+ private
134
+
135
+ def paged_results?
136
+ if @paged_results.nil?
137
+ @paged_results = root_dse('supportedControl').to_a.include?(CONTROL_PAGEDRESULTS)
138
+ end
139
+ @paged_results
140
+ end
141
+
142
+ # ::LDAP::LDAP_CONTROL_PAGEDRESULTS,
143
+ CONTROL_PAGEDRESULTS = "1.2.840.113556.1.4.319"
144
+
145
+ def paged_results_control(cookie = "", size = 126)
146
+ require 'ldap/control'
147
+ # values above 126 cause problems for slapd, as determined by net/ldap
148
+ ::LDAP::Control.new(
149
+ CONTROL_PAGEDRESULTS,
150
+ ::LDAP::Control.encode(size, cookie),
151
+ true
152
+ )
153
+ end
154
+
155
+ def search_parameters(options = {})
156
+ case options[:sort]
157
+ when Proc, Method then s_attr, s_proc = nil, options[:sort]
158
+ else s_attr, s_proc = options[:sort], nil
159
+ end
160
+ [
161
+ options[:base],
162
+ options[:scope],
163
+ options[:filter],
164
+ options[:attributes] && Array(options[:attributes]),
165
+ options[:attributes_only],
166
+ options[:timeout].to_i,
167
+ ((options[:timeout].to_f % 1) * 1e6).round,
168
+ s_attr.to_s,
169
+ s_proc
170
+ ]
171
+ end
172
+
173
+ def new_connection(default_port = nil)
174
+ if @options[:tls].nil?
175
+ conn = ::LDAP::Conn.new(
176
+ @options[:host]||"localhost",
177
+ *[@options[:port] || default_port].compact
178
+ )
179
+ else
180
+ conn = ::LDAP::SSLConn.new(
181
+ @options[:host]||"localhost",
182
+ @options[:port] || default_port || ::LDAP::LDAP_PORT,
183
+ @options[:tls]
184
+ )
185
+ end
186
+ conn.set_option(::LDAP::LDAP_OPT_PROTOCOL_VERSION, @options[:version])
187
+ conn
188
+ end
189
+
190
+ def bind_connection(conn, dn, password, &block)
191
+ if dn
192
+ password = password.call if password.respond_to?(:call)
193
+ conn.bind(dn, password, *[@options[:method]].compact, &block)
194
+ else
195
+ block_given? ? yield(conn) : conn
196
+ end
197
+ end
198
+
199
+ def full_username(username)
200
+ if username.kind_of?(Hash)
201
+ base = Ldaptic::DN(default_base_dn || "")
202
+ base / username
203
+ else
204
+ username
205
+ end
206
+ end
207
+
208
+ def with_reader(&block)
209
+ if @connection
210
+ with_conn(@connection, &block)
211
+ else
212
+ conn = new_connection
213
+ bind_connection(conn, @options[:username], @options[:password]) do
214
+ with_conn(conn, &block)
215
+ end
216
+ end
217
+ end
218
+
219
+ alias with_writer with_reader
220
+
221
+ def with_conn(conn, &block)
222
+ err, message, result = 0, nil, nil
223
+ begin
224
+ result = yield conn
225
+ rescue ::LDAP::ResultError => exception
226
+ message = exception.message
227
+ err = error_for_message(message)
228
+ end
229
+ conn_err = conn.err.to_i
230
+ if err.zero? && !conn_err.zero?
231
+ err = conn_err
232
+ message = conn.err2string(err) rescue nil
233
+ end
234
+ Ldaptic::Errors.raise_unless_zero(err, message)
235
+ result
236
+ end
237
+
238
+ # LDAP::Conn only gives us a worthless string rather than a real error
239
+ # code on exceptions.
240
+ def error_for_message(msg)
241
+ unless @errors
242
+ with_reader do |conn|
243
+ @errors = (0..127).inject({}) do |h, err|
244
+ h[conn.err2string(err)] = err; h
245
+ end
246
+ end
247
+ @errors.delete("Unknown error")
248
+ end
249
+ @errors[msg]
250
+ end
251
+
252
+ def mod(symbol)
253
+ {
254
+ :add => LDAP::LDAP_MOD_ADD,
255
+ :replace => LDAP::LDAP_MOD_REPLACE,
256
+ :delete => LDAP::LDAP_MOD_DELETE
257
+ }[symbol]
258
+ end
259
+
260
+ end
261
+ end
262
+ end