access_allow 0.1.0

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