lom 0.9.0

Sign up to get free protection for your applications and to get access to all the features.
checksums.yaml ADDED
@@ -0,0 +1,7 @@
1
+ ---
2
+ SHA256:
3
+ metadata.gz: 383b6cdf0a55888e40558ca5b24f46a7f5e5d033f27fc873e4625c42b385c571
4
+ data.tar.gz: cef1eef2f7ee9b2b39118e9f54b2d15f1ba5d35518ed04e98cb09a047cbbb34e
5
+ SHA512:
6
+ metadata.gz: dd9eab41714ae145477e2756300b0041dd2d0a12f90485a3997e95266bbb121a33e8a3ed3660d8fd14e7372fd0cfa0fca6da6f800f61599a1c995da221b67b4f
7
+ data.tar.gz: f435226cb86088cfa48db588b105396b79fc0172eec4a967168b507dffb1039f201f6d7261d371edf63883c7fe519ca0d6167c649259f282a7d38e592ee929e1
data/README.md ADDED
@@ -0,0 +1,152 @@
1
+ LDAP Object Mapper
2
+ ==================
3
+
4
+ Allow to map LDAP object to ruby object.
5
+
6
+ It is best used with dry-struct and dry-struct-setters libraries
7
+
8
+
9
+ Examples
10
+ ========
11
+
12
+ ~~~ruby
13
+ require 'net/ldap'
14
+ require 'lom/ldap'
15
+
16
+ using LOM::LDAP::Extensions
17
+
18
+ # Define LDAP handler used by LOM
19
+ LH = Net::LDAP.connect('ldap://127.0.0.1')
20
+ LH.auth 'uid=root,ou=Admins,dc=example,dc=com', 'foobar'
21
+ ~~~
22
+
23
+ ~~~ruby
24
+ # Defining mapping between LDAP and ruby using Dry::Struct
25
+ #
26
+ class User < Dry::Struct
27
+ include Dry::Struct::Setters
28
+ using LOM::LDAP::Extensions
29
+
30
+ ADMINS_BRANCH = 'ou=Admins,dc=example,dc=com'
31
+ TEAMS_BRANCH = 'ou=Team,dc=example,dc=com'
32
+
33
+ #
34
+ # Defining LDAP mapping
35
+ #
36
+ extend LOM::Mapper
37
+
38
+ ldap_branch "ou=People,dc=example,dc=com"
39
+ ldap_filter '(objectClass=inetOrgPerson)'
40
+ ldap_attrs '*', '+'
41
+ ldap_prefix :uid
42
+
43
+ ldap_from do
44
+ {
45
+ :firstname => first(:givenName, String ),
46
+ :lastname => first(:sn, String ),
47
+ :email => first(:mail, String ),
48
+ :homepage => first(:labeledURI, String ),
49
+ :address => first(:postalAddress, String ),
50
+ :title => first(:title, String ),
51
+ :type => all(:objectClass, String )
52
+ .map(&:downcase)
53
+ .include?('posixaccount') ? :full : :minimal,
54
+ :login => first(:uid, String ),
55
+ :password => nil,
56
+ :managers => all(:manager, String )
57
+ .map {|m| User.ldap_dn_to_id(m) },
58
+ :locked => first(:pwdAccountLockedTime, Time ),
59
+ :uid => first(:uidNumber, Integer ),
60
+ :gid => first(:gidNumber, Integer ),
61
+ :home => first(:homeDirectory, String ),
62
+ :teams => all(:memberOf, String ).map{|m|
63
+ LOM.id_from_dn(m, TEAMS_BRANCH, :cn)
64
+ }.compact,
65
+ }.compact
66
+ end
67
+
68
+ ldap_to do
69
+ oclass = [ 'inetOrgPerson' ]
70
+ if type == :full
71
+ oclass += [ 'posixAccount', 'sambaSamAccount', 'pwdPolicy' ]
72
+ { :gecos => fullname,
73
+ :loginShell => '/bin/bash'
74
+ }
75
+ end
76
+
77
+ { :givenName => firstname,
78
+ :sn => lastname,
79
+ :cn => fullname,
80
+ :mail => email,
81
+ :labeledURI => homepage,
82
+ :postalAddress => address,
83
+ :title => title,
84
+ :uid => login,
85
+ :manager => managers.map {|m| User.ldap_dn_from_id(m) },
86
+ :pwdAccountLockedTime => locked,
87
+ :uidNumber => uid,
88
+ :gidNumber => gid,
89
+ :homeDirectory => home.to_s,
90
+ }
91
+ end
92
+
93
+ ldap_list :locked, ->(predicate=true) do
94
+ Filtered.exists(:pwdAccountLockedTime, predicate: predicate)
95
+ end
96
+
97
+ ldap_list :manager, ->(manager) do
98
+ Filtered.has(:manager, manager) {|m|
99
+ case m
100
+ when true, nil then Filtered::ANY
101
+ when false, :none then Filtered::NONE
102
+ else User.ldap_dn_from_id(m.to_str)
103
+ end
104
+ }
105
+ end
106
+
107
+
108
+ #
109
+ # Object structure
110
+ #
111
+
112
+ transform_keys(&:to_sym)
113
+
114
+ attribute :firstname, Types::String
115
+ attribute :lastname, Types::String
116
+ attribute :email, Types::EMail
117
+ attribute? :homepage, Types::WebPage.optional
118
+ attribute? :address, Types::String.optional
119
+ attribute :title, Types::String
120
+ attribute :type, Types::Symbol.enum(:minimal, :full)
121
+ attribute :login, Types::Login
122
+ attribute? :password, Types::Password.optional
123
+ attribute? :managers, Types::Array.of(Types::Login)
124
+ attribute? :locked, Types::Time.optional
125
+ attribute? :uid, Types::Integer
126
+ attribute? :gid, Types::Integer
127
+ attribute? :home, Types::Pathname
128
+ attribute :teams, Types::Array.of(Types::Team)
129
+
130
+ # Various User representation that can be used in processing
131
+ # as string, in sql statement, as JSON
132
+ def to_s ; self.login ; end
133
+ def to_str ; self.login ; end
134
+ def sql_literal(ds) ; ds.literal(self.login) ; end
135
+ def to_json(*a) ; self.to_hash.compact.to_json(*a) ; end
136
+
137
+ # User full name.
138
+ def fullname
139
+ [ firstname, lastname ].join(' ')
140
+ end
141
+ end
142
+ ~~~
143
+
144
+
145
+ ~~~ruby
146
+ # Return user id of users for which account has been locked and
147
+ # with "John Doe" as manager
148
+ User.locked(true).manager('jdoe').list
149
+
150
+ # Return list of users (as User instance) without managers
151
+ User.manager(false).all
152
+ ~~~
data/lib/lom.rb ADDED
@@ -0,0 +1,5 @@
1
+ require_relative 'lom/core'
2
+ require_relative 'lom/ldap'
3
+ require_relative 'lom/handler'
4
+ require_relative 'lom/mapper'
5
+
data/lib/lom/core.rb ADDED
@@ -0,0 +1,49 @@
1
+ require_relative 'version'
2
+
3
+ class LOM
4
+ # Standard Error
5
+ class Error < StandardError
6
+ end
7
+
8
+ # Entry not found
9
+ class EntryNotFound < Error
10
+ end
11
+
12
+ # Mapping error
13
+ class MappingError < Error
14
+ end
15
+
16
+ # Conversion error
17
+ class ConvertionError < Error
18
+ end
19
+
20
+
21
+ # Time format used in ldap
22
+ TIME_FORMAT = "%Y%m%d%H%M%SZ"
23
+
24
+
25
+ # Convert a Date/Time object to an ldap string representation
26
+ #
27
+ # @param [Date, Time] ts
28
+ #
29
+ # @return [String] string representation of time in ldap
30
+ #
31
+ def self.to_ldap_time(ts)
32
+ case ts
33
+ when Date, Time then ts.strftime(TIME_FORMAT)
34
+ when nil then nil
35
+ else raise ArgumentError
36
+ end
37
+ end
38
+
39
+ # Get debugging mode
40
+ def self.debug
41
+ @@debug ||= []
42
+ end
43
+
44
+ # Set debugging mode
45
+ # @param [Array<:dry,:verbose>] debugging options
46
+ def self.debug=(v)
47
+ @@debug = v
48
+ end
49
+ end
@@ -0,0 +1,171 @@
1
+ require 'date'
2
+ require_relative 'ldap/converters'
3
+ require_relative 'ldap/extensions'
4
+
5
+ class LOM
6
+
7
+ class Filtered
8
+ include Enumerable
9
+
10
+ using LDAP::Extensions
11
+ using LDAP::Converters
12
+
13
+ NONE = Object.new.freeze
14
+ ANY = Object.new.freeze
15
+
16
+ def initialize(src, filter = nil, paged: nil)
17
+ @src = src
18
+ @filter = filter
19
+ @paged = paged
20
+ end
21
+ attr_reader :src, :filter, :paged
22
+
23
+ # Join two filter using a or operation
24
+ def |(o)
25
+ _operator_2('|', o)
26
+ end
27
+
28
+ # Join two filter using a and operation
29
+ def &(o)
30
+ _operator_2('&', o)
31
+ end
32
+
33
+ # Take the negation of this fileter
34
+ def ~@
35
+ _operator_1('!')
36
+ end
37
+
38
+
39
+ # Ask for paginated data.
40
+ #
41
+ # @note That is not supported by net/ldap and is emulated by taking
42
+ # a slice of the retrieved data. Avoid using.
43
+ #
44
+ # @param [Integer] page index (starting from 1)
45
+ # @param [Integer] page size
46
+ #
47
+ # @return [self]
48
+ def paginate(page, page_size)
49
+ @paged = [ page, page_size ]
50
+ self
51
+ end
52
+
53
+ # Iterate over matching data
54
+ def each(*args, &block)
55
+ @src.each(*args, filter: @filter, paged: self.paged, &block)
56
+ end
57
+
58
+ # Retrieve matching data as a list of object
59
+ #
60
+ # @return [Array<Object>]
61
+ #
62
+ def all
63
+ each(:object).to_a
64
+ end
65
+
66
+ # Retrieve matching data as a list of id
67
+ #
68
+ # @return [Array<String>]
69
+ #
70
+ def list
71
+ each(:id).to_a
72
+ end
73
+
74
+ # Escape (and convert) a value for correct processing.
75
+ #
76
+ # Before escaping, the value will be converted to string using
77
+ # if possible #to_ldap, #to_str, and #to_s in case of symbol
78
+ #
79
+ # @param [Object] val value to be escaped
80
+ #
81
+ def self.escape(val)
82
+ val = if val.respond_to?(:to_ldap) then val.to_ldap
83
+ elsif val.respond_to?(:to_str ) then val.to_str
84
+ elsif val.kind_of?(Symbol) then val.to_s
85
+ else raise ArgumentError, 'can\'t convert to string'
86
+ end
87
+ Net::LDAP::Filter.escape(val)
88
+ end
89
+
90
+ # Test if an attribute exists
91
+ def self.exists(attr, predicate: true)
92
+ case predicate
93
+ when true, nil then "(#{attr}=*)"
94
+ when false, :none then "(!(#{attr}=*))"
95
+ else raise ArgumentError
96
+ end
97
+ end
98
+
99
+ # Test if an attribute is of the specified value
100
+ def self.is(attr, val, predicate: true)
101
+ case predicate
102
+ when true, nil then "(#{attr}=#{escape(val)})"
103
+ when false then "(!(#{attr}=#{escape(val)}))"
104
+ else raise ArgumentError
105
+ end
106
+ end
107
+
108
+ # Test if an attribute has the specified value.
109
+ # Using NONE will test for absence, ANY for existence
110
+ def self.has(attr, val)
111
+ val = yield(val) if block_given?
112
+
113
+ case val
114
+ when ANY then "(#{attr}=*)"
115
+ when NONE then "(!(#{attr}=*))"
116
+ else "(#{attr}=#{escape(val)})"
117
+ end
118
+ end
119
+
120
+ # Test if an attribute as a time before the specified timestamp
121
+ # If an integer is given it is added to the today date
122
+ def self.before(attr, ts, predicate: true)
123
+ ts = Date.today + ts if ts.kind_of?(Integer)
124
+ ts = LOM.to_ldap_time(ts)
125
+ "(#{attr}<=#{ts})".then {|f| predicate ? f : "(!#{f})" }
126
+ end
127
+
128
+ # Test if an attribute as a time after the specified timestamp
129
+ # If an integer is given it is subtracted to the today date
130
+ def self.after(attr, ts, predicate: true)
131
+ ts = Date.today - ts if ts.kind_of?(Integer)
132
+ ts = LOM.to_ldap_time(ts)
133
+ "(#{attr}>=#{ts})".then {|f| predicate ? f : "(!#{f})" }
134
+ end
135
+
136
+ private
137
+
138
+ # Operation with 2 elements
139
+ def _operator_2(op, o)
140
+ if @src != o.src
141
+ raise ArgumentError, 'filter defined with different sources'
142
+ end
143
+ _filter = if !@filter.nil? && !o.filter.nil?
144
+ then Net::LDAP.filter(op, @filter, o.filter)
145
+ else @filter || o.filter
146
+ end
147
+ Filtered.new(@src, _filter, paged: o.paged || self.paged)
148
+ end
149
+
150
+ # Operation with 1 element
151
+ def _operator_1(op)
152
+ Filtered.new(@src, Net::LDAP.filter(op, @filter),
153
+ paged: self.paged)
154
+ end
155
+
156
+ # Check if an ldap_list has been defined with that name
157
+ def respond_to_missing?(method_name, include_private = false)
158
+ @src.ldap_listing.include?(method_name) || super
159
+ end
160
+
161
+ # Call the ldap_list defined with that name
162
+ def method_missing(method_name, *args, &block)
163
+ if @src.ldap_listing.include?(method_name)
164
+ self & @src.send(method_name, *args, &block)
165
+ else
166
+ super
167
+ end
168
+ end
169
+
170
+ end
171
+ end
@@ -0,0 +1,43 @@
1
+ require 'forwardable'
2
+
3
+ class LOM
4
+ using LDAP::Extensions
5
+
6
+
7
+ def self.lh=(lh)
8
+ @lh = lh
9
+ end
10
+
11
+ # Get the LDAP handler to use
12
+ #
13
+ # In order of preference:
14
+ #
15
+ # * the handler set using lh=
16
+ # * the LH constant in this scope or parent scope
17
+ # * the one defined in $lh
18
+ #
19
+ def self.lh
20
+ @lh || const_get(:LH) || $lh
21
+ end
22
+
23
+
24
+ # extend Forwardable
25
+ #
26
+ # def self.connect(*args)
27
+ # self.new(Net::LDAP.connect(*args))
28
+ # end
29
+ #
30
+ # def initialize(lh)
31
+ # @lh = lh
32
+ # end
33
+ #
34
+ # def_delegator :@lh, :search
35
+ # def_delegator :@lh, :update
36
+ # def_delegator :@lh, :modify
37
+ # def_delegator :@lh, :add
38
+ # def_delegator :@lh, :delete
39
+
40
+ end
41
+
42
+
43
+
data/lib/lom/ldap.rb ADDED
@@ -0,0 +1,33 @@
1
+ require_relative 'ldap/converters'
2
+ require_relative 'ldap/extensions'
3
+
4
+ class LOM
5
+ using LDAP::Extensions
6
+
7
+ # Retrieve the identifier.
8
+ #
9
+ # The given `dn` should be a direct child of the `branch`,
10
+ # and if `attr` is specified, the attribute name should also match.
11
+ #
12
+ # ~~~
13
+ # dn = "uid=jdoe,ou=People,dc=example,dc=com"
14
+ # LOM.id_from_dn(dn, "ou=People,dc=example,dc=com", :uid)
15
+ # ~~~
16
+ #
17
+ # @param [String] dn DN of the object
18
+ # @param [String] branch Branch the DN should belong
19
+ # @param [Symbol,String] attr Attribute name
20
+ #
21
+ # @return [String] Identifier
22
+ # @return [nil] Unable to extract identifier
23
+ #
24
+ def self.id_from_dn(dn, branch, attr = nil)
25
+ if sub = Net::LDAP::DN.sub?(dn, branch)
26
+ k, v, o = sub.to_a
27
+ if o.nil? && (!attr.nil? || (k == attr.to_s))
28
+ v
29
+ end
30
+ end
31
+ end
32
+
33
+ end
@@ -0,0 +1,132 @@
1
+ require 'date'
2
+ require 'set'
3
+
4
+ require_relative '../core'
5
+
6
+
7
+ module LOM::LDAP
8
+ module Converters
9
+
10
+ #
11
+ # Integer
12
+ #
13
+
14
+ refine Integer do
15
+ def to_ldap
16
+ self.to_s
17
+ end
18
+ end
19
+
20
+ refine Integer.singleton_class do
21
+ def from_ldap(v)
22
+ Integer(v)
23
+ end
24
+ end
25
+
26
+
27
+
28
+ #
29
+ # String
30
+ #
31
+
32
+ refine String do
33
+ def to_ldap
34
+ self
35
+ end
36
+ end
37
+
38
+ refine String.singleton_class do
39
+ def from_ldap(v)
40
+ v
41
+ end
42
+ end
43
+
44
+
45
+
46
+ #
47
+ # Date / Time
48
+ #
49
+
50
+ refine Date do
51
+ def to_ldap
52
+ self.to_time.to_ldap
53
+ end
54
+ end
55
+
56
+ refine Date.singleton_class do
57
+ def from_ldap(date)
58
+ return nil if date.nil?
59
+ Date.parse(date)
60
+ end
61
+ end
62
+
63
+ refine Time do
64
+ def to_ldap
65
+ self.gmtime.strftime("%Y%m%d%H%M%SZ")
66
+ end
67
+ end
68
+
69
+ refine Time.singleton_class do
70
+ def from_ldap(time)
71
+ return nil if time.nil?
72
+ self::gm(time[0,4].to_i, time[4,2].to_i, time[6,2].to_i,
73
+ time[8,2].to_i, time[10,2].to_i, time[12,2].to_i)
74
+ end
75
+ end
76
+
77
+
78
+
79
+ #
80
+ # Boolean
81
+ #
82
+
83
+ refine TrueClass do
84
+ def to_ldap
85
+ 'TRUE'
86
+ end
87
+ end
88
+
89
+ refine TrueClass.singleton_class do
90
+ def from_ldap(v)
91
+ v == 'TRUE'
92
+ end
93
+ end
94
+
95
+ refine FalseClass do
96
+ def to_ldap
97
+ 'FALSE'
98
+ end
99
+ end
100
+
101
+
102
+
103
+ #
104
+ # Array / Set
105
+ #
106
+
107
+ refine Set do
108
+ def to_ldap
109
+ self.to_a.to_ldap
110
+ end
111
+ end
112
+
113
+ refine Array do
114
+ def to_ldap
115
+ self.map { |val|
116
+ if val.respond_to?(:to_ldap) then val.to_ldap
117
+ elsif val.respond_to?(:to_str ) then val.to_str
118
+ elsif val.kind_of?(Symbol) then val.to_s
119
+ else raise LOM::ConvertionError,
120
+ "can't convert to string (#{val.class})"
121
+ end
122
+ }.tap {|list|
123
+ if err = list.find {|e| ! e.kind_of?(String) }
124
+ raise LOM::ConvertionError,
125
+ "detected a non-string element (#{err.class})"
126
+ end
127
+ }
128
+ end
129
+ end
130
+
131
+ end
132
+ end
@@ -0,0 +1,257 @@
1
+ # -*- coding: utf-8 -*-
2
+
3
+ require 'net/ldap'
4
+ require 'net/ldap/dn'
5
+
6
+ require_relative '../core'
7
+
8
+
9
+ module LOM::LDAP
10
+
11
+ # Ensure that the Converters module exists.
12
+ #
13
+ # NOTE: If the optional refinements provided by this modules are required,
14
+ # they need to be defined/loaded before requiring this file
15
+ # For example: require 'lom/ldap/converters'
16
+ module Converters
17
+ end
18
+
19
+
20
+ # Provide refinements to ease development with the net/ldap library:
21
+ #
22
+ # * Net::LDAP instance can be created from an URI
23
+ # using Net::LDAP.connect
24
+ #
25
+ # * Net::LDAP#search can use symbols for
26
+ # scope: :base, :one, :sub
27
+ # deref: :never, :search, :find, :always
28
+ #
29
+ # * Net::LDAP#get method allows retrieving the first entry of a DN
30
+ # (it is just a customized search query)
31
+ #
32
+ # * Net::LDAP#update method that try to intelligently update an
33
+ # LDAP attribute (to be used instead of Net::LDAP#modify)
34
+ #
35
+ # * Net::LDAP::Entry has been enhanced to easy casting of retrieved
36
+ # attributes
37
+ #
38
+ # * Net::LDAP::DN.sub? has been added to test if a DN is included
39
+ # in another, and will return the sub part
40
+ #
41
+ # * Net::LDAP::DN.escape and Net::LDAP:Filter.escape have been
42
+ # redefined to fix some issues
43
+ #
44
+ module Extensions
45
+ refine Net::LDAP.singleton_class do
46
+ def filter(op, *args)
47
+ op, check = case op
48
+ when :or, '|' then [ '|', 1.. ]
49
+ when :and, '&' then [ '&', 1.. ]
50
+ when :not, '!' then [ '!', 1 ]
51
+ when :ge, '>=' then [ '>=', 2 ]
52
+ when :eq, '=' then [ '=', 2 ]
53
+ when :le, '<=' then [ '<=', 2 ]
54
+ else raise ArgumentError, 'Unknown operation'
55
+ end
56
+ args = args.compact.map(&:strip).reject(&:empty?).map {|a|
57
+ if ( a[0] == '(' ) && ( a[-1] == ')' ) then a
58
+ elsif ( a[0] != '(' ) && ( a[-1] != ')' ) then "(#{a})"
59
+ else raise ArgumentError, "Bad LDAP filter: #{a}"
60
+ end
61
+ }
62
+ case args.size
63
+ when 0 then nil
64
+ when 1 then args[0]
65
+ else "(#{op}#{args.join})"
66
+ end
67
+ end
68
+
69
+ def connect(uri=nil, **opts)
70
+ if uri
71
+ uri = URI(uri)
72
+ case uri.scheme
73
+ when 'ldap' then
74
+ when 'ldaps' then opts[:encryption] = :simple_tls
75
+ else raise ArgumentError, "Unsupported protocol #{proto}";
76
+ end
77
+ opts[:host] = uri.host
78
+ opts[:port] = uri.port
79
+ end
80
+ self.new(opts)
81
+ end
82
+ end
83
+
84
+ refine Net::LDAP do
85
+ def close
86
+ end
87
+
88
+ def search(args={}, &block)
89
+ if deref = case args[:deref]
90
+ when :never then Net::LDAP::DerefAliases_Never
91
+ when :search then Net::LDAP::DerefAliases_Search
92
+ when :find then Net::LDAP::DerefAliases_Find
93
+ when :always then Net::LDAP::DerefAliases_Always
94
+ end
95
+ args[:deref] = deref
96
+ end
97
+ if scope = case args[:scope]
98
+ when :base then Net::LDAP::SearchScope_BaseObject
99
+ when :one then Net::LDAP::SearchScope_SingleLevel
100
+ when :sub then Net::LDAP::SearchScope_WholeSubtree
101
+ end
102
+ args[:scope] = scope
103
+ end
104
+ super(args, &block)
105
+ end
106
+
107
+ def get(dn:, attributes: nil, attributes_only: false,
108
+ return_result: true, time: nil, deref: :never, &block)
109
+ search(:base => dn,
110
+ :scope => :base,
111
+ :attributes => attributes,
112
+ :attributes_only => attributes_only,
113
+ :return_result => return_result,
114
+ :time => time,
115
+ :deref => deref,
116
+ &block)
117
+ .then {|r| return_result ? r&.first : r }
118
+ end
119
+
120
+ # Update an existing dn entry.
121
+ # The necessary operation (add/modify/replace) will be built
122
+ # accordingly.
123
+ #
124
+ # @note the dn can be specified, either in the dn parameter
125
+ # or as a key in the attributes parameter
126
+ #
127
+ # @param dn
128
+ # @param attributes
129
+ #
130
+ # @return [nil] dn doesn't exist so it can't be updated
131
+ # @return [Boolean] operation success
132
+ #
133
+ # @raise [ArgumentError] if DN missing or incoherent
134
+ #
135
+ def update(dn: nil, attributes: {})
136
+ # Normalize keys
137
+ attributes = attributes.to_h.dup
138
+ attributes.transform_keys! {|k| k.downcase.to_sym }
139
+ attributes.transform_values! {|v| Array(v) }
140
+ attributes.transform_values! {|v| v.empty? ? nil : v }
141
+
142
+ # Sanitize
143
+ _dn = attributes[:dn]
144
+ if _dn && _dn.size > 1
145
+ raise ArgumentError, 'only one DN can be specified'
146
+ end
147
+ if dn.nil? && _dn.nil?
148
+ raise ArgumentError, 'missing DN'
149
+ elsif dn && _dn && dn != _dn.first
150
+ raise ArgumentError, 'attribute DN doesn\'t match provided DN'
151
+ end
152
+
153
+ dn ||= _dn.first
154
+ attributes[:dn] = [ dn ]
155
+
156
+ # Retrieve existing attributes
157
+ # Note: dn is always present in entries
158
+ entries = get(dn: dn, attributes: attributes.keys)
159
+
160
+ # Entry not found
161
+ return nil if entries.nil?
162
+
163
+ # Identify keys
164
+ changing = attributes.compact.keys
165
+ removing = attributes.select {|k, v| v.nil? }.keys
166
+ existing = entries.attribute_names
167
+ add = changing - existing
168
+ modify = changing & existing
169
+ delete = removing & existing
170
+
171
+ # Remove key from update if same content
172
+ modify.reject! {|k| attributes[k] == entries[k] }
173
+
174
+ # Build operations
175
+ # Note: order is delete/modify/add
176
+ # to avoid "Object Class Violation" due to possible
177
+ # modification of objectClass
178
+ ops = []
179
+ ops += delete.map {|k| [ :delete, k, nil ] }
180
+ ops += modify.map {|k| [ :replace, k, attributes[k] ] }
181
+ ops += add .map {|k| [ :add, k, attributes[k] ] }
182
+
183
+ # Apply
184
+ if LOM.debug.include?(:verbose)
185
+ $stderr.puts "Update: #{dn}"
186
+ $stderr.puts ops.inspect
187
+ end
188
+ if LOM.debug.include?(:dry)
189
+ return true
190
+ end
191
+ return true if op.empty? # That's a no-op
192
+ modify(:dn => dn, :operations => ops) # Apply modifications
193
+ end
194
+ end
195
+
196
+ refine Net::LDAP::Filter.singleton_class do
197
+ def escape(str)
198
+ str.gsub(/([\x00-\x1f*()\\])/) { '\\%02x' % $1[0].ord }
199
+ end
200
+ end
201
+
202
+ refine Net::LDAP::DN.singleton_class do
203
+ def sub?(dn, prefix)
204
+ _dn = Net::LDAP::DN.new(dn ).to_a
205
+ _prefix = Net::LDAP::DN.new(prefix).to_a
206
+ return nil if _dn.size <= _prefix.size
207
+ sub = _dn[0 .. - (_prefix.size + 1)]
208
+ return nil if sub.empty?
209
+ Net::LDAP::DN.new(*sub)
210
+ end
211
+ end
212
+
213
+ refine Net::LDAP::DN.singleton_class do
214
+ def escape(str)
215
+ str.gsub(/([\x00-\x1f])/ ) { '\\%02x' % $1[0].ord } \
216
+ .gsub(/([\\+\"<>;,\#=])/ ) { '\\' + $1 }
217
+ end
218
+ end
219
+
220
+ refine Net::LDAP::Entry do
221
+ using LOM::LDAP::Converters
222
+
223
+ def _cast(val, cnv=nil, &block)
224
+ if cnv && block
225
+ raise ArgumentError,
226
+ 'converter can\'t be pass as parameter and as block'
227
+ elsif block
228
+ cnv = block
229
+ end
230
+
231
+ case cnv
232
+ when Method, Proc then cnv.call(val)
233
+ when Class then cnv.from_ldap(val)
234
+ when nil then val
235
+ else raise ArgumentError, "unhandled converter type (#{cnv.class})"
236
+ end
237
+ end
238
+ private :_cast
239
+
240
+ def [](name, cnv=nil, &block)
241
+ values = super(name)
242
+ if cnv.nil? && block.nil?
243
+ then values
244
+ else values.map {|e| _cast(e, cnv, &block) }
245
+ end
246
+ end
247
+ alias :all :[]
248
+
249
+ def first(name, cnv=nil, &block)
250
+ if value = super(name)
251
+ _cast(value, cnv, &block)
252
+ end
253
+ end
254
+ end
255
+ end
256
+
257
+ end
data/lib/lom/mapper.rb ADDED
@@ -0,0 +1,296 @@
1
+ require_relative 'ldap/converters'
2
+ require_relative 'ldap/extensions'
3
+ require_relative 'filtered'
4
+
5
+ class LOM
6
+ using LDAP::Extensions
7
+ using LDAP::Converters
8
+
9
+ # This module is to be prepend to Entry instance when processing
10
+ # block `from_ldap`
11
+ #
12
+ # It will allow the use of refined methods #first, #[], #all
13
+ # without requiring an explicit import of LDAPExt refinement
14
+ # in the class being mapped.
15
+ #
16
+ module EntryEnhanced
17
+ def first(*args) ; super ; end
18
+ def [](*args) ; super ; end
19
+ alias :all :[]
20
+ end
21
+
22
+
23
+
24
+ # Instance methods to be injected in the class being mapped.
25
+ #
26
+ module Mapper
27
+ module InstanceMethods
28
+ # LDAP handler
29
+ def lh
30
+ self.class.lh
31
+ end
32
+
33
+ # Save object to ldap.
34
+ #
35
+ # If object already exists, it will be updated otherwise created.
36
+ #
37
+ # @return [true, false]
38
+ #
39
+ def save!
40
+ attrs = instance_exec(self, &self.class._ldap_to)
41
+ .transform_values {|v|
42
+ # Don't use Array(), not what you think on
43
+ # some classes such as Time
44
+ v = [ ] if v.nil?
45
+ v = [ v ] unless v.is_a?(Array)
46
+ v.to_ldap
47
+ }
48
+ id, _ = Array(attrs[self.class._ldap_prefix])
49
+ raise MappingError, 'prefix for dn has multiple values' if _
50
+ dn = self.class.ldap_dn_from_id(id)
51
+
52
+ lh.update(dn: dn, attributes: attrs).then {|res|
53
+ break res unless res.nil?
54
+ attrs.reject! {|k, v| Array(v).empty? }
55
+ lh.add(dn: dn, attributes: attrs)
56
+ }
57
+ end
58
+ end
59
+ end
60
+
61
+
62
+
63
+ # Class methods to be injected in the class being mapped,
64
+ # and performs initialization thanks to #extend_object
65
+ #
66
+ module Mapper
67
+ def self.extend_object(o)
68
+ super
69
+ o.include Mapper::InstanceMethods
70
+ o.extend Enumerable
71
+ o.const_set(:Filtered, LOM::Filtered)
72
+ o.__ldap_init
73
+ end
74
+
75
+ def __ldap_init
76
+ @__ldap_branch = nil
77
+ @__ldap_prefix = nil
78
+ @__ldap_scope = :one
79
+ @__ldap_filter = nil
80
+ @__ldap_attrs = nil
81
+ @__ldap_from = nil
82
+ @__ldap_to = nil
83
+ @__ldap_list = []
84
+ @__ldap_lh = nil
85
+ end
86
+
87
+ # Get the LDAP handler to use
88
+ #
89
+ # In order of preference:
90
+ #
91
+ # * the handler set using lh=
92
+ # * the LH constant in this scope or parent scope
93
+ # * the one provided by LOM.lh
94
+ #
95
+ def lh
96
+ @__ldap_lh || const_get(:LH) || LOM.lh
97
+ end
98
+
99
+ # Set the LDAP handler to use
100
+ def lh=(lh)
101
+ @__ldap_lh = lh
102
+ end
103
+
104
+
105
+ def ldap_listing
106
+ @__ldap_list
107
+ end
108
+
109
+ def ldap_list(name, body=nil, &block)
110
+ if body && block
111
+ raise ArgumentError
112
+ elsif body.nil? && block.nil?
113
+ raise ArgumentError
114
+ elsif block
115
+ body = block
116
+ end
117
+
118
+ @__ldap_list << name
119
+ define_singleton_method(name) do |*args|
120
+ filter = body.call(*args)
121
+ LOM::Filtered.new(self, filter)
122
+ end
123
+ end
124
+
125
+
126
+ def ldap_branch(v)
127
+ @__ldap_branch = v
128
+ end
129
+
130
+ def ldap_prefix(v)
131
+ @__ldap_prefix = v
132
+ end
133
+
134
+ def ldap_scope(v)
135
+ @__ldap_scope = v
136
+ end
137
+
138
+ def ldap_filter(v)
139
+ @__ldap_filter = v[0] == '(' ? v : "(#{v})"
140
+ end
141
+
142
+ def ldap_attrs(*v)
143
+ @__ldap_attrs = v
144
+ end
145
+
146
+ # @note block will be executed in the Net::LDAP::Entry instance
147
+ def ldap_from(p=nil, &b)
148
+ if (! p.nil? ^ b.nil?) || (p && !p.kind_of?(Proc))
149
+ raise ArgumentError,
150
+ 'one and only one of proc/lamba/block need to be defined'
151
+ end
152
+ @__ldap_from = p || b
153
+ end
154
+
155
+ # @note block will be executed in the mapped object instance
156
+ def ldap_to(p=nil, &b)
157
+ if (! p.nil? ^ b.nil?) || (p && !p.kind_of?(Proc))
158
+ raise ArgumentError,
159
+ 'one and only one of proc/lamba/block need to be defined'
160
+ end
161
+ @__ldap_to = p || b
162
+ end
163
+
164
+
165
+
166
+ def ldap_dn_to_id(dn)
167
+ prefix = _ldap_prefix.to_s
168
+ branch = _ldap_branch
169
+
170
+ if sub = Net::LDAP::DN.sub?(dn, branch)
171
+ case prefix
172
+ when String, Symbol
173
+ k, v, _ = sub.to_a
174
+ raise ArgumentError, "not a direct child" if _
175
+ raise ArgumentError, "wrong prefix" if k.casecmp(prefix) != 0
176
+ v
177
+ end
178
+ end
179
+ end
180
+
181
+ def ldap_dn_from_id(id)
182
+ Net::LDAP::DN.new(_ldap_prefix.to_s, id, _ldap_branch).to_s
183
+ end
184
+
185
+ def _ldap_to_obj(entry)
186
+ raise EntryNotFound if entry.nil?
187
+ entry.extend(EntryEnhanced)
188
+ args = entry.instance_exec(entry, &_ldap_from)
189
+ args = [ args ] unless args.kind_of?(Array)
190
+ self.new(*args)
191
+ end
192
+
193
+
194
+ def each(type = :object, filter: nil, paged: nil)
195
+ # Create Enumerator if no block given
196
+ unless block_given?
197
+ return enum_for(:each, type, filter: filter, paged: paged)
198
+ end
199
+
200
+ # Merging filters
201
+ filters = [ filter, _ldap_filter ].compact
202
+ filter = filters.size == 2 ? "(&#{filters.join})" : filters.first
203
+
204
+ # Define attributes/converter according to selected type
205
+ attributes, converter =
206
+ case type
207
+ when :id then [ :dn, ->(e) { ldap_dn_to_id(e.dn) } ]
208
+ when :object then [ _ldap_attrs, ->(e) { _ldap_to_obj(e) } ]
209
+ else raise ArgumentError, 'type must be either :object or :id'
210
+ end
211
+
212
+ # Paginate
213
+ # XXX: pagination is emulated, should be avoided
214
+ skip, count = if paged
215
+ page, page_size = paged
216
+ [ (page - 1) * page_size, page_size ]
217
+ end
218
+
219
+ # Perform search
220
+ lh.search(:base => _ldap_branch,
221
+ :filter => filter,
222
+ :attributes => attributes,
223
+ :scope => _ldap_scope) {|entry|
224
+
225
+ if paged.nil?
226
+ yield(converter.(entry))
227
+ elsif skip > 0
228
+ skip -= 1
229
+ elsif count <= 0
230
+ break
231
+ else
232
+ count -= 1
233
+ yield(converter.(entry))
234
+ end
235
+ }
236
+ end
237
+
238
+ def paginate(page, page_size)
239
+ LOM::Filtered.new(self, paged: [ page, page_size ])
240
+ end
241
+
242
+ def all
243
+ each(:object).to_a
244
+ end
245
+
246
+ def list
247
+ each(:id).to_a
248
+ end
249
+
250
+ def get(name)
251
+ dn = ldap_dn_from_id(name)
252
+ attrs = _ldap_attrs
253
+ entry = lh.get(:dn => dn, :attributes => attrs)
254
+
255
+ _ldap_to_obj(entry)
256
+ end
257
+
258
+ def delete!(name)
259
+ dn = ldap_dn_from_id(name)
260
+ lh.delete(:dn => dn)
261
+ end
262
+
263
+ alias [] get
264
+
265
+ private
266
+
267
+ def _ldap_branch
268
+ @__ldap_branch || (raise MappingError, 'ldap_branch not defined')
269
+ end
270
+
271
+ def _ldap_prefix
272
+ @__ldap_prefix || (raise MappingError, 'ldap_prefix not defined')
273
+ end
274
+
275
+ def _ldap_scope
276
+ @__ldap_scope
277
+ end
278
+
279
+ def _ldap_filter
280
+ @__ldap_filter
281
+ end
282
+
283
+ def _ldap_attrs
284
+ @__ldap_attrs
285
+ end
286
+
287
+ def _ldap_from
288
+ @__ldap_from || (raise MappingError, 'ldap_from not defined' )
289
+ end
290
+
291
+ def _ldap_to
292
+ @__ldap_to || (raise MappingError, 'ldap_to not defined' )
293
+ end
294
+
295
+ end
296
+ end
@@ -0,0 +1,3 @@
1
+ class LOM
2
+ VERSION = '0.9.0'
3
+ end
data/lom.gemspec ADDED
@@ -0,0 +1,31 @@
1
+ # -*- encoding: utf-8 -*-
2
+
3
+ require_relative 'lib/lom/version'
4
+
5
+ Gem::Specification.new do |s|
6
+ s.name = 'lom'
7
+ s.version = LOM::VERSION
8
+ s.summary = "LDAP Object Mapper"
9
+ s.description = <<~EOF
10
+
11
+ Ease processing of parameters in Sinatra framework.
12
+ Integrates well with dry-types, sequel, ...
13
+
14
+ Example:
15
+ want! :user, Dry::Types::String, User
16
+ want? :expired, Dry::Types::Params::Bool.default(true)
17
+ EOF
18
+
19
+ s.homepage = 'https://gitlab.com/sdalu/lom'
20
+ s.license = 'MIT'
21
+
22
+ s.authors = [ "Stéphane D'Alu" ]
23
+ s.email = [ 'stephane.dalu@insa-lyon.fr' ]
24
+
25
+ s.files = %w[ README.md lom.gemspec ] +
26
+ Dir['lib/**/*.rb']
27
+
28
+ s.add_dependency 'net-ldap'
29
+ s.add_development_dependency 'yard', '~>0'
30
+ s.add_development_dependency 'rake', '~>13'
31
+ end
metadata ADDED
@@ -0,0 +1,103 @@
1
+ --- !ruby/object:Gem::Specification
2
+ name: lom
3
+ version: !ruby/object:Gem::Version
4
+ version: 0.9.0
5
+ platform: ruby
6
+ authors:
7
+ - Stéphane D'Alu
8
+ autorequire:
9
+ bindir: bin
10
+ cert_chain: []
11
+ date: 2021-05-26 00:00:00.000000000 Z
12
+ dependencies:
13
+ - !ruby/object:Gem::Dependency
14
+ name: net-ldap
15
+ requirement: !ruby/object:Gem::Requirement
16
+ requirements:
17
+ - - ">="
18
+ - !ruby/object:Gem::Version
19
+ version: '0'
20
+ type: :runtime
21
+ prerelease: false
22
+ version_requirements: !ruby/object:Gem::Requirement
23
+ requirements:
24
+ - - ">="
25
+ - !ruby/object:Gem::Version
26
+ version: '0'
27
+ - !ruby/object:Gem::Dependency
28
+ name: yard
29
+ requirement: !ruby/object:Gem::Requirement
30
+ requirements:
31
+ - - "~>"
32
+ - !ruby/object:Gem::Version
33
+ version: '0'
34
+ type: :development
35
+ prerelease: false
36
+ version_requirements: !ruby/object:Gem::Requirement
37
+ requirements:
38
+ - - "~>"
39
+ - !ruby/object:Gem::Version
40
+ version: '0'
41
+ - !ruby/object:Gem::Dependency
42
+ name: rake
43
+ requirement: !ruby/object:Gem::Requirement
44
+ requirements:
45
+ - - "~>"
46
+ - !ruby/object:Gem::Version
47
+ version: '13'
48
+ type: :development
49
+ prerelease: false
50
+ version_requirements: !ruby/object:Gem::Requirement
51
+ requirements:
52
+ - - "~>"
53
+ - !ruby/object:Gem::Version
54
+ version: '13'
55
+ description: |2
56
+
57
+ Ease processing of parameters in Sinatra framework.
58
+ Integrates well with dry-types, sequel, ...
59
+
60
+ Example:
61
+ want! :user, Dry::Types::String, User
62
+ want? :expired, Dry::Types::Params::Bool.default(true)
63
+ email:
64
+ - stephane.dalu@insa-lyon.fr
65
+ executables: []
66
+ extensions: []
67
+ extra_rdoc_files: []
68
+ files:
69
+ - README.md
70
+ - lib/lom.rb
71
+ - lib/lom/core.rb
72
+ - lib/lom/filtered.rb
73
+ - lib/lom/handler.rb
74
+ - lib/lom/ldap.rb
75
+ - lib/lom/ldap/converters.rb
76
+ - lib/lom/ldap/extensions.rb
77
+ - lib/lom/mapper.rb
78
+ - lib/lom/version.rb
79
+ - lom.gemspec
80
+ homepage: https://gitlab.com/sdalu/lom
81
+ licenses:
82
+ - MIT
83
+ metadata: {}
84
+ post_install_message:
85
+ rdoc_options: []
86
+ require_paths:
87
+ - lib
88
+ required_ruby_version: !ruby/object:Gem::Requirement
89
+ requirements:
90
+ - - ">="
91
+ - !ruby/object:Gem::Version
92
+ version: '0'
93
+ required_rubygems_version: !ruby/object:Gem::Requirement
94
+ requirements:
95
+ - - ">="
96
+ - !ruby/object:Gem::Version
97
+ version: '0'
98
+ requirements: []
99
+ rubygems_version: 3.0.8
100
+ signing_key:
101
+ specification_version: 4
102
+ summary: LDAP Object Mapper
103
+ test_files: []