checken 0.0.3

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.
checksums.yaml ADDED
@@ -0,0 +1,7 @@
1
+ ---
2
+ SHA256:
3
+ metadata.gz: 6bdb1baf6e0ae23735e7a39a8887f8763d547ebc1729c9988db95933dce08f72
4
+ data.tar.gz: 689ace3710457f53b88a7928b02f48791ccd36d7054079de5b2faab99b50c4e7
5
+ SHA512:
6
+ metadata.gz: a0c04c549042a1d9049a86aa485b2195af46fd2b92f68da4546455a3c180518a68c7f0135b247e24b4a49761686b2009bad89d650a3523bc52cc8fd629360145
7
+ data.tar.gz: 65b881ddbff40c87d633da457f02f925781442a13b45f648777574be0c7a5ed8cd781f1966b6c647e6d04e4c731a2d612e76f9a09bf4370d456941d4310c6745
@@ -0,0 +1,28 @@
1
+ module Checken
2
+ module Concerns
3
+ module HasParents
4
+
5
+ # Return the full path to this permission
6
+ #
7
+ # @return [String]
8
+ def path
9
+ @key.nil? ? nil : [@group.path, @key].compact.join('.')
10
+ end
11
+
12
+ # Return the parents for ths group
13
+ #
14
+ # @return [Array<Checken::PermissionGroup, Checken::Permission>]
15
+ def parents
16
+ @key.nil? ? [] : [@group.parents, @group].compact.flatten
17
+ end
18
+
19
+ # Return the root group
20
+ #
21
+ # @return [Checken::PermissionGroup]
22
+ def root
23
+ @key.nil? ? self : parents.first
24
+ end
25
+
26
+ end
27
+ end
28
+ end
@@ -0,0 +1,40 @@
1
+ require 'logger'
2
+ require 'checken/user_proxy'
3
+
4
+ module Checken
5
+ class Config
6
+
7
+ # The class that should be used to create user proxies.
8
+ #
9
+ # @return [Class]
10
+ def user_proxy_class
11
+ @user_proxy_class ||= UserProxy
12
+ end
13
+ attr_writer :user_proxy_class
14
+
15
+ # A logger class that will be used to log all activities
16
+ #
17
+ # @return [Logger]
18
+ def logger
19
+ @logger ||= Logger.new(log_path)
20
+ end
21
+ attr_writer :logger
22
+
23
+ # The path where logs should be written to if using the default logger
24
+ #
25
+ # @return [String]
26
+ def log_path
27
+ @log_path ||= "/dev/null"
28
+ end
29
+ attr_writer :log_path
30
+
31
+ # The method name which will return the current user object in any
32
+ # controller action.
33
+ #
34
+ # @return [Symbol]
35
+ def current_user_method_name
36
+ @current_user_method_name || :current_user
37
+ end
38
+
39
+ end
40
+ end
@@ -0,0 +1,80 @@
1
+ require 'checken/dsl/set_dsl'
2
+
3
+ module Checken
4
+ module DSL
5
+ class GroupDSL
6
+
7
+ def initialize(group, options = {})
8
+ @group = group
9
+
10
+ if options[:active_sets]
11
+ @active_sets = options[:active_sets]
12
+ end
13
+ end
14
+
15
+ def name(name)
16
+ @group.name = name
17
+ end
18
+
19
+ def description(description)
20
+ @group.description = description
21
+ end
22
+
23
+ def define_rule(key, *required_object_types, &block)
24
+ @group.define_rule(key, *required_object_types, &block)
25
+ end
26
+
27
+ def set(&block)
28
+ dsl = SetDSL.new(self)
29
+ active_sets << dsl
30
+ dsl.instance_eval(&block) if block_given?
31
+ dsl
32
+ ensure
33
+ active_sets.pop
34
+ end
35
+
36
+ def group(key, &block)
37
+ sub_group = @group.groups[key.to_sym] || @group.add_group(key.to_sym)
38
+ sub_group.dsl(:active_sets => active_sets, &block) if block_given?
39
+ sub_group
40
+ end
41
+
42
+ def permission(key, description = nil, &block)
43
+ permission = @group.add_permission(key)
44
+ permission.description = description
45
+
46
+ active_sets.each do |set_dsl|
47
+ set_dsl.required_object_types.each do |rot|
48
+ permission.add_required_object_type(rot)
49
+ end
50
+
51
+ set_dsl.rules.each do |key, rule|
52
+ permission.add_rule(key, rule)
53
+ end
54
+
55
+ set_dsl.dependencies.each do |path|
56
+ permission.add_dependency(path)
57
+ end
58
+
59
+ set_dsl.contexts.each do |context|
60
+ permission.add_context(context)
61
+ end
62
+
63
+ set_dsl.included_rules.each do |key, rule|
64
+ permission.include_rule(rule)
65
+ end
66
+ end
67
+
68
+ permission.dsl(&block) if block_given?
69
+ permission
70
+ end
71
+
72
+ private
73
+
74
+ def active_sets
75
+ @active_sets ||= []
76
+ end
77
+
78
+ end
79
+ end
80
+ end
@@ -0,0 +1,40 @@
1
+ module Checken
2
+ module DSL
3
+ class PermissionDSL
4
+
5
+ def initialize(permission)
6
+ @permission = permission
7
+ end
8
+
9
+ def rule(key, &block)
10
+ @permission.add_rule(key, &block)
11
+ end
12
+
13
+ def depends_on(path)
14
+ @permission.add_dependency(path)
15
+ end
16
+
17
+ def include_rule(key, options = {}, &block)
18
+ @permission.include_rule(key, options, &block)
19
+ end
20
+
21
+ def requires_object(*names)
22
+ names.each do |name|
23
+ @permission.add_required_object_type(name)
24
+ end
25
+ end
26
+
27
+ def context(*contexts)
28
+ contexts.each do |context|
29
+ @permission.add_context(context)
30
+ end
31
+ end
32
+
33
+ def context!(*contexts)
34
+ @permission.remove_all_contexts
35
+ context(*contexts)
36
+ end
37
+
38
+ end
39
+ end
40
+ end
@@ -0,0 +1,65 @@
1
+ module Checken
2
+ module DSL
3
+ class SetDSL
4
+
5
+ attr_reader :rules
6
+ attr_reader :required_object_types
7
+ attr_reader :dependencies
8
+ attr_reader :contexts
9
+ attr_reader :included_rules
10
+
11
+ def initialize(group_dsl)
12
+ @group_dsl = group_dsl
13
+ @rules = {}
14
+ @required_object_types = []
15
+ @dependencies = []
16
+ @contexts = []
17
+ @included_rules = {}
18
+ end
19
+
20
+ def rule(name, &block)
21
+ @rules[name] = Rule.new(name, &block)
22
+ end
23
+
24
+ def include_rule(key, options = {}, &block)
25
+ @included_rules[key] = begin
26
+ rule = IncludedRule.new(key, &block)
27
+ rule.condition = options[:if]
28
+ rule
29
+ end
30
+ end
31
+
32
+ def requires_object(*names)
33
+ names.each do |name|
34
+ @required_object_types << name
35
+ end
36
+ end
37
+
38
+ def depends_on(*paths)
39
+ paths.each do |path|
40
+ @dependencies << path
41
+ end
42
+ end
43
+
44
+ def context(*contexts)
45
+ contexts.each do |context|
46
+ @contexts << context
47
+ end
48
+ end
49
+
50
+ def permission(name, description = nil, &block)
51
+ @group_dsl.permission(name, description, &block)
52
+ end
53
+
54
+ def group(key, &block)
55
+ # Pass the group back to the source group.
56
+ @group_dsl.group(key, &block)
57
+ end
58
+
59
+ def set(&block)
60
+ @group_dsl.set(&block)
61
+ end
62
+
63
+ end
64
+ end
65
+ end
@@ -0,0 +1,36 @@
1
+ module Checken
2
+ class Error < StandardError
3
+ end
4
+
5
+ class PermissionNotFoundError < Error
6
+ end
7
+
8
+ class InvalidObjectError < Error
9
+ end
10
+
11
+ class NoPermissionsFoundError < Error
12
+ end
13
+
14
+ class SchemaError < Error
15
+ end
16
+
17
+ class PermissionDeniedError < Error
18
+ attr_reader :code
19
+ attr_reader :description
20
+ attr_reader :permission
21
+ attr_accessor :rule
22
+ attr_accessor :user
23
+ attr_accessor :object
24
+
25
+ def initialize(code, description, permission = nil)
26
+ @code = code
27
+ @description = description
28
+ @permission = permission
29
+ @memo = {}
30
+ end
31
+
32
+ def message
33
+ "Access denied: #{description} (#{code})"
34
+ end
35
+ end
36
+ end
@@ -0,0 +1,72 @@
1
+ module Checken
2
+ module Extensions
3
+ module ActionController
4
+
5
+ def self.included(base)
6
+ base.extend ClassMethods
7
+ base.helper_method :granted_checken_permissions
8
+ base.class_eval do
9
+ private :checken_user_proxy
10
+ private :restrict
11
+ private :granted_checken_permissions
12
+ end
13
+ end
14
+
15
+ def checken_user_proxy
16
+ # Can be overriden to return the user proxy class which can be used
17
+ # when performing permission checks using `restrict`.
18
+ end
19
+
20
+ def restrict(permission_path, object = nil, options = {})
21
+ if checken_user_proxy.nil?
22
+ user = send(Checken.current_schema.config.current_user_method_name)
23
+ user_proxy = Checken.current_schema.config.user_proxy_class.new(user)
24
+ else
25
+ user_proxy = checken_user_proxy
26
+ end
27
+ granted_permissions = Checken.current_schema.check_permission!(permission_path, user_proxy, object)
28
+ granted_permissions.each do |permission|
29
+ granted_checken_permissions << permission
30
+ end
31
+ end
32
+
33
+ def granted_checken_permissions
34
+ @granted_checken_permissions ||= []
35
+ end
36
+
37
+ module ClassMethods
38
+ def restrict(permission_path, object_or_options = {}, options_if_object_provided = {})
39
+ if object_or_options.is_a?(Hash)
40
+ object = nil
41
+ options = object_or_options
42
+ else
43
+ object = object_or_options
44
+ options = options_if_object_provided
45
+ end
46
+
47
+ restrict_options = options.delete(:restrict_options)
48
+
49
+ before_action(options) do
50
+ if object.is_a?(Proc)
51
+ # If a proc is given, resolve manually
52
+ resolved_object = object.call
53
+ elsif object.is_a?(Symbol)
54
+ if object.to_s =~ /\A@/
55
+ resolved_object = instance_variable_get(object.to_s)
56
+ else
57
+ resolved_object = send(object)
58
+ end
59
+ else
60
+ # Otherwise, the object is nil
61
+ resolved_object = nil
62
+ end
63
+
64
+ restrict(permission_path, resolved_object, restrict_options)
65
+ end
66
+
67
+ end
68
+ end
69
+
70
+ end
71
+ end
72
+ end
@@ -0,0 +1,14 @@
1
+ module Checken
2
+ class IncludedRule
3
+
4
+ attr_reader :key
5
+ attr_reader :block
6
+ attr_accessor :condition
7
+
8
+ def initialize(key, &block)
9
+ @key = key
10
+ @block = block
11
+ end
12
+
13
+ end
14
+ end
@@ -0,0 +1,299 @@
1
+ require 'checken/concerns/has_parents'
2
+ require 'checken/dsl/permission_dsl'
3
+ require 'checken/rule'
4
+ require 'checken/rule_execution'
5
+ require 'checken/included_rule'
6
+
7
+ module Checken
8
+ class Permission
9
+
10
+ include Checken::Concerns::HasParents
11
+
12
+ attr_reader :group
13
+ attr_reader :key
14
+
15
+ # A description of this permission
16
+ #
17
+ # @return [String]
18
+ attr_accessor :description
19
+
20
+ # A list of permission paths that this permission depends on
21
+ #
22
+ # @return [Array<String>]
23
+ attr_reader :dependencies
24
+
25
+ # An array of object type names (as Strings) that the object passed to this
26
+ # permission must be one of. If empty, any object is permitted.
27
+ #
28
+ # @return [Array<String>]
29
+ attr_reader :required_object_types
30
+
31
+ # The name of the contexts that apply to this permission
32
+ #
33
+ # @return [Array<Symbol>]
34
+ attr_reader :contexts
35
+
36
+ # Create a new permission group
37
+ #
38
+ # @param group [Checken::PermissionGroup, nil]
39
+ # @param key [Symbol]
40
+ def initialize(group, key)
41
+ if group.nil?
42
+ raise Error, "Group must be provided when creating a permission"
43
+ end
44
+
45
+ @group = group
46
+ @key = key
47
+ @required_object_types = []
48
+ @dependencies = []
49
+ @contexts = []
50
+ end
51
+
52
+ # Return a description
53
+ #
54
+ # @return [String]
55
+ def description
56
+ @description || "#{path}"
57
+ end
58
+
59
+ # Check this permission and raises an error if not permitted.
60
+ #
61
+ # @param user [Object]
62
+ # @param object [Object]
63
+ # @raises [Checken::PermissionDeniedError]
64
+ # @raises [Checken::InvalidObjectError]
65
+ # @return true
66
+ def check!(user_proxy, object = nil)
67
+ # If we havent' been given a user proxy here, we need to make one. This
68
+ # shouldn't happen very often in production because everything would be
69
+ # encapsulated by the User#can? method.
70
+ unless user_proxy.is_a?(Checken::UserProxy)
71
+ user_proxy = @group.schema.config.user_proxy_class.new(user_proxy)
72
+ end
73
+
74
+ # If we're asking about this permission and we aren't in the correct
75
+ # context, it should be denied always.
76
+ unless @contexts.empty?
77
+ unless @contexts.any? { |c| user_proxy.contexts.include?(c) }
78
+ @group.schema.logger.info "`#{self.path}` not granted to #{user_proxy.description} because not in context."
79
+ error = PermissionDeniedError.new('NotInContext', "Permission '#{self.path}' cannot be granted in the #{user_proxy.contexts.join(',')} context(s). Only allowed for #{@contexts.join(', ')}.", self)
80
+ error.user = user_proxy.user
81
+ error.object = object
82
+ raise error
83
+ end
84
+ end
85
+
86
+ # Check the user has this permission
87
+ unless user_proxy.granted_permissions.include?(self.path)
88
+ @group.schema.logger.info "`#{self.path}` not granted to #{user_proxy.description}"
89
+ error = PermissionDeniedError.new('PermissionNotGranted', "User has not been granted the '#{self.path}' permission", self)
90
+ error.user = user_proxy.user
91
+ error.object = object
92
+ raise error
93
+ end
94
+
95
+ # Check other dependent rules once we've established this
96
+ # user has the base rule. The actual rules won't be checked
97
+ # until we've checked other rules.
98
+ dependencies_as_permissions.each do |dependency_permission|
99
+ @group.schema.logger.info "`#{self.path}` has a dependency of `#{dependency_permission.path}`..."
100
+ dependency_permission.check!(user_proxy, object)
101
+ end
102
+
103
+ # Check any included rules too
104
+ if unsatisifed_rule = self.first_unsatisfied_included_rule(user_proxy, object)
105
+ @group.schema.logger.info "`#{self.path} not granted to #{user_proxy.description} because rule `#{unsatisifed_rule.rule.key}` on `#{self.path}` was not satisified."
106
+ error = PermissionDeniedError.new('IncludedRuleNotSatisifed', "Rule #{unsatisifed_rule.rule.key} (on #{self.path}) was not satisified.", self)
107
+ error.rule = unsatisifed_rule
108
+ error.user = user_proxy.user
109
+ error.object = object
110
+ raise error
111
+ end
112
+
113
+ # Check rules
114
+ if self.required_object_types.empty? || self.required_object_types.include?(object.class.name)
115
+ if unsatisifed_rule = self.first_unsatisfied_rule(user_proxy, object)
116
+ @group.schema.logger.info "`#{self.path} not granted to #{user_proxy.description} because rule `#{unsatisifed_rule.rule.key}` on `#{self.path}` was not satisified."
117
+ error = PermissionDeniedError.new('RuleNotSatisifed', "Rule #{unsatisifed_rule.rule.key} (on #{self.path}) was not satisified.", self)
118
+ error.rule = unsatisifed_rule
119
+ error.user = user_proxy.user
120
+ error.object = object
121
+ raise error
122
+ else
123
+ @group.schema.logger.info "`#{self.path}` granted to #{user_proxy.description}"
124
+ [self, *dependencies_as_permissions]
125
+ end
126
+ else
127
+ # If one of the permission doesn't have the right object type, raise an error
128
+ raise InvalidObjectError, "The #{object.class.name} object provided to permission check for #{self.path} was not valid. Valid object types are: #{self.required_object_types.join(', ')}"
129
+ end
130
+ end
131
+
132
+ # Return a hash of all configured rules
133
+ #
134
+ # @return [Hash]
135
+ def rules
136
+ @rules ||= {}
137
+ end
138
+
139
+ # Add a new rule to this permission
140
+ #
141
+ # @param key [String]
142
+ # @return [Checken::Rule]
143
+ def add_rule(key, rule = nil, &block)
144
+ key = key.to_sym
145
+ if rules[key].nil?
146
+ rule ||= Rule.new(key, &block)
147
+ rules[key] = rule
148
+ else
149
+ raise Error, "Rule with key '#{key}' already exists on this permission"
150
+ end
151
+ end
152
+
153
+ # Return a hash of all configured included rules
154
+ #
155
+ # @return [Hash]
156
+ def included_rules
157
+ @included_rules ||= {}
158
+ end
159
+
160
+ # Add a new rule to this permission
161
+ #
162
+ # @param key [String]
163
+ # @return [Checken::Rule]
164
+ def include_rule(key_or_existing_rule, options = {}, &block)
165
+ if key_or_existing_rule.is_a?(IncludedRule)
166
+ key = key_or_existing_rule.key
167
+ included_rule = key_or_existing_rule
168
+ else
169
+ key = key_or_existing_rule.to_sym
170
+ included_rule = nil
171
+ end
172
+
173
+ if included_rules[key].nil?
174
+ included_rule ||= begin
175
+ new_rule = IncludedRule.new(key, &block)
176
+ new_rule.condition = options[:if]
177
+ new_rule
178
+ end
179
+ included_rules[key] = included_rule
180
+ else
181
+ raise Error, "Rule with key '#{key}' already been included on this permission"
182
+ end
183
+ end
184
+
185
+ # Add a new context to this permission
186
+ #
187
+ # @param context [Symbol]
188
+ # @return [Symbol, false]
189
+ def add_context(context)
190
+ context = context.to_sym
191
+ if self.contexts.include?(context)
192
+ false
193
+ else
194
+ self.contexts << context
195
+ context
196
+ end
197
+ end
198
+
199
+ # Remove all context from this permission
200
+ #
201
+ # @return [Integer]
202
+ def remove_all_contexts
203
+ previous_size = @contexts.size
204
+ @contexts = []
205
+ previous_size
206
+ end
207
+
208
+ # Add a new dependency to this permission
209
+ #
210
+ # @param path [String]
211
+ # @return [String, false]
212
+ def add_dependency(path)
213
+ path = path.to_s
214
+ if dependencies.include?(path)
215
+ false
216
+ else
217
+ dependencies << path
218
+ path
219
+ end
220
+ end
221
+
222
+ # Add a new dependency to this permission
223
+ #
224
+ # @param path [String]
225
+ # @return [String, false]
226
+ def add_required_object_type(type)
227
+ type = type.to_s
228
+ if required_object_types.include?(type)
229
+ false
230
+ else
231
+ required_object_types << type
232
+ type
233
+ end
234
+ end
235
+
236
+ # Check all the rules for this permission and ensure they are compliant.
237
+ #
238
+ # @param [Checken::UserProxy]
239
+ # @param [Object]
240
+ # @return [Checken::Rule, false] false if all rules are satisified
241
+ def first_unsatisfied_rule(user_proxy, object)
242
+ self.rules.values.each do |rule|
243
+ rule_execution = RuleExecution.new(rule, user_proxy.user, object)
244
+ unless rule_execution.satisfied?
245
+ return rule_execution
246
+ end
247
+ end
248
+ nil
249
+ end
250
+
251
+ def first_unsatisfied_included_rule(user_proxy, object)
252
+ self.included_rules.values.each do |included_rule|
253
+
254
+ if included_rule.condition && !included_rule.condition.call(user_proxy.user, object)
255
+ # If the inclusion has a condition, check that and skip this
256
+ # included rule if it's not valid.
257
+ next
258
+ end
259
+
260
+ if included_rule.block
261
+ translated_object = included_rule.block.call(object)
262
+ else
263
+ translated_object = object
264
+ end
265
+
266
+ rule = @group.all_defined_rules[included_rule.key]
267
+ if rule.nil?
268
+ raise Error, "No defined rule with key #{included_rule.key} is available for #{self.path}"
269
+ end
270
+
271
+ unless rule.required_object_types.empty? || rule.required_object_types.include?(translated_object.class.name)
272
+ raise InvalidObjectError, "The #{translated_object.class.name} object provided to included rule (#{rule.key}) for #{self.path} was not valid. Valid object types are: #{rule.required_object_types.join(', ')}"
273
+ end
274
+
275
+ rule_execution = RuleExecution.new(rule, user_proxy.user, translated_object)
276
+ unless rule_execution.satisfied?
277
+ return rule_execution
278
+ end
279
+ end
280
+ nil
281
+ end
282
+
283
+ def dsl(&block)
284
+ dsl = DSL::PermissionDSL.new(self)
285
+ dsl.instance_eval(&block) if block_given?
286
+ dsl
287
+ end
288
+
289
+ # Return an array of all dependencies as permissions
290
+ #
291
+ # @return [Array<Checken::Permission>]
292
+ def dependencies_as_permissions
293
+ @dependencies_as_permissions ||= dependencies.map do |path|
294
+ @group.schema.root_group.find_permissions_from_path(path)
295
+ end.flatten
296
+ end
297
+
298
+ end
299
+ end
@@ -0,0 +1,169 @@
1
+ require 'checken/permission'
2
+ require 'checken/error'
3
+ require 'checken/concerns/has_parents'
4
+ require 'checken/dsl/group_dsl'
5
+
6
+ module Checken
7
+ class PermissionGroup
8
+
9
+ include Checken::Concerns::HasParents
10
+
11
+ attr_accessor :name
12
+ attr_accessor :description
13
+ attr_reader :schema
14
+ attr_reader :group
15
+ attr_reader :key
16
+ attr_reader :groups
17
+ attr_reader :permissions
18
+ attr_reader :defined_rules
19
+
20
+ # Return a group or permission matching the given key
21
+ #
22
+ # @param group_or_permission_key [Symbol]
23
+ # @return [Checken::PermissionGroup, Checken::Permission, nil]
24
+ def [](group_or_permission_key)
25
+ @groups[group_or_permission_key.to_sym] || @permissions[group_or_permission_key.to_sym]
26
+ end
27
+
28
+ # Create a new permission group
29
+ #
30
+ # @param group [Checken::PermissionGroup, nil]
31
+ # @param key [Symbol]
32
+ def initialize(schema, group, key = nil)
33
+ if group && key.nil?
34
+ raise Error, "Cannot create a new non-root permission group without a key"
35
+ elsif group.nil? && key
36
+ raise Error, "Cannot create a new root permission group with a key"
37
+ end
38
+
39
+ @schema = schema
40
+ @group = group
41
+ @key = key.to_sym if key
42
+ @groups = {}
43
+ @permissions = {}
44
+ @defined_rules = {}
45
+ end
46
+
47
+ # Return a group or a permission that matches the given key
48
+ #
49
+ # @return [Cheken::PermissionGroup, Checken::Permission]
50
+ def group_or_permission(key)
51
+ key = key.to_sym
52
+ @groups[key] || @permissions[key]
53
+ end
54
+
55
+ # Adds a new sub group to this group
56
+ #
57
+ # @param key [String]
58
+ # @return [Checken::PermissionGroup]
59
+ def add_group(key)
60
+ key = key.to_sym
61
+ if group_or_permission(key).nil?
62
+ @groups[key] = PermissionGroup.new(@schema, self, key)
63
+ else
64
+ raise Error, "Group or permission with key of #{key} already exists"
65
+ end
66
+ end
67
+
68
+ # Adds a permission to the group
69
+ #
70
+ # @param key [String]
71
+ # @return [Checken::Permission]
72
+ def add_permission(key)
73
+ key = key.to_sym
74
+ if group_or_permission(key).nil?
75
+ @permissions[key] = Permission.new(self, key)
76
+ else
77
+ raise Error, "Group or permission with key of #{key} already exists"
78
+ end
79
+ end
80
+
81
+ # Define a new global rule that can be used by any permissions
82
+ #
83
+ # @param key [Symbol]
84
+ # @param required_object_types [Array]
85
+ # @return [Checken::Rule]
86
+ def define_rule(key, *required_object_types, &block)
87
+ if all_defined_rules[key.to_sym]
88
+ raise Checken::Error, "Rule #{key} has already been defined"
89
+ else
90
+ rule = Rule.new(key, &block)
91
+ required_object_types.each { |rot| rule.required_object_types << rot }
92
+ @defined_rules[key.to_sym] = rule
93
+ rule
94
+ end
95
+ end
96
+
97
+ # Return an array of all defined rules on this and all upper groups
98
+ #
99
+ # @return [Array<Checken::Rule>]
100
+ def all_defined_rules
101
+ if @group
102
+ @defined_rules.merge(@group.all_defined_rules)
103
+ else
104
+ @defined_rules
105
+ end
106
+ end
107
+
108
+ # Find permissions from a path
109
+ def find_permissions_from_path(path)
110
+ unless path.is_a?(String) && path.length > 0
111
+ raise PermissionNotFoundError, "Must provide a permission path"
112
+ end
113
+
114
+ path_parts = path.split('.').map(&:to_sym)
115
+ last_group_or_permission = self
116
+ while part = path_parts.shift
117
+ if part == :*
118
+ if path_parts.empty?
119
+ # We're at the end of the path, that's an acceptable place for a wildcard.
120
+ # Return all the permissions in the final group.
121
+ return last_group_or_permission.permissions.values
122
+ else
123
+ raise Error, "Wildcards must be placed at the end of a permission path"
124
+ end
125
+ elsif part == :** && path_parts[0] == :*
126
+ # If we get a **.* wildcard, we should find permissions in the sub groups too.
127
+ return last_group_or_permission.all_permissions
128
+ else
129
+ last_group_or_permission = last_group_or_permission.group_or_permission(part)
130
+ if last_group_or_permission.is_a?(Permission) && !path_parts.empty?
131
+ raise Error, "Permission found too early in the path. Permission key should always be at the end of the path."
132
+ elsif last_group_or_permission.nil?
133
+ raise PermissionNotFoundError, "No permission found matching '#{path}'"
134
+ end
135
+ end
136
+ end
137
+
138
+ if last_group_or_permission.is_a?(Permission)
139
+ [last_group_or_permission]
140
+ else
141
+ raise Error, "Last part of path was not a permission. Last part of permission must be a path"
142
+ end
143
+ end
144
+
145
+ # Return all permissions in this group and all the permissions in its sub groups
146
+ #
147
+ # @return [Array<Checken::Permission>]
148
+ def all_permissions
149
+ array = []
150
+ @permissions.each { |_, permission| array << permission }
151
+ @groups.each do |_, group|
152
+ group.all_permissions.each do |permission|
153
+ array << permission
154
+ end
155
+ end
156
+ array
157
+ end
158
+
159
+ # Execute the given block within the group DSL
160
+ #
161
+ # @return [Checken::DSL::GroupDSL]
162
+ def dsl(options = {}, &block)
163
+ dsl = DSL::GroupDSL.new(self, options)
164
+ dsl.instance_eval(&block)
165
+ dsl
166
+ end
167
+
168
+ end
169
+ end
@@ -0,0 +1,32 @@
1
+ require 'checken/schema'
2
+ require 'checken/reload_middleware'
3
+
4
+ module Checken
5
+ class Railtie < Rails::Railtie
6
+
7
+ initializer 'checken.initialize' do |app|
8
+ # Initialize a new schema for the application when it is loaded.
9
+ Checken::Schema.instance = Checken::Schema.new
10
+
11
+ # Default configuration
12
+ Checken::Schema.instance.configure do |config|
13
+ # Set the logger to log into a file in the log directory.
14
+ # This can be overriden later if needed.
15
+ config.log_path = Rails.root.join('log', 'checken.log')
16
+ end
17
+
18
+ # Load from a directory
19
+ Checken::Schema.instance.load_from_directory(Rails.root.join('permissions'))
20
+
21
+ # Add controller options
22
+ ActiveSupport.on_load :action_controller do
23
+ require 'checken/extensions/action_controller'
24
+ include Checken::Extensions::ActionController
25
+ end
26
+
27
+ # Insert the middleware
28
+ app.middleware.insert_before(ActionDispatch::Callbacks, Checken::ReloadMiddleware)
29
+ end
30
+
31
+ end
32
+ end
@@ -0,0 +1,25 @@
1
+ require 'checken/schema'
2
+
3
+ module Checken
4
+ class ReloadMiddleware
5
+
6
+ MUTEX = Mutex.new
7
+
8
+ def initialize(app)
9
+ @app = app
10
+ end
11
+
12
+ def call(env)
13
+ # If we need to reload, we shall do that here.
14
+ unless Rails.application.config.cache_classes
15
+ MUTEX.synchronize do
16
+ Checken::Schema.instance.reload
17
+ end
18
+ end
19
+
20
+ # Call our app as normal
21
+ @app.call(env)
22
+ end
23
+
24
+ end
25
+ end
@@ -0,0 +1,22 @@
1
+ module Checken
2
+ class Rule
3
+
4
+ attr_reader :key
5
+ attr_reader :required_object_types
6
+
7
+ def initialize(key, &block)
8
+ @key = key
9
+ @block = block
10
+ @required_object_types = []
11
+ end
12
+
13
+ # Are we satisifed that this rule's condition is true?
14
+ #
15
+ # @param user [Checken::User]
16
+ # @return [Boolean]
17
+ def satisfied?(rule_execution)
18
+ !!@block.call(rule_execution.user, rule_execution.object, rule_execution)
19
+ end
20
+
21
+ end
22
+ end
@@ -0,0 +1,21 @@
1
+ module Checken
2
+ class RuleExecution
3
+
4
+ attr_reader :rule
5
+ attr_reader :user
6
+ attr_reader :object
7
+ attr_reader :memo
8
+
9
+ def initialize(rule, user, object = nil)
10
+ @rule = rule
11
+ @user = user
12
+ @object = object
13
+ @memo = {}
14
+ end
15
+
16
+ def satisfied?
17
+ @rule.satisfied?(self)
18
+ end
19
+
20
+ end
21
+ end
@@ -0,0 +1,132 @@
1
+ require 'checken/config'
2
+ require 'checken/permission'
3
+ require 'checken/permission_group'
4
+
5
+ module Checken
6
+ class Schema
7
+
8
+ class << self
9
+ # This can be used for storing a global instance of a schema for an application
10
+ # that may require such a thing.
11
+ attr_accessor :instance
12
+ end
13
+
14
+ attr_reader :root_group
15
+ attr_reader :config
16
+
17
+ # Create a new schema
18
+ #
19
+ def initialize
20
+ @root_group = PermissionGroup.new(self, nil)
21
+ @config = Config.new
22
+ end
23
+
24
+ # Add configuration for this schema
25
+ def configure(&block)
26
+ block.call(@config)
27
+ end
28
+
29
+ # Does the given user have the appropriate permissions to handle?
30
+ #
31
+ # @param permission_path [String]
32
+ # @param user [User]
33
+ # @param object [Object]
34
+ def check_permission!(permission_path, user_proxy, object = nil)
35
+ permissions = @root_group.find_permissions_from_path(permission_path)
36
+
37
+ if permissions.size == 1
38
+ # If we only have a single permission, we'll just run the check
39
+ # as normal through the check process. This will work as normal and raise
40
+ # and return directly.
41
+ permissions.first.check!(user_proxy, object)
42
+
43
+ elsif permissions.size == 0
44
+ # No permissions found
45
+ raise Checken::NoPermissionsFoundError, "No permissions found matching #{permission_path}"
46
+
47
+ else
48
+ # If we have multiple permissions, we need to loop through each permission
49
+ # and handle them as appropriate.
50
+ granted_permissions = []
51
+ ungranted_permissions = 0
52
+ permissions.each do |permission|
53
+ begin
54
+ permission.check!(user_proxy, object).each do |permission|
55
+ granted_permissions << permission
56
+ end
57
+ rescue Checken::PermissionDeniedError => e
58
+ if e.code == 'PermissionNotGranted'
59
+ # If the permission isn't granted, update the counter so we can
60
+ # keep track of the number of ungranted permissions.
61
+ ungranted_permissions += 1
62
+ else
63
+ # Raise other errors as normal
64
+ raise
65
+ end
66
+ end
67
+ end
68
+
69
+ if permissions.size == ungranted_permissions
70
+ # If the user is ungranted to all the found permissions, they do not
71
+ # have access and should be denied.
72
+ raise PermissionDeniedError.new('PermissionNotGranted', "User does not have any permissions #{permissions.map(&:path).join(', ')} permission.", permissions.first)
73
+ else
74
+ granted_permissions
75
+ end
76
+ end
77
+ end
78
+
79
+ # Load a set of schema files from a given directory
80
+ #
81
+ # @param path [String]
82
+ # @return [Boolean]
83
+ def load_from_directory(path)
84
+ # Store the load path for future reload
85
+ @load_path = path
86
+
87
+ # If the path doesn't exist, just return false. We won't load anything
88
+ # if the directory hasnt' been loaded yet.
89
+ unless File.exist?(path)
90
+ return false
91
+ end
92
+
93
+ # Check that the directory is a a directory
94
+ unless File.directory?(path)
95
+ raise Error, "Path to directory must be a directory. #{path} is not a directory."
96
+ end
97
+
98
+ # Read all the files and pass them through the DSL for the root schema.
99
+ # Each directory is a group. Everything in the root will be at the root.
100
+ Dir[File.join(path, "**", "*.rb")].each do |path|
101
+ contents = File.read(path)
102
+ dsl = DSL::GroupDSL.new(@root_group)
103
+ dsl.instance_eval(contents, path)
104
+ end
105
+
106
+ logger.info "Loaded permission schema from #{path}"
107
+
108
+ true
109
+ end
110
+
111
+ # Reload the schema from the directory if possible
112
+ #
113
+ # @return [void]
114
+ def reload
115
+ if @load_path
116
+ @root_group = PermissionGroup.new(self, nil)
117
+ load_from_directory(@load_path)
118
+ true
119
+ else
120
+ raise Error, "Cannot reload a schema that wasn't loaded from a directory"
121
+ end
122
+ end
123
+
124
+ # Return the logger
125
+ #
126
+ # @return [Logger]
127
+ def logger
128
+ @config.logger
129
+ end
130
+
131
+ end
132
+ end
@@ -0,0 +1,36 @@
1
+ module Checken
2
+ module User
3
+
4
+ # Can the user perform the given action?
5
+ #
6
+ # @param permission_path [String] the permission name/path
7
+ # @option options [Checken::Schema] :schema an optional scheme to use
8
+ # @return [Boolean]
9
+ def check_permission!(permission_path, object_or_options = {}, options_when_object_provided = {})
10
+ if object_or_options.is_a?(Hash)
11
+ object = nil
12
+ options = object_or_options
13
+ else
14
+ object = object_or_options
15
+ options = options_when_object_provided
16
+ end
17
+
18
+ schema = options.delete(:schema) || Checken.current_schema || Checken::Schema.instance
19
+
20
+ if schema.nil?
21
+ raise Error, "Could not determine a schema. Make sure you set Checken.current_schema or pass :schema to can? methods."
22
+ end
23
+
24
+ user_proxy = schema.config.user_proxy_class.new(self)
25
+ schema.check_permission!(permission_path, user_proxy, object)
26
+ end
27
+
28
+ def can?(*args)
29
+ check_permission!(*args)
30
+ true
31
+ rescue Checken::PermissionDeniedError => e
32
+ false
33
+ end
34
+
35
+ end
36
+ end
@@ -0,0 +1,49 @@
1
+ module Checken
2
+ # The user proxy class sits on top of a user and provides the methods that
3
+ # checken needs. You can write your own proxy or use the default. If you use
4
+ # the default you'll need to make sure that the users you provide implement
5
+ # the methods this proxy will call.
6
+ #
7
+ # All interactions between checken and a user will happen via a proxy. This
8
+ # default class is a useful benchmark list of how your user should behave.
9
+ class UserProxy
10
+
11
+ attr_accessor :user
12
+
13
+ # @param user [Object]
14
+ def initialize(user)
15
+ @user = user
16
+ end
17
+
18
+ # Return a suitable description for this user for use in log files
19
+ #
20
+ # @return [String]
21
+ def description
22
+ if @user.respond_to?(:id)
23
+ "#{@user.class}##{@user.id}"
24
+ else
25
+ "#{@user.class}"
26
+ end
27
+ end
28
+
29
+ # Returns an array of permissions that this user has permission to
30
+ # use.
31
+ #
32
+ # @return [Array<String>]
33
+ def granted_permissions
34
+ @user.assigned_checken_permissions
35
+ end
36
+
37
+ # An array of contexts that this user is part of
38
+ #
39
+ # @return [Array<Symbol>]
40
+ def contexts
41
+ if @user.respond_to?(:checken_contexts)
42
+ @user.checken_contexts
43
+ else
44
+ []
45
+ end
46
+ end
47
+
48
+ end
49
+ end
@@ -0,0 +1,3 @@
1
+ module Checken
2
+ VERSION = '0.0.3'
3
+ end
data/lib/checken.rb ADDED
@@ -0,0 +1,20 @@
1
+ require 'checken/version'
2
+ require 'checken/user'
3
+
4
+ module Checken
5
+
6
+ # Return the current global scheme
7
+ def self.current_schema
8
+ Thread.current[:cheken_schema] || Checken::Schema.instance
9
+ end
10
+
11
+ # Set the current global schema
12
+ def self.current_schema=(schema)
13
+ Thread.current[:cheken_schema] = schema
14
+ end
15
+
16
+ end
17
+
18
+ if defined?(Rails)
19
+ require 'checken/railtie'
20
+ end
metadata ADDED
@@ -0,0 +1,64 @@
1
+ --- !ruby/object:Gem::Specification
2
+ name: checken
3
+ version: !ruby/object:Gem::Version
4
+ version: 0.0.3
5
+ platform: ruby
6
+ authors:
7
+ - Adam Cooke
8
+ autorequire:
9
+ bindir: bin
10
+ cert_chain: []
11
+ date: 2025-03-27 00:00:00.000000000 Z
12
+ dependencies: []
13
+ description: An authorization framework for Ruby & Rails applications.
14
+ email:
15
+ - adam@krystal.io
16
+ executables: []
17
+ extensions: []
18
+ extra_rdoc_files: []
19
+ files:
20
+ - lib/checken.rb
21
+ - lib/checken/concerns/has_parents.rb
22
+ - lib/checken/config.rb
23
+ - lib/checken/dsl/group_dsl.rb
24
+ - lib/checken/dsl/permission_dsl.rb
25
+ - lib/checken/dsl/set_dsl.rb
26
+ - lib/checken/error.rb
27
+ - lib/checken/extensions/action_controller.rb
28
+ - lib/checken/included_rule.rb
29
+ - lib/checken/permission.rb
30
+ - lib/checken/permission_group.rb
31
+ - lib/checken/railtie.rb
32
+ - lib/checken/reload_middleware.rb
33
+ - lib/checken/rule.rb
34
+ - lib/checken/rule_execution.rb
35
+ - lib/checken/schema.rb
36
+ - lib/checken/user.rb
37
+ - lib/checken/user_proxy.rb
38
+ - lib/checken/version.rb
39
+ homepage: https://github.com/krystal/checken
40
+ licenses:
41
+ - MIT
42
+ metadata:
43
+ rubygems_mfa_required: 'false'
44
+ changelog_uri: https://github.com/krystal/checken/CHANGELOG.md
45
+ post_install_message:
46
+ rdoc_options: []
47
+ require_paths:
48
+ - lib
49
+ required_ruby_version: !ruby/object:Gem::Requirement
50
+ requirements:
51
+ - - ">="
52
+ - !ruby/object:Gem::Version
53
+ version: '0'
54
+ required_rubygems_version: !ruby/object:Gem::Requirement
55
+ requirements:
56
+ - - ">="
57
+ - !ruby/object:Gem::Version
58
+ version: '0'
59
+ requirements: []
60
+ rubygems_version: 3.5.22
61
+ signing_key:
62
+ specification_version: 4
63
+ summary: This gem provides a friendly DSL for managing and enforcing permissions.
64
+ test_files: []