access_allow 0.1.0

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: a3f5b6c7372b951cf3e958813f2a5535a0e1889e646524590e10e1600106ec8d
4
+ data.tar.gz: abd0e5aa1f28a82206f02232ca527f84ac340364ed078d515363879d7e8de5f6
5
+ SHA512:
6
+ metadata.gz: 385a3320289a1b69ddba30897996105547027520009c926cf65f7ba794a7d1c430052bab3c52bae8026c20db61b66bf3954a5d915a8c0c21c02742082907d707
7
+ data.tar.gz: 4b04e9507daaeac18001dc29be96f80d60039073122996eb3b2ec7a7a97a6c5eb5afa130cee417d95cb667686fed85cd02a9de2bb4d760f29f305ff686653b11
data/MIT-LICENSE ADDED
@@ -0,0 +1,20 @@
1
+ Copyright 2022 Stephen Ierodiaconou
2
+
3
+ Permission is hereby granted, free of charge, to any person obtaining
4
+ a copy of this software and associated documentation files (the
5
+ "Software"), to deal in the Software without restriction, including
6
+ without limitation the rights to use, copy, modify, merge, publish,
7
+ distribute, sublicense, and/or sell copies of the Software, and to
8
+ permit persons to whom the Software is furnished to do so, subject to
9
+ the following conditions:
10
+
11
+ The above copyright notice and this permission notice shall be
12
+ included in all copies or substantial portions of the Software.
13
+
14
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
15
+ EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF
16
+ MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND
17
+ NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE
18
+ LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION
19
+ OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
20
+ WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
data/README.md ADDED
@@ -0,0 +1,374 @@
1
+ # AccessAllow
2
+
3
+ Permissions and access control gem for Rails.
4
+
5
+ # Roles, Abilities and the permissions model
6
+
7
+ Users should be assigned a `role` where a role is a named grouping of specific permissions (or abilities as we
8
+ call them). Roles are configured in the application configuration.
9
+
10
+ Abilities are named permissions that live inside a namespace. These are context dependant. For example we might think
11
+ of the ability for being able to check out a shopping cart as `shopping_cart: :check_out` where `shopping_cart` is the
12
+ ability namespace for anything to do with the shopping cart and `check_out` is the specific ability name.
13
+
14
+ Thus abilities are acquired by user either through their assigned role, or an ability can be directly assign in the
15
+ database, via the User association `permissions`.
16
+
17
+ ## Role and abilities utility methods
18
+
19
+ `AccessAllow::Roles` provides a bunch of utility methods that
20
+
21
+ * check if a given role name is for a specific user type or not
22
+ * returns humanized versions of the role names
23
+
24
+ `AccessAllow::Abilities` currently provides utility methods to convert between string and hash representations of abilities.
25
+
26
+ # Configuring Roles & their abilities
27
+
28
+ Schema configured in `Configuration` and should be configured to create roles with their abilities.
29
+
30
+ The structure consists of:
31
+
32
+ <user_type_key>:
33
+ <user_role_key>:
34
+ <ability_namespace_key>:
35
+ <ability_key>: [true/false]
36
+
37
+ where
38
+
39
+ * `user_type_key` is determined from the model name of the user class (eg `User` => `user`)
40
+ * `user_role_key` is the name of the user role (eg `account_owner`)
41
+ * `ability_namespace_key` is the name of the group of abilites (eg `product_management`)
42
+ * `ability_key` is the name of the actual ability (eg `edit_product`) and is set to a boolean
43
+ to indicate if the ability is available to the specific configuration or not
44
+
45
+ __Note__: ability names must be defined in the correct user type, role, namespace key space otherwise
46
+ the app will raise an exception. This is to prevent accidentally forgetting to define the default
47
+ permissions of a role around a specific feature.
48
+
49
+ ## Setting a user specific ability
50
+
51
+ User specific abilities are persisted in `Permission`s where the attribute `ability_name` stores the
52
+ ability namespace and name in one combined string. The format is `/` separated. Eg `tag_management/edit_tag`. Use
53
+ `AccessAllow::Abilities` to convert between string and hash representations of abilities.
54
+
55
+ The existence of a `Permission` sets the specific ability in the above described structure of abilities.
56
+
57
+ The `AbilitiesManager` handles mixing these assigned abilities into the users specific total ability list.
58
+
59
+ __Note__ that an ability defined in a `Permission` __must__ also exist in the role assigned abilities. If
60
+ it does not then it is ignored. In other words a `Permission` can only override abilities defined for the
61
+ role that are set to `false`. This allows a user to be given a specific ability that normally their role has not got,
62
+ but does not allow you to assign arbitrary abilities to a user, thus preventing dangerous situations where an ability
63
+ that say is only for Admins is assigned to a User role.
64
+
65
+ # Manually checking user abilities
66
+
67
+ The class `AccessAllow::Check` implements ability check logic. Using this class one can check if a user has a specific ability
68
+ and optionally raise if not.
69
+
70
+ You can either build a new instance of the check class and then use `#possible?` and `#possible!` of use the class
71
+ helper methods
72
+
73
+ * `.call(user, <ability_namespace>: <ability_name>)`: checks if user has `ability_name` in `ability_namespace`. Returns
74
+ a boolean result
75
+ * `.call!(user, <ability_namespace>: <ability_name>)`: checks if user has `ability_name` in `ability_namespace`. Returns
76
+ true or raises `AccessAllow::ViolationError`
77
+
78
+ The methods exposed by `Check` are useful for checking for abilities in other objects. To define abilities checks
79
+ around controller actions see the next section.
80
+
81
+ # Controller DSL for specifying requirements and abilities needed to perform actions
82
+
83
+ Much of the time permissions checks will occur in Controllers. Also many controller actions have specific checks and
84
+ requirements around the user or other entities related to the controller action. For example, when editing a user's
85
+ profile, one must check that the user who is trying to execute the `update` action has the ability (permission) to
86
+ do it, but also that the user is even from the same company as the user being edited.
87
+
88
+ As such a DSL exists that can be used in controllers to define sets of required checks and rules around actions that then
89
+ define what abilities or checks are needed to allow a specific action to execute. The rules can also define what
90
+ should happen if the checks do not pass, or if no rules match the current situation.
91
+
92
+ The DSL allows us to define 3 categories of our so called access rules:
93
+
94
+ ## Required check rules
95
+
96
+ Many times we want to specify that certain requirements are required to allow a user to perform a certain action. These
97
+ requirements maybe certain checks on the user, or they maybe related to their role or abilities.
98
+
99
+ These checks must all pass to allow the user to continue. They are checked before any other access checks are executed.
100
+ If the checks do not pass then a 'violation' is returned, which is then handled by the controller accordingly.
101
+
102
+ These rules consist of a 'check', an optional set of required abilities, and optionally what violation type to raised
103
+ if the check does not pass.
104
+
105
+ ## Action allow rules
106
+
107
+ Action allow rules are defined to provide specific rules which allow a user to perform a specific controller action.
108
+
109
+ Note that AccessAllow prevents an action from being executed unless it is explicitly allowed for the given user trying to
110
+ execute it.
111
+
112
+ For a user to be allowed to perform a given controller action, there must be a matching "action allow" rule for that
113
+ action for which the check passes and permissions requirements are met. Any matching rule will allow the user to execute
114
+ the action. Note if no rules match successfully then the no-match behaviour is executed.
115
+
116
+ These rules consist of a check, an optional set of required abilities, a set of action names to which the rule applies
117
+ and optionally a name to alias the check as a "named check" rule (see below).
118
+
119
+ ## Named check rule
120
+
121
+ These are named checks that can then be referenced by name in action logic or in views to say perform some conditional
122
+ logic. The method provided for checking if any named check rule is valid for the current context is `access_allowed?`.
123
+ There is more details below on this.
124
+
125
+ Say for example you want a user to be `approved` and have the ability `company_profile/edit` to edit the company
126
+ profile, and want to conditionally display a "Edit" button in the view. You could define a named check, say
127
+ `:approved_can_edit` (that checks `approved` and that the user has the ability) and use it in the view to conditionally
128
+ display the button:
129
+
130
+ <% if access_allowed? :approved_can_edit %>
131
+ The button...
132
+ <% end %>
133
+
134
+ Note that when you define an "action allow" rule it is automatically also added to these 'named checks' by the action
135
+ name, for example, if there is action allow rule for `:create` then we can use `access_allowed? :create`.
136
+
137
+ Also note that it is possible to specify a custom 'named check' name for the "action allow" rule (see more below).
138
+
139
+ ## No-match behaviour
140
+
141
+ The DSL also allows us to define what should happen when executing an action and no rule matches the current situation.
142
+
143
+ What should happen is defined using one of a set of predefined 'violations' which are handled in specific ways. See
144
+ the discussion below on violations.
145
+
146
+ ## Violations
147
+
148
+ The behaviour when a "required check" or an action has no matching "allow rule" is defined with so called "violation"
149
+ configuration. These violations are handled in a standardised way by the controller callback that performs the rule
150
+ checks.
151
+
152
+ The violation types are:
153
+
154
+ * `severe`:
155
+ this violation is considered something unusual and is logged. The end user will simply see a 404 page to avoid exposing
156
+ to them that there is in fact an actionable endpoint at the route they tried to access.
157
+ * `hidden`:
158
+ this violation type is considered less severe, but still aims to avoid leaking information to the end user
159
+ about the actual available routes on the app. If this violation is raised the user will see a 404 page and
160
+ the violation is logged to the app logs.
161
+ * `not_permitted`:
162
+ this violation is used when a user can know that an action and route exists but that they do not
163
+ have the assigned 'abilities' to perform the action. The end user will see a 403 (forbidden) page
164
+ and the violation is simply logged to the app logs.
165
+ * `redirect`:
166
+ this violation type is used when we want to perform a redirect if the user does not have the necessary
167
+ permissions. By default it will redirect to `root_path` but you can use a block to specify the destination path.
168
+ The block must return a string or other structure that is accepted by `redirect_to`.
169
+
170
+ ## The DSL & defining checks
171
+
172
+ The methods are as follows:
173
+
174
+ ### `access_require(check, with:, violation: :severe, &block)`
175
+
176
+ Used to define a "required check rule".
177
+
178
+ Takes a check name (a symbol or array of symbols) (see details below), an optional `violation` type (defaults to
179
+ `severe`) for when the check does not pass, and a block for when the violation type is `redirect` and you want to
180
+ specify custom logic to determine the redirection destination. Also can take an an optional set of abilities (a hash)
181
+ passed to `with:` to check against the user.
182
+
183
+ ### `access_allow(check, with: nil, to:, as: nil)`
184
+
185
+ Used to define an "action allow rule" with optional named check alias.
186
+
187
+ Takes a check name (a symbol or array of symbols) (see details below), an optional set of abilities (a hash) passed to
188
+ `with:` to check against the user, and an optional name (symbol) passed with `as:` to allow the rule to be used as a
189
+ "named check". The controller actions the rule applies to is passed to `to:` (symbol or array of symbols).
190
+
191
+ ### `access_allow(check, with: nil, as:)`
192
+
193
+ Used to define a "named check rule".
194
+
195
+ Similar to the "action allow rule" but without the actions. This rule is thus only available to be used as a "named
196
+ check".
197
+
198
+ ### `access_no_match(violation, &block)`
199
+
200
+ Used to define the "no match" behaviour, ie what happens when an action is trying to be executed by no access rule
201
+ matches or passes for the given user and action.
202
+
203
+ Takes a `violation` type and optionally a block for when the violation type is `redirect` and you want to specify
204
+ custom logic to determine the redirection destination.
205
+
206
+ ### Defining abilities needed
207
+
208
+ Permissions requirements are specified for the rule with `with:`.
209
+
210
+ The permissions are defined as a hash containing keys representing the ability namespaces and associated values
211
+ representing the required abilities.
212
+
213
+ For example, `{tag_management: [:add_new, :edit_existing], product_management: :edit_variants}` would mean that the
214
+ user must have all 3 of the abilities, `tag_management: :add_new`, `tag_management: :edit_existing` and
215
+ `product_management: :edit_variants`.
216
+
217
+ ### Defining Checks & predefined checks
218
+
219
+ Access rules must specify one or more 'checks' as part of their rule definition.
220
+
221
+ 'Checks' are basically controller methods which return a boolean to determine if the check 'passed' or 'failed'. Checks
222
+ are normally custom code written for the given context of the feature. Note that checks do not need to perform the
223
+ abilities checks specified by `with:`, these are performed by the gem logic for you.
224
+
225
+ Checks are specified by providing an instance method on the controller named `allow_(name)?`, where `name` is the check
226
+ name, and which returns a boolean.
227
+
228
+ For example, if defining a check for an action allow rule where the user must be approved on the platform, and
229
+ have a specific ability assigned to them, then the 'check' part (named say `approved_user`) is "user must be approved
230
+ on the platform" part of the rule, and would be defined on the controller as an instance method `allow_approved_user?`.
231
+
232
+ There are some predefined 'common' checks, where you do not need to define the `allow_(name)?` method. These are:
233
+
234
+ * `:public`: anyone, logged in or not
235
+ * `:authenticated_user`: any logged in user (uses `current_user` or whatever is set as the `current_user_method` in the config)
236
+
237
+ # View helper to check permissions of user for conditional view sections
238
+
239
+ It is also possible to check `access` rules from inside views using the `access_allowed?` view helper, which takes
240
+ a list of "named check" names. If any of those check names passes the method returns `true`.
241
+
242
+ Note that check names also include the actions for which rules exists, as described earlier.
243
+
244
+ ```erb
245
+ # in controller
246
+ allow_access :admin, to: :new
247
+
248
+ # in view
249
+ <% if access_allowed? :new %>
250
+ Only 'admin' users who are allowed to execute action `:new` can see this
251
+ <% end %>
252
+ ```
253
+
254
+ and
255
+
256
+ ```erb
257
+ # in controller
258
+ allow_access :my_check, as: named_rule
259
+
260
+ # in view
261
+ <% if access_allowed? :named_rule %>
262
+ Only users for whom the `named_rule` check passes can see this
263
+ <% end %>
264
+ ```
265
+
266
+ # Example
267
+
268
+ Consider the following view fragment, and then the controller heirarchy defined below:
269
+
270
+ `tags_controller.rb`
271
+
272
+ ```ruby
273
+ class TagsController < AdminController
274
+ # Allow any admin to access the :index and :show actions
275
+ access_allow :admin, to: [:index, :show]
276
+ # Only let admins with the ability `tag_management: :manage` to execute other actions.
277
+ # Also in our view we can use the check name `tag_management` to conditionally add say an "Add new Tag" button
278
+ access_allow :admin, with: {tag_management: :manage}, to: :all, as: :tag_management
279
+ # On the index page we also conditionally show some statistics about Tag usage, but only to admins with the right
280
+ # ability. This is done with the named check `:view_usage_stats`
281
+ access_allow :admin, with: {tag_management: :usage_stats}, as: :view_usage_stats
282
+ # Admins with a special flag called "im_magic" can also access the :magic action
283
+ access_allow :magic_admin, to: :magic
284
+
285
+ def allow_admin?
286
+ current_user.admin?
287
+ end
288
+
289
+ def allow_magic_admin?
290
+ current_user.im_magic? && allow_admin?
291
+ end
292
+
293
+ # ...
294
+ end
295
+
296
+ class AdminController < AuthenticatedController
297
+ # Only admins can access actions on this controller or its sub controllers. Any authenticated user who is not an
298
+ # admin user will generate a severe access violation. They will see a 404 but the violation will be logged.
299
+ access_require :admin, violation: :severe
300
+ # Once we have verified the user is an admin we can 403 them instead of 404 when they try to access a page they
301
+ # dont have permission for. We don't need to hide the existence of the action from them.
302
+ access_no_match :not_permitted
303
+ # ...
304
+ end
305
+
306
+ class AuthenticatedController < ApplicationController
307
+ # Any action requires an authenticated user. The defined behaviour is that if the user trying to access the action
308
+ # is not authenticated they are redirected to the sign-in page.
309
+ access_require :authenticated_user, violation: :redirect do
310
+ sign_in_path
311
+ end
312
+
313
+ # ...
314
+ end
315
+
316
+ class ApplicationController < ActionController::Base
317
+ # By default, if no access rules match when executing an action then show the user a 404 to prevent leaking the
318
+ # existence of the end point
319
+ access_no_match :hidden
320
+ # ...
321
+ end
322
+
323
+ ```
324
+
325
+ `tags/index.html.erb`
326
+
327
+ ```erb
328
+ <p>Tags Index</p>
329
+ <% if access_allowed? :tag_management %>
330
+ <button>Add new tag</button>
331
+ <% end %>
332
+ <% if access_allowed? :view_usage_stats %>
333
+ <div> ... </div>
334
+ <% end %>
335
+ <ul> ... </ul>
336
+ ```
337
+
338
+ ## Usage
339
+
340
+ Add this to your `ApplicationController`
341
+
342
+ ```ruby
343
+ class ApplicationController < ActionController::Base
344
+ include AccessAllow::ControllerAccessDsl
345
+ end
346
+ ```
347
+
348
+ ## Installation
349
+ Add this line to your application's Gemfile:
350
+
351
+ ```ruby
352
+ gem "access_allow"
353
+ ```
354
+
355
+ And then execute:
356
+ ```bash
357
+ $ bundle
358
+ ```
359
+
360
+ Or install it yourself as:
361
+ ```bash
362
+ $ gem install access_allow
363
+ ```
364
+
365
+ Then run the **generator to add the initializer**
366
+
367
+ rails g access_allow:install
368
+
369
+
370
+ ## Contributing
371
+ Contribution directions go here.
372
+
373
+ ## License
374
+ The gem is available as open source under the terms of the [MIT License](https://opensource.org/licenses/MIT).
data/Rakefile ADDED
@@ -0,0 +1,3 @@
1
+ require "bundler/setup"
2
+
3
+ require "bundler/gem_tasks"
@@ -0,0 +1,25 @@
1
+ # frozen_string_literal: true
2
+
3
+ module AccessAllow
4
+ class Abilities
5
+ class << self
6
+ def qualified_name(ability_namespace, ability_name)
7
+ raise StandardError, "You can't have blank ability names" if ability_namespace.blank? || ability_name.blank?
8
+ "#{ability_namespace}/#{ability_name}"
9
+ end
10
+
11
+ def parse_qualified_name(name)
12
+ parts = name.split("/").map do |part|
13
+ raise StandardError "Ability namespaces or names cannot be blank" if part.blank?
14
+ part.to_sym
15
+ end
16
+ return parts if parts.size == 2
17
+ raise StandardError "Ability name must have a namespace and name (was #{name})"
18
+ end
19
+
20
+ def humanized_name(type, ability_namespace, ability_name)
21
+ I18n.t("abilities.#{type}.abilities.#{ability_namespace}.#{ability_name}")
22
+ end
23
+ end
24
+ end
25
+ end
@@ -0,0 +1,81 @@
1
+ # frozen_string_literal: true
2
+
3
+ module AccessAllow
4
+ class AbilitiesManager
5
+ def initialize(user)
6
+ @user = user
7
+ end
8
+
9
+ def has?(ability_namespace, ability_name)
10
+ namespace = namespaced_context(ability_namespace)
11
+ namespace[ability_name]
12
+ end
13
+
14
+ def to_a
15
+ @to_a ||=
16
+ combined_role_and_user_assigned.flat_map do |namespace, abilities|
17
+ abilities
18
+ .to_a
19
+ .each_with_object([]) do |config, arr|
20
+ ability, permitted = config
21
+ arr << [namespace, ability.to_sym] if permitted
22
+ end
23
+ end
24
+ end
25
+
26
+ private
27
+
28
+ attr_reader :user
29
+
30
+ def namespaced_context(ability_namespace)
31
+ context = combined_role_and_user_assigned[ability_namespace]
32
+ raise StandardError, "Permission namespace unknown: #{ability_namespace}" unless context
33
+ context
34
+ end
35
+
36
+ def combined_role_and_user_assigned
37
+ @combined_role_and_user_assigned ||=
38
+ begin
39
+ base_perms = role_assigned.deep_dup
40
+ user.send(AccessAllow.configuration.permissions_association_name).each do |perm|
41
+ namespace, name = AccessAllow::Abilities.parse_qualified_name(perm.ability_name)
42
+
43
+ # We only assign if the permission already exists in the base role based configs
44
+ next if base_perms.dig(namespace, name).nil?
45
+ base_perms[namespace][name] = true
46
+ end
47
+ base_perms
48
+ end
49
+ end
50
+
51
+ def role_assigned
52
+ unless role_based_abilities[user_type_key]
53
+ raise(StandardError, "User type (#{user_type_key}) has no permissions defined")
54
+ end
55
+ unless role_based_abilities[user_type_key][user_role_key]
56
+ raise(
57
+ StandardError,
58
+ "Role (#{user_role_key}) for user type (#{user_type_key}) has no permissions defined"
59
+ )
60
+ end
61
+ role_based_abilities[user_type_key][user_role_key]
62
+ end
63
+
64
+ def role_based_abilities
65
+ AccessAllow.configuration.roles_and_permissions
66
+ end
67
+
68
+ def user_type_key
69
+ user.class.name.underscore.to_sym
70
+ end
71
+
72
+ def user_role_key
73
+ role = user.send(AccessAllow.configuration.role_method_name)
74
+ (role.presence || "primary").to_sym
75
+ end
76
+
77
+ def about_user
78
+ "#{user.class} with ID #{user.id}"
79
+ end
80
+ end
81
+ end
@@ -0,0 +1,237 @@
1
+ # frozen_string_literal: true
2
+
3
+ module AccessAllow
4
+ class AccessManager
5
+ VIOLATION_TYPES = %i[severe hidden redirect not_permitted].freeze
6
+
7
+ def initialize
8
+ @required_rules = []
9
+ @action_rules = []
10
+ @named_rules_map = {}
11
+ @all_actions_rules = []
12
+ @no_match_rule = {violation: :severe}
13
+ end
14
+
15
+ # When initialising an access manager for a controller make sure to clone the current parent controllers rules
16
+ def initialize_clone(parent_manager)
17
+ @required_rules = parent_manager.required_rules.deep_dup
18
+ @action_rules = parent_manager.action_rules.deep_dup
19
+ @named_rules_map = parent_manager.named_rules_map.deep_dup
20
+ @all_actions_rules = parent_manager.all_actions_rules.deep_dup
21
+ @no_match_rule = parent_manager.no_match_rule.deep_dup
22
+ end
23
+
24
+ def add_allow_rule(rule, to, with = nil, as = nil)
25
+ insert_access_rule(parse_rule(rule, to: to, with: with, as: as))
26
+ end
27
+
28
+ def add_required_rule(rule, violation, with = nil, handler = nil)
29
+ unless VIOLATION_TYPES.include?(violation)
30
+ raise StandardError, "You must provide a valid violation type"
31
+ end
32
+ insert_required_rule(rule, with, violation, handler)
33
+ end
34
+
35
+ def configure_no_match(violation_type, &block)
36
+ rule = {violation: violation_type}
37
+ rule[:handler] = block if block
38
+ @no_match_rule = rule
39
+ end
40
+
41
+ # Rule is applied like this:
42
+ #
43
+ # First apply any required rules, ie all must pass to allow user to progress
44
+ #
45
+ # Then apply allow rules, where any pass will allow user to progress:
46
+ # * if there is a constraint on an action name, check that
47
+ # * if it has user type requirement, then apply that next
48
+ # * if it has a perms requirement, apply that after
49
+ # * if it has custom rules, apply those
50
+
51
+ # Compare against all configured rules which have actions
52
+ def allow_action?(user, controller, current_action)
53
+ # Required checks must all pass
54
+ required_rules.each do |config|
55
+ permitted_or_violation = execute_required_rule(config, user, controller, current_action)
56
+ return permitted_or_violation unless permitted_or_violation == true
57
+ end
58
+
59
+ # Check action rules
60
+ allowed =
61
+ action_rules.any? { |config| execute_rule(config, user, controller, current_action) }
62
+ return true if allowed
63
+
64
+ # return no-match
65
+ no_match_rule
66
+ end
67
+
68
+ # Evaluate specific rules. Ie we dont check action match, we just find if there are any checks that pass
69
+ # for the current context. Used in view helper. The rules are defined with access_allow and a name or alias.
70
+ def allow?(rules, user, current_controller)
71
+ Array
72
+ .wrap(rules)
73
+ .any? do |rule|
74
+ rule_configs = named_rules_map[rule]
75
+ rule_configs&.any? { |config| execute_rule(config, user, current_controller) }
76
+ end
77
+ end
78
+
79
+ # Introspection methods
80
+
81
+ def no_match_violation
82
+ no_match_rule[:violation]
83
+ end
84
+
85
+ def named_rule_exists?(name)
86
+ !named_rules_map[name].nil?
87
+ end
88
+
89
+ def required_check_exists?(name)
90
+ required_rules.any? do |r|
91
+ r[:rules_set][:all]&.include?(name) || r[:rules_set][:any]&.include?(name)
92
+ end
93
+ end
94
+
95
+ protected
96
+
97
+ attr_reader :required_rules, :action_rules, :named_rules_map, :all_actions_rules, :no_match_rule
98
+
99
+ private
100
+
101
+ def parse_rule(rule, with: nil, as: nil, to: nil)
102
+ # If rule is a hash then its rules + perms - one key is single rule, multiple considered AND
103
+ # If an array then AND condition on the rules
104
+ actions = Array.wrap(to)
105
+ given_names = Array.wrap(as)
106
+ if actions.empty? && given_names.empty?
107
+ raise StandardError,
108
+ "You must specify the actions which the rule applies to or if a check must have a name"
109
+ end
110
+ {
111
+ aliases: given_names.presence || actions,
112
+ rules_set: prepare_rule_set(rule),
113
+ perms: parse_permissions(with),
114
+ actions: actions
115
+ }
116
+ end
117
+
118
+ # Parse the (optional) permissions configuration for the rule, defined with the `with:` option
119
+ def parse_permissions(perm_config)
120
+ return if perm_config.blank?
121
+
122
+ # A permission comprises of a namespace key, and with a single or array of permission names.
123
+ # If multiple are specified they all must apply
124
+ perm_config.to_a.flat_map do |c|
125
+ namespace, perm_name = c
126
+ if perm_name.is_a?(Array)
127
+ perm_name.map { |n| {namespace => n} }
128
+ else
129
+ {namespace => perm_name}
130
+ end
131
+ end
132
+ end
133
+
134
+ def insert_required_rule(rule, with, violation, handler)
135
+ config = {
136
+ rules_set: prepare_rule_set(rule),
137
+ perms: parse_permissions(with),
138
+ violation: violation
139
+ }
140
+ config[:handler] = handler if handler
141
+ required_rules << config
142
+ end
143
+
144
+ def prepare_rule_set(rules)
145
+ return rules if rules.is_a?(Hash) && (rules[:any] || rules[:all])
146
+ {all: Array.wrap(rules)}
147
+ end
148
+
149
+ def insert_access_rule(parsed_rule)
150
+ # Or add alias and/or action rule
151
+ aliased_as = parsed_rule[:aliases]
152
+ aliased_as&.each do |alias_name|
153
+ named_rules_map[alias_name] = [] if named_rules_map[alias_name].nil?
154
+ named_rules_map[alias_name] << parsed_rule
155
+ end
156
+ return if parsed_rule[:actions].blank?
157
+ all_actions_rules << parsed_rule if parsed_rule[:actions].include?(:all)
158
+ action_rules << parsed_rule
159
+ end
160
+
161
+ # To execute a rule:
162
+ # - check that the action is valid if specified
163
+ # - check that the user has required abilities if specified
164
+ # - apply the rules (all rules for the given allow config) to check if it passes for the current context
165
+ def execute_rule(config, user, controller, action_name = nil)
166
+ return if action_name && !allowed_action?(config[:actions], action_name)
167
+ execute_rules_set(config[:rules_set], config[:perms], user, controller, action_name)
168
+ end
169
+
170
+ # Required rules either pass or return their configured violation state
171
+ def execute_required_rule(config, user, controller, action_name)
172
+ if execute_rules_set(config[:rules_set], config[:perms], user, controller, action_name)
173
+ return true
174
+ end
175
+ config.slice(:violation, :handler)
176
+ end
177
+
178
+ def execute_rules_set(rules_set, perms, user, controller, action_name)
179
+ return unless user_has_perms?(user, perms)
180
+ if rules_set[:all]
181
+ rules_set[:all].all? do |rule|
182
+ Array.wrap(rule).all? { |r| apply_rule(r, user, controller, action_name) }
183
+ end
184
+ elsif rules_set[:any]
185
+ rules_set[:any].any? do |rule|
186
+ Array.wrap(rule).all? { |r| apply_rule(r, user, controller, action_name) }
187
+ end
188
+ else
189
+ raise NotImplementedError, "Unknown rule set"
190
+ end
191
+ end
192
+
193
+ # Check specified action is even got a rule to apply to it
194
+ def allowed_action?(actions, action_name)
195
+ actions.include?(:all) || actions.include?(action_name)
196
+ end
197
+
198
+ # Check if the user has permissions defined in the rule
199
+ def user_has_perms?(user, perms)
200
+ return true if perms.blank?
201
+ perms.all? { |perm| AccessAllow::Check.call(user, perm) }
202
+ end
203
+
204
+ # Some rules are predefined, otherwise apply the rule by calling the methods on the controller
205
+ # which the rule defines, which are named after the rule with a `allow_` prefix
206
+ def apply_rule(rule, user, controller, action_name)
207
+ case rule
208
+ when :public
209
+ true
210
+ when :authenticated_user
211
+ user.present?
212
+ else
213
+ apply_custom_rule(rule, user, controller, action_name)
214
+ end
215
+ end
216
+
217
+ # Apply the rule by calling the appropriate `allow_#{name}` instance method on the controller
218
+ # The method can be optionally called with the current user being tested against the rule, and optionally
219
+ # the rule configuration itself.
220
+ def apply_custom_rule(rule, user, controller, action_name)
221
+ controller.instance_exec(user, rule: rule, action_name: action_name) do |uut, rule_info|
222
+ check_name = "allow_#{rule}?".to_sym
223
+ unless respond_to?(check_name)
224
+ raise NotImplementedError, "Check #{check_name} not implemented!"
225
+ end
226
+ case method(check_name).arity
227
+ when 1
228
+ send(check_name, uut)
229
+ when 2
230
+ send(check_name, uut, rule_info)
231
+ else
232
+ send(check_name)
233
+ end
234
+ end
235
+ end
236
+ end
237
+ end
@@ -0,0 +1,55 @@
1
+ # frozen_string_literal: true
2
+
3
+ module AccessAllow
4
+ class Check
5
+ class << self
6
+ def call(user, config)
7
+ build_perms_checker(user, config).possible?
8
+ end
9
+
10
+ def call!(user, config)
11
+ build_perms_checker(user, config).possible!
12
+ end
13
+
14
+ private
15
+
16
+ def build_perms_checker(user, config)
17
+ perm_namespace, perm_name = config.to_a.first
18
+ new(user, perm_namespace, perm_name)
19
+ end
20
+ end
21
+
22
+ def initialize(user, ability_namespace, ability_name)
23
+ @user = user
24
+ @ability_manager = user ? AccessAllow::AbilitiesManager.new(user) : nil
25
+ @ability_namespace = ability_namespace.to_sym
26
+ @ability_name = ability_name.to_sym
27
+ end
28
+
29
+ def possible?
30
+ unless user
31
+ Rails.logger.info error_message(false)
32
+ return false
33
+ end
34
+ ability_manager.has?(ability_namespace, ability_name).tap { |can| Rails.logger.info error_message(can) }
35
+ end
36
+
37
+ def possible!
38
+ possible? || raise(AccessAllow::ViolationError, error_message(false))
39
+ end
40
+
41
+ private
42
+
43
+ attr_reader :user, :ability_namespace, :ability_name, :ability_manager
44
+
45
+ # Error messages
46
+
47
+ def about_user
48
+ user ? "#{user.class} with ID #{user.id}" : "Unauthenticated user"
49
+ end
50
+
51
+ def error_message(can)
52
+ "#{about_user} #{can ? "can" : "cannot"} do '#{ability_name}'"
53
+ end
54
+ end
55
+ end
@@ -0,0 +1,113 @@
1
+ # frozen_string_literal: true
2
+
3
+ module AccessAllow
4
+ # Setup rules and configuration to specify access for a controller. Either specific actions or all actions.
5
+ module ControllerAccessDsl
6
+ extend ActiveSupport::Concern
7
+
8
+ included do
9
+ helper_method :access_allowed?
10
+
11
+ # Add a before action to check `allow` permissions rules. Note this is a 'prepend' as we
12
+ # want to try to ensure this happens before anything else.
13
+ prepend_before_action do |controller|
14
+ ensure_authenticated_before_perms_check if respond_to?(:ensure_authenticated_before_perms_check)
15
+ access_result = self.class.access_manager.allow_action?(
16
+ send(AccessAllow.configuration.current_user_method),
17
+ controller,
18
+ controller.action_name.to_sym
19
+ )
20
+ next true if access_result == true
21
+ Rails.logger.info("Blocked access for #{access_log_user_info} to access '#{access_log_action_tried}'")
22
+ handle_access_violation(access_result)
23
+ end
24
+ end
25
+
26
+ # The DSL of the access rule configuration is defined below
27
+ class_methods do
28
+ # The access controls are inherited to controller subclasses
29
+ def inherited(subclass)
30
+ subclass.instance_variable_set(:@access_manager, @access_manager.clone)
31
+ super
32
+ end
33
+
34
+ # Configure what should happen when no access rule matches the action being executed
35
+ def access_no_match(violation, &block)
36
+ access_manager.configure_no_match(violation, &block)
37
+ end
38
+
39
+ # TODO: consider a `if:` conditional allowed on rules
40
+ # Specify an access requirement, that must pass for any other action access checks to pass
41
+ # The default violation level is :severe
42
+ def access_require(check, with: nil, violation: :severe, &block)
43
+ access_manager.add_required_rule(check, violation, with, block)
44
+ end
45
+
46
+ # Specify an access rule requirement for a specific action or set of actions. You can optionally
47
+ # also specify what abilities are required to match the rule. Using `as:` and no `to:` actions you can also
48
+ # specify an access rule which is not used when actions are executed but instead can be checked by the given name,
49
+ # thus allowing one to define a check that is used inside the processing of an action rather than before it.
50
+ # You can also specify an action rule with `to:` and then alias it to a named check with `as:`
51
+ def access_allow(check, with: nil, to: nil, as: nil)
52
+ access_manager.add_allow_rule(check, to, with, as)
53
+ end
54
+
55
+ # Create a new manager instance for this particular controller
56
+ def access_manager
57
+ @access_manager ||= AccessAllow::AccessManager.new
58
+ end
59
+ end
60
+
61
+ # `access_allowed?` is exposed as a view helper to execute checks or allow rules and return if they
62
+ # passed or not. Useful for doing conditional work in the view or a controller action
63
+ def access_allowed?(*check_rules)
64
+ self.class.access_manager.allow?(
65
+ check_rules,
66
+ send(AccessAllow.configuration.current_user_method),
67
+ self
68
+ )
69
+ end
70
+
71
+ protected
72
+
73
+ # When required access rules are violated, or when no match on an action occurs, the result from the access
74
+ # manager is processed here.
75
+ def handle_access_violation(access_result)
76
+ case access_result[:violation]
77
+ when :redirect
78
+ redirect_destination = access_result[:handler] ? instance_exec(&access_result[:handler]) : root_path
79
+ raise ::AccessAllow::ResponseForbiddenError, "Not permitted" unless redirect_destination
80
+ Rails.logger.info("#{access_log_user_info} tried to access a page that they can't access and they were " \
81
+ "redirected to '#{redirect_destination}'. (#{access_log_action_tried})")
82
+ redirect_to redirect_destination
83
+ when :not_permitted
84
+ Rails.logger.info("#{access_log_user_info} tried to access a page that they can't access and they were " \
85
+ "told about it. (#{access_log_action_tried})")
86
+ raise ::AccessAllow::ResponseForbiddenError, "Not permitted"
87
+ when :hidden
88
+ Rails.logger.info("#{access_log_user_info} tried to access a page that they can't access and they won't " \
89
+ "know exists (#{access_log_action_tried})")
90
+ raise ActionController::RoutingError, "Not Found"
91
+ else
92
+ log_severe_access_violation_and_not_found
93
+ end
94
+ end
95
+
96
+ def log_severe_access_violation_and_not_found
97
+ Rails.logger.error(
98
+ "#{access_log_user_info} tried to access a page that they can't access and" \
99
+ " it is considered suspicious they should attempt to see it. The action was: #{access_log_action_tried}"
100
+ )
101
+ raise ActionController::RoutingError, "Not Found"
102
+ end
103
+
104
+ def access_log_user_info
105
+ user = send(AccessAllow.configuration.current_user_method)
106
+ user ? "User #{user.id}" : "An unauthenticated user"
107
+ end
108
+
109
+ def access_log_action_tried
110
+ "#{controller_name}##{action_name}"
111
+ end
112
+ end
113
+ end
@@ -0,0 +1,4 @@
1
+ module AccessAllow
2
+ class Railtie < ::Rails::Railtie
3
+ end
4
+ end
@@ -0,0 +1,28 @@
1
+ # frozen_string_literal: true
2
+
3
+ module AccessAllow
4
+ class Roles
5
+ class << self
6
+ # Get a human readable version of the role key
7
+ def humanized_name(type, role)
8
+ I18n.t("abilities.#{type}.roles.#{role}")
9
+ end
10
+
11
+ # Check roles are valid for specific user types
12
+
13
+ def for?(type, role)
14
+ roles_for(type).include?(role.to_sym)
15
+ end
16
+
17
+ def roles_for(type)
18
+ configuration[type.to_sym]&.keys || []
19
+ end
20
+
21
+ private
22
+
23
+ def configuration
24
+ AccessAllow.configuration.roles_and_permissions
25
+ end
26
+ end
27
+ end
28
+ end
@@ -0,0 +1,3 @@
1
+ module AccessAllow
2
+ VERSION = "0.1.0"
3
+ end
@@ -0,0 +1,36 @@
1
+ require "access_allow/version"
2
+ require "access_allow/railtie"
3
+ require "access_allow/abilities"
4
+ require "access_allow/abilities_manager"
5
+ require "access_allow/access_manager"
6
+ require "access_allow/check"
7
+ require "access_allow/controller_access_dsl"
8
+ require "access_allow/roles"
9
+
10
+ module AccessAllow
11
+ class ViolationError < StandardError; end
12
+
13
+ class ResponseForbiddenError < StandardError; end
14
+
15
+ class << self
16
+ def configuration
17
+ @configuration ||= Configuration.new
18
+ end
19
+
20
+ def configure
21
+ yield(configuration) if block_given?
22
+ configuration
23
+ end
24
+ end
25
+
26
+ class Configuration
27
+ attr_accessor :roles_and_permissions, :current_user_method, :permissions_association_name, :role_method_name
28
+
29
+ def initialize
30
+ @roles_and_permissions = {}
31
+ @current_user_method = :current_user
32
+ @permissions_association_name = :permissions
33
+ @role_method_name = :role
34
+ end
35
+ end
36
+ end
@@ -0,0 +1,3 @@
1
+ Creates an initial configuration, an initializer and copies in the Permission migration & model
2
+
3
+ rails generate access_allow:install
@@ -0,0 +1,26 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "rails/generators/base"
4
+ require "rails/generators/active_record/migration"
5
+
6
+ module AccessAllow
7
+ module Generators
8
+ # The Install generator `access_allow:install`
9
+ class InstallGenerator < ::Rails::Generators::Base
10
+ include ::ActiveRecord::Generators::Migration
11
+
12
+ source_root File.expand_path(__dir__)
13
+
14
+ desc "Creates an initial configuration, an initializer and copies in the Permission migration & model."
15
+
16
+ def copy_tasks
17
+ template "templates/access_allow.rb", "config/initializers/access_allow.rb"
18
+ migration_template "templates/migration.rb.erb", "db/migrate/access_allow_create_permissions.rb", migration_version: migration_version
19
+ end
20
+
21
+ def migration_version
22
+ "[#{Rails::VERSION::MAJOR}.#{Rails::VERSION::MINOR}]"
23
+ end
24
+ end
25
+ end
26
+ end
@@ -0,0 +1,10 @@
1
+ # frozen_string_literal: true
2
+
3
+ AccessAllow.configure do |config|
4
+ # Roles and permissions associated with each role (you might want to store this in a YAML file and load it here)
5
+ config.roles_and_permissions = {}
6
+
7
+ # config.current_user_method = :current_user
8
+ # config.permissions_association_name = :permissions
9
+ # config.role_method_name = :role
10
+ end
@@ -0,0 +1,11 @@
1
+ # frozen_string_literal: true
2
+
3
+ class AccessAllowCreatePermissions < ActiveRecord::Migration<%= migration_version %>
4
+ def change
5
+ create_table :permissions do |t|
6
+ t.string :ability_name
7
+ t.references :user, foreign_key: true, null: false
8
+ t.timestamps
9
+ end
10
+ end
11
+ end
@@ -0,0 +1,4 @@
1
+ # desc "Explaining what the task does"
2
+ # task :access_allow do
3
+ # # Task goes here
4
+ # end
metadata ADDED
@@ -0,0 +1,76 @@
1
+ --- !ruby/object:Gem::Specification
2
+ name: access_allow
3
+ version: !ruby/object:Gem::Version
4
+ version: 0.1.0
5
+ platform: ruby
6
+ authors:
7
+ - Stephen Ierodiaconou
8
+ autorequire:
9
+ bindir: bin
10
+ cert_chain: []
11
+ date: 2022-11-22 00:00:00.000000000 Z
12
+ dependencies:
13
+ - !ruby/object:Gem::Dependency
14
+ name: rails
15
+ requirement: !ruby/object:Gem::Requirement
16
+ requirements:
17
+ - - ">="
18
+ - !ruby/object:Gem::Version
19
+ version: 7.0.4
20
+ type: :runtime
21
+ prerelease: false
22
+ version_requirements: !ruby/object:Gem::Requirement
23
+ requirements:
24
+ - - ">="
25
+ - !ruby/object:Gem::Version
26
+ version: 7.0.4
27
+ description: Permissions and access control gem for Rails.
28
+ email:
29
+ - stevegeek@gmail.com
30
+ executables: []
31
+ extensions: []
32
+ extra_rdoc_files: []
33
+ files:
34
+ - MIT-LICENSE
35
+ - README.md
36
+ - Rakefile
37
+ - lib/access_allow.rb
38
+ - lib/access_allow/abilities.rb
39
+ - lib/access_allow/abilities_manager.rb
40
+ - lib/access_allow/access_manager.rb
41
+ - lib/access_allow/check.rb
42
+ - lib/access_allow/controller_access_dsl.rb
43
+ - lib/access_allow/railtie.rb
44
+ - lib/access_allow/roles.rb
45
+ - lib/access_allow/version.rb
46
+ - lib/generators/access_allow/USAGE
47
+ - lib/generators/access_allow/install_generator.rb
48
+ - lib/generators/access_allow/templates/access_allow.rb
49
+ - lib/generators/access_allow/templates/migration.rb.erb
50
+ - lib/tasks/access_allow_tasks.rake
51
+ homepage: https://github.com/stevegeek/access_allow
52
+ licenses:
53
+ - MIT
54
+ metadata:
55
+ homepage_uri: https://github.com/stevegeek/access_allow
56
+ source_code_uri: https://github.com/stevegeek/access_allow
57
+ post_install_message:
58
+ rdoc_options: []
59
+ require_paths:
60
+ - lib
61
+ required_ruby_version: !ruby/object:Gem::Requirement
62
+ requirements:
63
+ - - ">="
64
+ - !ruby/object:Gem::Version
65
+ version: '0'
66
+ required_rubygems_version: !ruby/object:Gem::Requirement
67
+ requirements:
68
+ - - ">="
69
+ - !ruby/object:Gem::Version
70
+ version: '0'
71
+ requirements: []
72
+ rubygems_version: 3.3.7
73
+ signing_key:
74
+ specification_version: 4
75
+ summary: Permissions and access control gem for Rails.
76
+ test_files: []