entitlements 0.1.7

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 (57) hide show
  1. checksums.yaml +7 -0
  2. data/VERSION +1 -0
  3. data/bin/deploy-entitlements +18 -0
  4. data/lib/entitlements/auditor/base.rb +163 -0
  5. data/lib/entitlements/backend/base_controller.rb +171 -0
  6. data/lib/entitlements/backend/base_provider.rb +55 -0
  7. data/lib/entitlements/backend/dummy/controller.rb +89 -0
  8. data/lib/entitlements/backend/dummy.rb +3 -0
  9. data/lib/entitlements/backend/ldap/controller.rb +188 -0
  10. data/lib/entitlements/backend/ldap/provider.rb +128 -0
  11. data/lib/entitlements/backend/ldap.rb +4 -0
  12. data/lib/entitlements/backend/member_of/controller.rb +203 -0
  13. data/lib/entitlements/backend/member_of.rb +3 -0
  14. data/lib/entitlements/cli.rb +121 -0
  15. data/lib/entitlements/data/groups/cached.rb +120 -0
  16. data/lib/entitlements/data/groups/calculated/base.rb +478 -0
  17. data/lib/entitlements/data/groups/calculated/filters/base.rb +93 -0
  18. data/lib/entitlements/data/groups/calculated/filters/member_of_group.rb +32 -0
  19. data/lib/entitlements/data/groups/calculated/modifiers/base.rb +38 -0
  20. data/lib/entitlements/data/groups/calculated/modifiers/expiration.rb +56 -0
  21. data/lib/entitlements/data/groups/calculated/ruby.rb +137 -0
  22. data/lib/entitlements/data/groups/calculated/rules/base.rb +35 -0
  23. data/lib/entitlements/data/groups/calculated/rules/group.rb +129 -0
  24. data/lib/entitlements/data/groups/calculated/rules/username.rb +41 -0
  25. data/lib/entitlements/data/groups/calculated/text.rb +337 -0
  26. data/lib/entitlements/data/groups/calculated/yaml.rb +171 -0
  27. data/lib/entitlements/data/groups/calculated.rb +290 -0
  28. data/lib/entitlements/data/groups.rb +13 -0
  29. data/lib/entitlements/data/people/combined.rb +197 -0
  30. data/lib/entitlements/data/people/dummy.rb +71 -0
  31. data/lib/entitlements/data/people/ldap.rb +142 -0
  32. data/lib/entitlements/data/people/yaml.rb +102 -0
  33. data/lib/entitlements/data/people.rb +58 -0
  34. data/lib/entitlements/extras/base.rb +40 -0
  35. data/lib/entitlements/extras/ldap_group/base.rb +20 -0
  36. data/lib/entitlements/extras/ldap_group/filters/member_of_ldap_group.rb +50 -0
  37. data/lib/entitlements/extras/ldap_group/rules/ldap_group.rb +69 -0
  38. data/lib/entitlements/extras/orgchart/base.rb +32 -0
  39. data/lib/entitlements/extras/orgchart/logic.rb +171 -0
  40. data/lib/entitlements/extras/orgchart/person_methods.rb +55 -0
  41. data/lib/entitlements/extras/orgchart/rules/direct_report.rb +62 -0
  42. data/lib/entitlements/extras/orgchart/rules/management.rb +59 -0
  43. data/lib/entitlements/extras.rb +82 -0
  44. data/lib/entitlements/models/action.rb +82 -0
  45. data/lib/entitlements/models/group.rb +280 -0
  46. data/lib/entitlements/models/person.rb +149 -0
  47. data/lib/entitlements/plugins/dummy.rb +22 -0
  48. data/lib/entitlements/plugins/group_of_names.rb +28 -0
  49. data/lib/entitlements/plugins/posix_group.rb +46 -0
  50. data/lib/entitlements/plugins.rb +13 -0
  51. data/lib/entitlements/rule/base.rb +74 -0
  52. data/lib/entitlements/service/ldap.rb +405 -0
  53. data/lib/entitlements/util/mirror.rb +42 -0
  54. data/lib/entitlements/util/override.rb +64 -0
  55. data/lib/entitlements/util/util.rb +219 -0
  56. data/lib/entitlements.rb +606 -0
  57. metadata +343 -0
@@ -0,0 +1,337 @@
1
+ # frozen_string_literal: true
2
+ # Interact with rules that are stored in a simplified text file.
3
+
4
+ require "yaml"
5
+ require_relative "../../../util/util"
6
+
7
+ module Entitlements
8
+ class Data
9
+ class Groups
10
+ class Calculated
11
+ class Text < Entitlements::Data::Groups::Calculated::Base
12
+ include ::Contracts::Core
13
+ C = ::Contracts
14
+
15
+ SEMICOLON_PREDICATES = %w[expiration]
16
+
17
+ # Standard interface: Calculate the members of this group.
18
+ #
19
+ # Takes no arguments.
20
+ #
21
+ # Returns a Set[String] with DN's of the people in the group.
22
+ Contract C::None => C::Or[:calculating, C::SetOf[Entitlements::Models::Person]]
23
+ def members
24
+ @members ||= begin
25
+ Entitlements.logger.debug "Calculating members from #{filename}"
26
+ members_from_rules(rules)
27
+ end
28
+ end
29
+
30
+ # Standard interface: Get the description of this group.
31
+ #
32
+ # Takes no arguments.
33
+ #
34
+ # Returns a String with the group description, or "" if undefined.
35
+ Contract C::None => String
36
+ def description
37
+ return "" unless parsed_data.key?("description")
38
+
39
+ if parsed_data["description"]["!="].any?
40
+ fatal_message("The description cannot use '!=' operator in #{filename}!")
41
+ end
42
+
43
+ unless parsed_data["description"]["="].size == 1
44
+ fatal_message("The description key is duplicated in #{filename}!")
45
+ end
46
+
47
+ parsed_data["description"]["="].first.fetch(:key)
48
+ end
49
+
50
+ # Files can support modifiers that act independently of rules.
51
+ # This returns the modifiers from the file as a hash.
52
+ #
53
+ # Takes no arguments.
54
+ #
55
+ # Returns Hash[<String>key => <Object>value]
56
+ Contract C::None => C::HashOf[String => C::Any]
57
+ def modifiers
58
+ parse_with_prefix("modifier_")
59
+ end
60
+
61
+ private
62
+
63
+ # Get a hash of the filters defined in the group.
64
+ #
65
+ # Takes no arguments.
66
+ #
67
+ # Returns a Hash[String => :all/:none/List of strings].
68
+ Contract C::None => C::HashOf[String => C::Or[:all, :none, C::ArrayOf[String]]]
69
+ def initialize_filters
70
+ result = Entitlements::Data::Groups::Calculated.filters_default
71
+
72
+ parsed_data.each do |raw_key, val|
73
+ if raw_key == "filter_"
74
+ fatal_message("In #{filename}, cannot have a key named \"filter_\"!")
75
+ end
76
+
77
+ next unless raw_key.start_with?("filter_")
78
+ key = raw_key.sub(/\Afilter_/, "")
79
+
80
+ unless result.key?(key)
81
+ fatal_message("In #{filename}, the key #{raw_key} is invalid!")
82
+ end
83
+
84
+ if val["!="].any?
85
+ fatal_message("The filter #{key} cannot use '!=' operator in #{filename}!")
86
+ end
87
+
88
+ values = val["="].reject { |v| expired?(v[:expiration], filename) }.map { |v| v[:key].strip }
89
+ if values.size == 1 && (values.first == "all" || values.first == "none")
90
+ result[key] = values.first.to_sym
91
+ elsif values.size > 1 && (values.include?("all") || values.include?("none"))
92
+ fatal_message("In #{filename}, #{raw_key} cannot contain multiple entries when 'all' or 'none' is used!")
93
+ elsif values.size == 0
94
+ # This could happen if all of the specified filters were deleted due to expiration.
95
+ # In that case make no changes so the default gets used.
96
+ next
97
+ else
98
+ result[key] = values
99
+ end
100
+ end
101
+
102
+ result
103
+ end
104
+
105
+ # Files can support metadata intended for consumption by things other than LDAP.
106
+ # This returns the metadata from the file as a hash.
107
+ #
108
+ # Takes no arguments.
109
+ #
110
+ # Returns Hash[<String>key => <Object>value]
111
+ Contract C::None => C::HashOf[String => C::Any]
112
+ def initialize_metadata
113
+ parse_with_prefix("metadata_")
114
+ end
115
+
116
+ # Metadata and modifiers are parsed with nearly identical logic. In DRY spirit, use
117
+ # a single parsing method.
118
+ #
119
+ # prefix - String with the prefix expected for the key.
120
+ #
121
+ # Returns Hash[<String>key => <Object>value]
122
+ Contract String => C::HashOf[String => C::Any]
123
+ def parse_with_prefix(prefix)
124
+ result = {}
125
+ parsed_data.each do |raw_key, val|
126
+ if raw_key == "#{prefix}"
127
+ raise "In #{filename}, cannot have a key named \"#{prefix}\"!"
128
+ end
129
+
130
+ next unless raw_key.start_with?(prefix)
131
+ key = raw_key.sub(/\A#{prefix}/, "")
132
+
133
+ if val["!="].any?
134
+ fatal_message("The key #{raw_key} cannot use '!=' operator in #{filename}!")
135
+ end
136
+
137
+ unless val["="].size == 1
138
+ fatal_message("In #{filename}, the key #{raw_key} is repeated!")
139
+ end
140
+
141
+ unless val["="].first.keys == [:key]
142
+ settings = (val["="].first.keys - [:key]).map { |i| i.to_s.inspect }.join(",")
143
+ fatal_message("In #{filename}, the key #{raw_key} cannot have additional setting(s) #{settings}!")
144
+ end
145
+
146
+ result[key] = val["="].first.fetch(:key)
147
+ end
148
+ result
149
+ end
150
+
151
+ # Obtain the rule set from the content of the file and convert it to an object.
152
+ #
153
+ # Takes no arguments.
154
+ #
155
+ # Returns a Hash.
156
+ Contract C::None => C::HashOf[String => C::Any]
157
+ def rules
158
+ @rules ||= begin
159
+ ignored_keys = %w[description]
160
+
161
+ relevant_entries = parsed_data.reject { |k, _| ignored_keys.include?(k) }
162
+ relevant_entries.reject! { |k, _| k.start_with?("metadata_", "filter_", "modifier_") }
163
+
164
+ # Review all entries
165
+ affirmative = []
166
+ mandatory = []
167
+ negative = []
168
+ relevant_entries.each do |k, v|
169
+ function = function_for(k)
170
+ unless whitelisted_methods.member?(function)
171
+ Entitlements.logger.fatal "The method #{k.inspect} is not allowed in #{filename}!"
172
+ raise "The method #{k.inspect} is not allowed in #{filename}!"
173
+ end
174
+
175
+ add_relevant_entries!(affirmative, function, v["="], filename)
176
+ add_relevant_entries!(mandatory, function, v["&="], filename)
177
+ add_relevant_entries!(negative, function, v["!="], filename)
178
+ end
179
+
180
+ # Expiration pre-processing: An entitlement that is expired as a whole should not
181
+ # raise an error about having no conditions.
182
+ if parsed_data.key?("modifier_expiration") && affirmative.empty?
183
+ exp_date = parsed_data.fetch("modifier_expiration").fetch("=").first.fetch(:key)
184
+ date = Entitlements::Util::Util.parse_date(exp_date)
185
+ return {"always" => false} if date <= Time.now.utc.to_date
186
+ end
187
+
188
+ # There has to be at least one affirmative condition, not just all negative ones.
189
+ # Override with `metadata_no_conditions_ok = true`.
190
+ if affirmative.empty?
191
+ return {"always" => false} if [true, "true"].include?(metadata["no_conditions_ok"])
192
+ fatal_message("No conditions were found in #{filename}!")
193
+ end
194
+
195
+ # Get base affirmative and negative rules.
196
+ result = affirmative_negative_rules(affirmative, negative)
197
+
198
+ # Apply any mandatory rules.
199
+ if mandatory.size == 1
200
+ old_result = result.dup
201
+ result = { "and" => [mandatory.first, old_result] }
202
+ elsif mandatory.size > 1
203
+ old_result = result.dup
204
+ result = { "and" => [{ "or" => mandatory }, old_result] }
205
+ end
206
+
207
+ # Return what we've got.
208
+ result
209
+ end
210
+ end
211
+
212
+ # Handle affirmative and negative rules.
213
+ #
214
+ # affirmative - An array of Hashes with rules.
215
+ # negative - An array of Hashes with rules.
216
+ #
217
+ # Returns appropriate and / or hash.
218
+ Contract C::ArrayOf[Hash], C::ArrayOf[Hash] => C::HashOf[String => C::Any]
219
+ def affirmative_negative_rules(affirmative, negative)
220
+ if negative.empty?
221
+ # This is a simplified file. Just OR all the conditions together. (For
222
+ # something more complicated, use YAML or ruby formats.)
223
+ { "or" => affirmative }
224
+ else
225
+ # Each affirmative condition is OR'd, but any negative condition will veto.
226
+ # For something more complicated, use YAML or ruby formats.
227
+ {
228
+ "and" => [
229
+ { "or" => affirmative },
230
+ { "and" => negative.map { |condition| { "not" => condition } } }
231
+ ]
232
+ }
233
+ end
234
+ end
235
+
236
+ # Helper method to extract relevant entries from the parsed rules and concatenate them
237
+ # onto the given array.
238
+ #
239
+ # array_to_update - An Array which will have relevant rules concat'd to it.
240
+ # key - String with the key.
241
+ # rule_items - An Array of Hashes with the rules to evaluate.
242
+ # filename - Filename where rule is defined (used for error printing).
243
+ #
244
+ # Updates and returns array_to_update.
245
+ Contract C::ArrayOf[C::HashOf[String => String]], String, C::ArrayOf[C::HashOf[Symbol => String]], String => C::ArrayOf[C::HashOf[String => String]]
246
+ def add_relevant_entries!(array_to_update, key, rule_items, filename)
247
+ new_items = rule_items.reject { |item| expired?(item[:expiration], filename) }.map { |item| { key => item[:key] } }
248
+ array_to_update.concat new_items
249
+ end
250
+
251
+ # Return the parsed data from the file. This is called on demand and cached.
252
+ #
253
+ # Takes no arguments.
254
+ #
255
+ # Returns a Hash.
256
+ Contract C::None => C::HashOf[String => C::HashOf[String, C::ArrayOf[C::HashOf[Symbol, String]]]]
257
+ def parsed_data
258
+ @parsed_data ||= begin
259
+ result = {}
260
+ filter_keywords = Entitlements::Data::Groups::Calculated.filters_index.keys
261
+ content = File.read(filename).split(/\n/)
262
+ content.each do |raw_line|
263
+ line = raw_line.strip
264
+
265
+ # Ignore comments and blank lines
266
+ next if line.start_with?("#") || line == ""
267
+
268
+ # Ensure valid lines
269
+ unless line =~ /\A([\w\-]+)\s*([&!]?=)\s*(.+?)\s*\z/
270
+ Entitlements.logger.fatal "Unparseable line #{line.inspect} in #{filename}!"
271
+ raise "Unparseable line #{line.inspect} in #{filename}!"
272
+ end
273
+
274
+ # Parsing
275
+ raw_key, operator, val = Regexp.last_match(1), Regexp.last_match(2), Regexp.last_match(3)
276
+
277
+ key = if filter_keywords.include?(raw_key)
278
+ "filter_#{raw_key}"
279
+ elsif MODIFIERS.include?(raw_key)
280
+ "modifier_#{raw_key}"
281
+ else
282
+ raw_key
283
+ end
284
+
285
+ # Contractor function is used internally but may not be specified in the file by the user.
286
+ if key == "contractor"
287
+ Entitlements.logger.fatal "The method #{key.inspect} is not permitted in #{filename}!"
288
+ raise "Rule Error: #{key} is not a valid function in #{filename}!"
289
+ end
290
+
291
+ result[key] ||= {}
292
+ result[key]["="] ||= []
293
+ result[key]["!="] ||= []
294
+ result[key]["&="] ||= []
295
+
296
+ # Semicolon predicates
297
+ if key == "description"
298
+ result[key][operator] << { key: val }
299
+ else
300
+ result[key][operator] << parsed_predicate(val)
301
+ end
302
+ end
303
+
304
+ result
305
+ end
306
+ end
307
+
308
+ # Parse predicate for a rule. Turn into a hash of { key: <String of Primary Value> + other keys in line }.
309
+ #
310
+ # val - The predicate string
311
+ #
312
+ # Returns a Hash.
313
+ Contract String => C::HashOf[Symbol, String]
314
+ def parsed_predicate(val)
315
+ v = val.sub(/\s*#.*\z/, "")
316
+ return { key: v } unless v.include?(";")
317
+
318
+ parts = v.split(/\s*;\s*/)
319
+ op_hash = { key: parts.shift }
320
+ parts.each do |part|
321
+ if part =~ /\A(\w+)\s*=\s*(\S+)\s*\z/
322
+ predicate_keyword, predicate_value = Regexp.last_match(1), Regexp.last_match(2)
323
+ unless SEMICOLON_PREDICATES.include?(predicate_keyword)
324
+ raise ArgumentError, "Rule Error: Invalid semicolon predicate #{predicate_keyword.inspect} in #{filename}!"
325
+ end
326
+ op_hash[predicate_keyword.to_sym] = predicate_value
327
+ else
328
+ raise ArgumentError, "Rule Error: Unparseable semicolon predicate #{part.inspect} in #{filename}!"
329
+ end
330
+ end
331
+ op_hash
332
+ end
333
+ end
334
+ end
335
+ end
336
+ end
337
+ end
@@ -0,0 +1,171 @@
1
+ # frozen_string_literal: true
2
+ # Interact with rules that are stored in a YAML file.
3
+
4
+ require "yaml"
5
+
6
+ module Entitlements
7
+ class Data
8
+ class Groups
9
+ class Calculated
10
+ class YAML < Entitlements::Data::Groups::Calculated::Base
11
+ include ::Contracts::Core
12
+ C = ::Contracts
13
+
14
+ # Standard interface: Calculate the members of this group.
15
+ #
16
+ # Takes no arguments.
17
+ #
18
+ # Returns a Set[String] with DN's of the people in the group.
19
+ Contract C::None => C::Or[:calculating, C::SetOf[Entitlements::Models::Person]]
20
+ def members
21
+ @members ||= begin
22
+ Entitlements.logger.debug "Calculating members from #{filename}"
23
+ members_from_rules(rules)
24
+ end
25
+ end
26
+
27
+ # Standard interface: Get the description of this group.
28
+ #
29
+ # Takes no arguments.
30
+ #
31
+ # Returns a String with the group description, or "" if undefined.
32
+ Contract C::None => String
33
+ def description
34
+ parsed_data.fetch("description", "")
35
+ end
36
+
37
+ # Files can support modifiers that act independently of rules.
38
+ # This returns the modifiers from the file as a hash.
39
+ #
40
+ # Takes no arguments.
41
+ #
42
+ # Returns Hash[<String>key => <Object>value]
43
+ Contract C::None => C::HashOf[String => C::Any]
44
+ def modifiers
45
+ parsed_data.select { |k, _v| MODIFIERS.include?(k) }
46
+ end
47
+
48
+ private
49
+
50
+ # Get a hash of the filters defined in the group.
51
+ #
52
+ # Takes no arguments.
53
+ #
54
+ # Returns a Hash[String => :all/:none/List of strings].
55
+ Contract C::None => C::HashOf[String => C::Or[:all, :none, C::ArrayOf[String]]]
56
+ def initialize_filters
57
+ result = Entitlements::Data::Groups::Calculated.filters_default
58
+ return result unless parsed_data.key?("filters")
59
+
60
+ f = parsed_data["filters"]
61
+ unless f.is_a?(Hash)
62
+ raise ArgumentError, "For filters in #{filename}: expected Hash, got #{f.inspect}!"
63
+ end
64
+
65
+ f.each do |key, val|
66
+ unless result.key?(key)
67
+ raise ArgumentError, "Filter #{key} in #{filename} is invalid!"
68
+ end
69
+
70
+ values = if val.is_a?(String)
71
+ [val]
72
+ elsif val.is_a?(Array)
73
+ val
74
+ else
75
+ raise ArgumentError, "Value #{val.inspect} for #{key} in #{filename} is invalid!"
76
+ end
77
+
78
+ # Check for expiration
79
+ values.reject! { |v| v.is_a?(Hash) && expired?(v["expiration"].to_s, filename) }
80
+ values.map! { |v| v.is_a?(Hash) ? v.fetch("key") : v.strip }
81
+
82
+ if values.size == 1 && (values.first == "all" || values.first == "none")
83
+ result[key] = values.first.to_sym
84
+ elsif values.size > 1 && (values.include?("all") || values.include?("none"))
85
+ raise ArgumentError, "In #{filename}, #{key} cannot contain multiple entries when 'all' or 'none' is used!"
86
+ elsif values.size == 0
87
+ # This could happen if all of the specified filters were deleted due to expiration.
88
+ # In that case make no changes so the default gets used.
89
+ next
90
+ else
91
+ result[key] = values
92
+ end
93
+ end
94
+
95
+ result
96
+ end
97
+
98
+ # Files can support metadata intended for consumption by things other than LDAP.
99
+ # This returns the metadata from the file as a hash.
100
+ #
101
+ # Takes no arguments.
102
+ #
103
+ # Returns Hash[<String>key => <Object>value]
104
+ Contract C::None => C::HashOf[String => C::Any]
105
+ def initialize_metadata
106
+ return {} unless parsed_data.key?("metadata")
107
+ result = parsed_data["metadata"]
108
+
109
+ unless result.is_a?(Hash)
110
+ raise ArgumentError, "For metadata in #{filename}: expected Hash, got #{result.inspect}!"
111
+ end
112
+
113
+ result.each do |key, _|
114
+ next if key.is_a?(String)
115
+ raise ArgumentError, "For metadata in #{filename}: keys are expected to be strings, but #{key.inspect} is not!"
116
+ end
117
+
118
+ result
119
+ end
120
+
121
+ # Obtain the rule set from the YAML file and convert it to an object. Cache this the first
122
+ # time it happens, because this code is going to be called once per person!
123
+ #
124
+ # Takes no arguments.
125
+ #
126
+ # Returns a Hash.
127
+ Contract C::None => C::HashOf[String => C::Any]
128
+ def rules
129
+ @rules ||= begin
130
+ rules_hash = parsed_data["rules"]
131
+ unless rules_hash.is_a?(Hash)
132
+ raise "Expected to find 'rules' as a Hash in #{filename}, but got #{rules_hash.class}!"
133
+ end
134
+ remove_expired_rules(rules_hash)
135
+ end
136
+ end
137
+
138
+ # Remove expired rules from the rules hash.
139
+ #
140
+ # rules_hash - Hash of rules.
141
+ #
142
+ # Returns the updated hash that has no expired rules in it.
143
+ Contract C::HashOf[String => C::Any] => C::HashOf[String => C::Any]
144
+ def remove_expired_rules(rules_hash)
145
+ if rules_hash.keys.size == 1
146
+ if rules_hash.values.first.is_a?(Array)
147
+ return { rules_hash.keys.first => rules_hash.values.first.map { |v| remove_expired_rules(v) }.reject { |h| h.empty? } }
148
+ else
149
+ return rules_hash
150
+ end
151
+ end
152
+
153
+ expdate = rules_hash.delete("expiration")
154
+ return {} if expired?(expdate, filename)
155
+ rules_hash
156
+ end
157
+
158
+ # Return the parsed data from the file. This is called on demand and cached.
159
+ #
160
+ # Takes no arguments.
161
+ #
162
+ # Returns a Hash.
163
+ Contract C::None => C::HashOf[String => C::Any]
164
+ def parsed_data
165
+ @parsed_data ||= ::YAML.load(File.read(filename))
166
+ end
167
+ end
168
+ end
169
+ end
170
+ end
171
+ end