accessly 0.0.1

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.
@@ -0,0 +1,8 @@
1
+ require "active_record"
2
+
3
+ module Accessly
4
+ class PermittedActionOnObject < ActiveRecord::Base
5
+ belongs_to :actor, polymorphic: true
6
+ belongs_to :object, polymorphic: true
7
+ end
8
+ end
@@ -0,0 +1,95 @@
1
+ module Accessly
2
+ module Permission
3
+ class Grant < Accessly::Base
4
+
5
+ # Create an instance of Accessly::Permission::Grant
6
+ # Pass in an ActiveRecord::Base for actor
7
+ #
8
+ # @param actor [ActiveRecord::Base] The actor to grant permission
9
+ def initialize(actor)
10
+ super(actor)
11
+ @actor = case actor
12
+ when ActiveRecord::Base
13
+ actor
14
+ else
15
+ raise Accessly::GrantError.new("Actor is not an ActiveRecord::Base object")
16
+ end
17
+ end
18
+
19
+ # Grant a permission to an actor.
20
+ # @return [nil]
21
+ # @overload grant!(action_id, object_type)
22
+ # Allow permission on a general action in the given namespace represented by object_type.
23
+ # A grant is universally unique and is enforced at the database level.
24
+ #
25
+ # @param action_id [Integer] The action to grant for the object
26
+ # @param object_type [String] The namespace of the given action_id.
27
+ # @raise [Accessly::GrantError] if the operation does not succeed
28
+ # @return [nil] Returns nil if successful
29
+ #
30
+ # @example
31
+ # # Allow the user access to posts for action id 3
32
+ # Accessly::Permission::Grant.new(user).grant!(3, "posts")
33
+ # @example
34
+ # # Allow the user access to posts for action id 3 on a segment
35
+ # Accessly::Permission::Grant.new(user).on_segment(1).grant!(3, "posts")
36
+ #
37
+ # @overload grant!(action_id, object_type, object_id)
38
+ # Allow permission on an ActiveRecord object.
39
+ # A grant is universally unique and is enforced at the database level.
40
+ #
41
+ # @param action_id [Integer] The action to grant for the object
42
+ # @param object_type [ActiveRecord::Base] The ActiveRecord model that receives a permission grant.
43
+ # @param object_id [Integer] The id of the ActiveRecord object which receives a permission grant
44
+ # @raise [Accessly::GrantError] if the operation does not succeed
45
+ # @return [nil] Returns nil if successful
46
+ #
47
+ # @example
48
+ # # Allow the user access to Post 7 for action id 3
49
+ # Accessly::Permission::Grant.new(user).grant!(3, Post, 7)
50
+ # @example
51
+ # # Allow the user access to Post 7 for action id 3 on a segment
52
+ # Accessly::Permission::Grant.new(user).on_segment(1).grant!(3, Post, 7)
53
+ def grant!(action_id, object_type, object_id = nil)
54
+ if object_id.nil?
55
+ general_action_grant(action_id, object_type)
56
+ else
57
+ object_action_grant(action_id, object_type, object_id)
58
+ end
59
+ end
60
+
61
+ private
62
+
63
+ def general_action_grant(action_id, object_type)
64
+ Accessly::PermittedAction.create!(
65
+ id: SecureRandom.uuid,
66
+ segment_id: @segment_id,
67
+ actor: @actor,
68
+ action: action_id,
69
+ object_type: String(object_type)
70
+ )
71
+ nil
72
+ rescue ActiveRecord::RecordNotUnique
73
+ nil
74
+ rescue => e
75
+ raise Accessly::GrantError.new("Could not grant action #{action_id} on object #{object_type} for actor #{@actor} because #{e}")
76
+ end
77
+
78
+ def object_action_grant(action_id, object_type, object_id)
79
+ Accessly::PermittedActionOnObject.create!(
80
+ id: SecureRandom.uuid,
81
+ segment_id: @segment_id,
82
+ actor: @actor,
83
+ action: action_id,
84
+ object_type: String(object_type),
85
+ object_id: object_id
86
+ )
87
+ nil
88
+ rescue ActiveRecord::RecordNotUnique
89
+ nil
90
+ rescue => e
91
+ raise Accessly::GrantError.new("Could not grant action #{action_id} on object #{object_type} with id #{object_id} for actor #{@actor} because #{e}")
92
+ end
93
+ end
94
+ end
95
+ end
@@ -0,0 +1,87 @@
1
+ module Accessly
2
+ module Permission
3
+ class Revoke < Accessly::Base
4
+
5
+ # Create an instance of Accessly::Permission::Revoke
6
+ # Pass in an ActiveRecord::Base for actor
7
+ #
8
+ # @param actor [ActiveRecord::Base] The actor to revoke permission
9
+ def initialize(actor)
10
+ super(actor)
11
+ @actor = case actor
12
+ when ActiveRecord::Base
13
+ actor
14
+ else
15
+ raise Accessly::RevokeError.new("Actor is not an ActiveRecord::Base object")
16
+ end
17
+ end
18
+
19
+ # Revoke a permission for an actor.
20
+ # @return [nil]
21
+ # @overload revoke!(action_id, object_type)
22
+ # Revoke permission on a general action in the given namespace represented by object_type.
23
+ #
24
+ # @param action_id [Integer] The action to revoke
25
+ # @param object_type [String] The namespace of the given action_id.
26
+ # @raise [Accessly::RevokeError] if the operation does not succeed
27
+ # @return [nil] Returns nil if successful
28
+ #
29
+ # @example
30
+ # # Remove user access to posts for action id 3
31
+ # Accessly::Permission::Revoke.new(user).revoke!(3, Post)
32
+ # @example
33
+ # # Remove user access to posts for action id 3 on a segment
34
+ # Accessly::Permission::Revoke.new(user).on_segment(1).revoke!(3, Post)
35
+ #
36
+ # @overload revoke!(action_id, object_type, object_id)
37
+ # Revoke permission on an ActiveRecord object.
38
+ #
39
+ # @param action_id [Integer] The action to revoke
40
+ # @param object_type [ActiveRecord::Base] The ActiveRecord model that removes a permission.
41
+ # @param object_id [Integer] The id of the ActiveRecord object that removes a permission
42
+ # @raise [Accessly::RevokeError] if the operation does not succeed
43
+ # @return [nil] Returns nil if successful
44
+ #
45
+ # @example
46
+ # # Remove user access to Post 7 for action id 3
47
+ # Accessly::Permission::Revoke.new(user).revoke!(3, Post, 7)
48
+ # @example
49
+ # # Remove user access to Post 7 for action id 3 on a segment
50
+ # Accessly::Permission::Revoke.new(user).on_segment(1).revoke!(3, Post, 7)
51
+ def revoke!(action_id, object_type, object_id = nil)
52
+ if object_id.nil?
53
+ general_action_revoke(action_id, object_type)
54
+ else
55
+ object_action_revoke(action_id, object_type, object_id)
56
+ end
57
+ end
58
+
59
+ private
60
+
61
+ def general_action_revoke(action_id, object_type)
62
+ Accessly::PermittedAction.where(
63
+ segment_id: @segment_id,
64
+ actor: @actor,
65
+ action: action_id,
66
+ object_type: String(object_type)
67
+ ).delete_all
68
+ nil
69
+ rescue => e
70
+ raise Accessly::RevokeError.new("Could not revoke action #{action_id} on object #{object_type} for actor #{@actor} because #{e}")
71
+ end
72
+
73
+ def object_action_revoke(action_id, object_type, object_id)
74
+ Accessly::PermittedActionOnObject.where(
75
+ segment_id: @segment_id,
76
+ actor: @actor,
77
+ action: action_id,
78
+ object_type: String(object_type),
79
+ object_id: object_id
80
+ ).delete_all
81
+ nil
82
+ rescue => e
83
+ raise Accessly::RevokeError.new("Could not revoke #{action_id} on object #{object_type} with id #{object_id} for actor #{@actor} because #{e}")
84
+ end
85
+ end
86
+ end
87
+ end
@@ -0,0 +1,40 @@
1
+ require "accessly/query_builder"
2
+
3
+ module Accessly
4
+ module PermittedActions
5
+ class Base
6
+
7
+ def initialize(actors, segment_id)
8
+ @actors = actors
9
+ @segment_id = segment_id
10
+ end
11
+
12
+ protected
13
+
14
+ def past_lookups
15
+ @_past_lookups ||= {}
16
+ end
17
+
18
+ def find_or_set_value(*keys, &query)
19
+ found_value = past_lookups.dig(*keys)
20
+
21
+ if found_value.nil?
22
+ found_value = query.call
23
+ set_value(*keys, value: found_value)
24
+ end
25
+
26
+ found_value
27
+ end
28
+
29
+ def set_value(*keys, value:)
30
+ lookup = past_lookups
31
+ keys[0..-2].each do |key|
32
+ lookup[key] ||= {}
33
+ lookup = lookup[key]
34
+ end
35
+
36
+ lookup[keys[-1]] = value
37
+ end
38
+ end
39
+ end
40
+ end
@@ -0,0 +1,65 @@
1
+ module Accessly
2
+ module PermittedActions
3
+ class OnObjectQuery < Base
4
+
5
+ def initialize(actors, segment_id)
6
+ super(actors, segment_id)
7
+ end
8
+ # Ask whether the actor has permission to perform action_id
9
+ # on a given record.
10
+ #
11
+ # Lookups are cached in the object to prevent redundant database calls.
12
+ #
13
+ # @param action_id [Integer, Array<Integer>] The action or actions we're checking whether the actor has. If this is an array, then the check is ORed.
14
+ # @param object_type [ActiveRecord::Base] The ActiveRecord model which we're checking for permission on.
15
+ # @param object_id [Integer] The id of the ActiveRecord object which we're checking for permission on.
16
+ # @return [Boolean] Returns true if actor has been granted the permission on the specified record, false otherwise.
17
+ #
18
+ # @example
19
+ # # Can the user perform the action with id 5 for the Post with id 7?
20
+ # Accessly::Query.new(user).can?(5, Post, 7)
21
+ # @example
22
+ # # Can the user perform the action with id 5 for the Post with id 7 on segment 1?
23
+ # Accessly::Query.new(user).on_segment(1).can?(5, Post, 7)
24
+ def can?(action_id, object_type, object_id)
25
+ find_or_set_value(action_id, object_type, object_id) do
26
+ Accessly::QueryBuilder.with_actors(Accessly::PermittedActionOnObject, @actors)
27
+ .where(
28
+ segment_id: @segment_id,
29
+ action: action_id,
30
+ object_type: String(object_type),
31
+ object_id: object_id
32
+ ).exists?
33
+ end
34
+ end
35
+
36
+ # Returns an ActiveRecord::Relation of ids in the namespace for
37
+ # which the actor has permission to perform action_id.
38
+ #
39
+ # @param action_id [Integer] The action we're checking on the actor in the namespace.
40
+ # @param namespace [String] The namespace to check actor permissions.
41
+ # @return [ActiveRecord::Relation]
42
+ #
43
+ # @example
44
+ # # Give me the list of Post ids on which the user has permission to perform action_id 3
45
+ # Accessly::Query.new(user).list(3, Post)
46
+ # @example
47
+ # # Give me the list of Post ids on which the user has permission to perform action_id 3 on segment 1
48
+ # Accessly::Query.new(user).on_segment(1).list(3, Post)
49
+ # @example
50
+ # # Give me the list of Post ids on which the user and its groups has permission to perform action_id 3
51
+ # Accessly::Query.new(User => user.id, Group => [1,2]).list(3, Post)
52
+ # @example
53
+ # # Give me the list of Post ids on which the user and its groups has permission to perform action_id 3 on segment 1
54
+ # Accessly::Query.new(User => user.id, Group => [1,2]).on_segment(1).list(3, Post)
55
+ def list(action_id, namespace)
56
+ Accessly::QueryBuilder.with_actors(Accessly::PermittedActionOnObject, @actors)
57
+ .where(
58
+ segment_id: @segment_id,
59
+ action: Integer(action_id),
60
+ object_type: String(namespace),
61
+ ).select(:object_id)
62
+ end
63
+ end
64
+ end
65
+ end
@@ -0,0 +1,42 @@
1
+ require "accessly/permitted_actions/base"
2
+
3
+ module Accessly
4
+ module PermittedActions
5
+ class Query < Base
6
+
7
+ def initialize(actors, segment_id)
8
+ super(actors, segment_id)
9
+ end
10
+
11
+ # Ask whether the actor has permission to perform action_id
12
+ # in the given namespace. Multiple actions can have the same id
13
+ # as long as their namespace is different. The namespace can be
14
+ # any String. We recommend using namespace to group a class of
15
+ # permissions, such as to group parts of a particular feature
16
+ # in your application.
17
+ #
18
+ # Lookups are cached in the object to prevent redundant database calls.
19
+ #
20
+ # @param action_id [Integer, Array<Integer>] The action or actions we're checking whether the actor has. If this is an array, then the check is ORed.
21
+ # @param object_type [String] The namespace of the given action_id.
22
+ # @return [Boolean] Returns true if actor has been granted the permission, false otherwise.
23
+ #
24
+ # @example
25
+ # # Can the user perform the action with id 3 for posts?
26
+ # Accessly::Query.new(user).can?(3, Post)
27
+ # @example
28
+ # # Can the user perform the action with id 3 for posts on segment 1?
29
+ # Accessly::Query.new(user).on_segment(1).can?(3, Post)
30
+ def can?(action_id, object_type)
31
+ find_or_set_value(action_id, object_type) do
32
+ Accessly::QueryBuilder.with_actors(Accessly::PermittedAction, @actors)
33
+ .where(
34
+ segment_id: @segment_id,
35
+ action: action_id,
36
+ object_type: String(object_type),
37
+ ).exists?
38
+ end
39
+ end
40
+ end
41
+ end
42
+ end
@@ -0,0 +1,269 @@
1
+ module Accessly
2
+ module Policy
3
+ class Base
4
+
5
+ attr_reader :actor
6
+
7
+ def initialize(actor)
8
+ @actor = actor
9
+ end
10
+
11
+ def self.actions(actions)
12
+ _actions.merge!(actions)
13
+ actions.each do |action, action_id|
14
+ _define_action_methods(action, action_id)
15
+ end
16
+ end
17
+
18
+ def self.actions_on_objects(actions_on_objects)
19
+ _actions_on_objects.merge!(actions_on_objects)
20
+ actions_on_objects.each do |action, action_id|
21
+ _define_action_methods(action, action_id)
22
+ end
23
+ end
24
+
25
+ def self.namespace
26
+ String(self)
27
+ end
28
+
29
+ def namespace
30
+ self.class.namespace
31
+ end
32
+
33
+ def self.model_scope
34
+ raise ArgumentError.new("#model_scope is not defined on #{self.name}.")
35
+ end
36
+
37
+ def model_scope
38
+ self.class.model_scope
39
+ end
40
+
41
+ def unrestricted?
42
+ false
43
+ end
44
+
45
+ def segment_id
46
+ nil
47
+ end
48
+
49
+ def grant(action, object = nil)
50
+ object_id = _get_object_id(object)
51
+
52
+ action_id = if object_id.nil?
53
+ _get_general_action_id!(action)
54
+ else
55
+ _get_action_on_object_id!(action)
56
+ end
57
+
58
+ grant_object.grant!(action_id, namespace, object_id)
59
+ end
60
+
61
+ def revoke(action, object = nil)
62
+ object_id = _get_object_id(object)
63
+
64
+ action_id = if object_id.nil?
65
+ _get_general_action_id!(action)
66
+ else
67
+ _get_action_on_object_id!(action)
68
+ end
69
+
70
+ revoke_object.revoke!(action_id, namespace, object_id)
71
+ end
72
+
73
+ def accessly_query
74
+ @_accessly_query ||= begin
75
+ query = Accessly::Query.new(actor)
76
+ query.on_segment(segment_id) unless segment_id.nil?
77
+ query
78
+ end
79
+ end
80
+
81
+ def grant_object
82
+ grant_object = Accessly::Permission::Grant.new(actor)
83
+ grant_object.on_segment(segment_id) unless segment_id.nil?
84
+
85
+ grant_object
86
+ end
87
+
88
+ def revoke_object
89
+ revoke_object = Accessly::Permission::Revoke.new(actor)
90
+ revoke_object.on_segment(segment_id) unless segment_id.nil?
91
+
92
+ revoke_object
93
+ end
94
+
95
+ private
96
+
97
+ # Determines whether the caller is trying to call an action method
98
+ # in the format `action_name?`. If so, this calls that method with
99
+ # the given arguments.
100
+ def method_missing(method_name, *args)
101
+ action_method_name = _resolve_action_method_name(method_name)
102
+ if action_method_name.nil?
103
+ super
104
+ else
105
+ send(action_method_name, *args)
106
+ end
107
+ end
108
+
109
+ # Parses an action name from a given method name of the format
110
+ # `action_name?` or `action_name and returns the action method
111
+ # or the list method name. If the method name does not follow
112
+ # one of those formats, this assumes the caller is not calling
113
+ # an action or list method and returns nil.
114
+ def _resolve_action_method_name(method_name)
115
+ action_method_match = /\A(\w+)(\??)\z/.match(method_name)
116
+
117
+ return nil if action_method_match.nil? || action_method_match[1].nil?
118
+
119
+ action_name = action_method_match[1].to_sym
120
+ is_predicate = action_method_match[2] == "?"
121
+
122
+ if !_action_defined?(action_name)
123
+ nil
124
+ elsif is_predicate
125
+ _action_method_name(action_name)
126
+ else
127
+ _action_list_method_name(action_name)
128
+ end
129
+ end
130
+
131
+ # The implementation for action methods follow the naming format
132
+ # `_resolve_action_name`. This is to allow child Policies to override
133
+ # the action method and still be able to call `super` when they
134
+ # need to call the base implementation of the action method.
135
+ def self._action_method_name(action_name)
136
+ "_resolve_#{action_name}"
137
+ end
138
+
139
+ def _action_method_name(action_name)
140
+ self.class._action_method_name(action_name)
141
+ end
142
+
143
+ # The implementation for list methods follow the naming format
144
+ # `_list_action_name`. This is to allow child Policies to override
145
+ # the list method and still be able to call `super` when they
146
+ # need to call the base implementation of the lsit method.
147
+ def self._action_list_method_name(action_name)
148
+ "_list_#{action_name}"
149
+ end
150
+
151
+ def _action_list_method_name(action_name)
152
+ self.class._action_list_method_name(action_name)
153
+ end
154
+
155
+ # Defines the action method on the Policy class for the given
156
+ # action name.
157
+ def self._define_action_methods(action, action_id)
158
+ unless method_defined?(_action_method_name(action))
159
+ define_method(_action_method_name(action)) do |*args|
160
+ _can_do_action?(action, action_id, args.first)
161
+ end
162
+ end
163
+
164
+ unless method_defined?(_action_list_method_name(action))
165
+ define_method(_action_list_method_name(action)) do |*args|
166
+ _list_for_action(action, action_id)
167
+ end
168
+ end
169
+ end
170
+
171
+ # Determines whether the caller is calling an object action
172
+ # method or a non-object action method and calls the appropriate
173
+ # implementation.
174
+ def _can_do_action?(action, action_id, object)
175
+ if object.nil?
176
+ _can_do_action_without_object?(action, action_id)
177
+ else
178
+ _can_do_action_with_object?(action, action_id, object)
179
+ end
180
+ end
181
+
182
+ # Determines whether the actor has permission to do the action
183
+ # outside of an object context. If the actor should have unrestricted
184
+ # access, then this returns true without checking.
185
+ #
186
+ # @return [Boolean]
187
+ def _can_do_action_without_object?(action, action_id)
188
+ if _actions[action].nil?
189
+ _invalid_general_action!(action)
190
+ elsif unrestricted?
191
+ true
192
+ else
193
+ accessly_query.can?(action_id, namespace)
194
+ end
195
+ end
196
+
197
+ # Determines whether the actor has permission to do the action
198
+ # on an object. If the actor should have unrestricted access,
199
+ # then this returns true without checking.
200
+ #
201
+ # @return [Boolean]
202
+ def _can_do_action_with_object?(action, action_id, object)
203
+ object_id = _get_object_id(object)
204
+
205
+ if _actions_on_objects[action].nil?
206
+ _invalid_action_on_object!(action)
207
+ elsif unrestricted?
208
+ true
209
+ else
210
+ accessly_query.can?(action_id, namespace, object_id)
211
+ end
212
+ end
213
+
214
+ def _list_for_action(action, action_id)
215
+ if _actions_on_objects[action].nil?
216
+ _invalid_action_on_object!(action)
217
+ elsif unrestricted?
218
+ model_scope
219
+ else
220
+ model_scope.where(id: accessly_query.list(action_id, namespace))
221
+ end
222
+ end
223
+
224
+ def _get_general_action_id!(action)
225
+ _actions[action] || _invalid_general_action!(action)
226
+ end
227
+
228
+ def _get_action_on_object_id!(action)
229
+ _actions_on_objects[action] || _invalid_action_on_object!(action)
230
+ end
231
+
232
+ def _invalid_general_action!(action)
233
+ raise ArgumentError.new("#{action} is not defined as a general action for #{self.class.name}")
234
+ end
235
+
236
+ def _invalid_action_on_object!(action)
237
+ raise ArgumentError.new("#{action} is not defined as an action-on-object for #{self.class.name}")
238
+ end
239
+
240
+ def _get_object_id(object)
241
+ object.respond_to?(:id) ? object.id : object
242
+ end
243
+
244
+ def self._action_defined?(action_name)
245
+ _actions.include?(action_name) || _actions_on_objects.include?(action_name)
246
+ end
247
+
248
+ def _action_defined?(action_name)
249
+ self.class._action_defined?(action_name)
250
+ end
251
+
252
+ def self._actions
253
+ @@_actions ||= {}
254
+ end
255
+
256
+ def _actions
257
+ self.class._actions
258
+ end
259
+
260
+ def self._actions_on_objects
261
+ @@_actions_on_objects ||= {}
262
+ end
263
+
264
+ def _actions_on_objects
265
+ self.class._actions_on_objects
266
+ end
267
+ end
268
+ end
269
+ end