entitlements 0.1.7

Sign up to get free protection for your applications and to get access to all the features.
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