consent 1.0.1 → 2.0.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.
data/docs/README.md ADDED
@@ -0,0 +1,355 @@
1
+ # Consent [![Build Status](https://travis-ci.org/powerhome/consent.svg?branch=master)](https://travis-ci.org/powerhome/consent)
2
+
3
+ ## What is Consent
4
+
5
+ Consent makes defining permissions easier by providing a clean, concise DSL for authorization
6
+ so that all abilities do not have to be in your `Ability` class.
7
+
8
+ Also, Consent adds an `Authorizable` model, so that you can easily grant permissions to your
9
+ ActiveRecord models.
10
+
11
+ ## Installation
12
+
13
+ Add this line to your application's Gemfile:
14
+
15
+ ```ruby
16
+ gem 'consent'
17
+ ```
18
+
19
+ And then execute:
20
+
21
+ $ bundle
22
+
23
+ Or install it yourself as:
24
+
25
+ $ gem install consent
26
+
27
+ Then, require the engine in your `application.rb`
28
+
29
+ ```ruby
30
+ require "active_record/railtie"
31
+ require "consent/engine"
32
+ ```
33
+
34
+ If you wish to use the activerecord adapter (`accessible_by` and `accessible_through`), you must load `active_record/railtie` before loading the `consent/engine`.
35
+
36
+ ### Install and run the migrations
37
+
38
+ Copy and execute the migrations:
39
+
40
+ $ rails consent_engine:install:migrations
41
+ $ rails db:migrate
42
+
43
+ This will create the `consent_histories` and `consent_permissions` tables. If you want to use a different table prefix, you should set `Consent.table_name_prefix =` before you execute the migrations. I.e.:
44
+
45
+ ```ruby
46
+ # config/initializers/consent.rb
47
+
48
+ require "consent"
49
+
50
+ Consent.table_name_prefix = "my_app_"
51
+ ```
52
+
53
+ ## Authorizable
54
+
55
+ To grant permissions, you need an authorizable model. For our example we'll call it `Role`:
56
+
57
+ ```ruby
58
+ class Role < ApplicationRecord
59
+ include ::Consent::Authorizable
60
+ end
61
+ ```
62
+
63
+ You can now grant permissions to role with `grant`, `grant_all`, and `grant_all!`:
64
+
65
+ ```ruby
66
+ role = Role.new
67
+ role.grant subject: Project, action: :update, view: :department
68
+ # OR
69
+ role.grant_all({ project: { update: :department } })
70
+ # OR
71
+ role.grant_all({ project: { update: :department } }, replace: true) # to replace everything
72
+ # OR
73
+ role.grant_all!({ project: { update: :department } }, replace: true) # to grant and save
74
+
75
+ role.permissions
76
+ => [#<Consent::Permission subject: Project, action: :update, view: :department>]
77
+ ```
78
+
79
+ In the above example, we're granting `:department` view to perform `:update` in the `Project` subject.
80
+
81
+ You can now create a `Consent::Ability` using the permissions granted to the role:
82
+
83
+ ```ruby
84
+ ability = Consent::Ability.new(user, permissions: role.permissions)
85
+ ```
86
+
87
+ ## Defining permissions and views
88
+
89
+ Generate permissions with the `consent:permissions` generator. I.e:
90
+
91
+ $ rails g consent:permissions Project "Our Projects"
92
+ create app/permissions/projects.rb
93
+ create spec/permissions/projects_spec.rb
94
+
95
+ This will generate the permission definition:
96
+
97
+ ```ruby
98
+ Consent.define Project, "Our Projects" do
99
+ #in this case, Project is the subject
100
+ # and `Our Projects` is the description that makes it clear to users
101
+ # what the subject is acting upon.
102
+
103
+ end
104
+ ```
105
+
106
+ We can now define the `:update` action and a couple of different views:
107
+
108
+ ```ruby
109
+ Consent.define Project, "Our Projects" do
110
+ view :all, "All projects"
111
+
112
+ view :department, "Projects from their department" do |user|
113
+ { department_id: user.department_id }
114
+ end
115
+
116
+ view :team, "Projects from their team" do |user|
117
+ { team_id: user.team_id }
118
+ end
119
+
120
+ action :update, views: %i[department team all]
121
+ end
122
+ ```
123
+
124
+ The `:department` view will restrict the user to projects with matching `department_id`. That
125
+ means that for `Project.accessible_by(ability, :update)`, with an ability using a User with
126
+ department_id = 13, it will run a query similar to:
127
+
128
+ ```sql
129
+ > user = User.new(department_id: 13)
130
+ > ability = Consent::Ability.new(user)
131
+ > ability.consent subject: Project, action: :update, view: :department
132
+ > Project.accessible_by(user).to_sql
133
+ "SELECT * FROM projects WHERE department_id = 1"
134
+ ```
135
+
136
+ ### Subject
137
+
138
+ The subject is the central point of a group of actions and views. It will typically
139
+ be an `ActiveRecord`, a `:symbol`, or any plain ruby class.
140
+
141
+ ### Views
142
+
143
+ Views are the rules that limit access to actions. For instance, a user may see a `Project`
144
+ from his department, but not from others. You can enforce it with a `:department` view,
145
+ as in the examples below:
146
+
147
+ ### Hash Conditions
148
+
149
+ Probably the most commonly used. When the view can be defined using a `where` scope in
150
+ an ActiveRecord context. It follows a match condition and will return all objects that meet
151
+ the criteria:
152
+
153
+ ```ruby
154
+ Consent.define Project, 'Projects' do
155
+ view :department, "User's department only" do |user|
156
+ { department_id: user.id }
157
+ end
158
+ end
159
+ ```
160
+
161
+ Although hash conditions (matching object's attributes) are recommended, the constraints can
162
+ be anything you want. Since Consent does not enforce the rules, those rules are directly given
163
+ to CanCan. Following [CanCan rules](https://github.com/CanCanCommunity/cancancan/wiki/Defining-Abilities%3A-Best-Practice)
164
+ for defining abilities is recommended.
165
+
166
+ ### Object Conditions
167
+
168
+ If you're not matching for equal values, then you would need to use an object condition.
169
+
170
+ If you already have an object and want to check to see whether the user has permission to view
171
+ that specific object, you would use object conditions.
172
+
173
+ If your needs can't be satisfied by hash conditions, it is recommended that a second condition
174
+ is given for constraining object instances. For example, if you want to restrict a view for smaller
175
+ volume projects:
176
+
177
+ ```ruby
178
+ Consent.define Project, 'Projects' do
179
+ view :small_volumes, "User's department only",
180
+ -> (user) {
181
+ ['amount < ?', user.volume_limit]
182
+ end,
183
+ -> (user, project) {
184
+ project.amount < user.volume_limit
185
+ }
186
+ end
187
+ ```
188
+
189
+ For object conditions, the latter argument will be the referred object, while the
190
+ first will be the context given to the [Permission](#permission) (also check
191
+ [CanCan integration](#cancan-integration)).
192
+
193
+ ### Action
194
+
195
+ An action is anything you can perform on a given subject. In the example of
196
+ Features this would look like the following using Consent's DSL:
197
+
198
+ ```ruby
199
+ Consent.define :features, 'Beta Features' do
200
+ action :beta_chat, 'Beta Chat App'
201
+ end
202
+ ```
203
+
204
+ To associate different views to the same action:
205
+
206
+ ```ruby
207
+ Consent.define Project, 'Projects' do
208
+ # returns conditions that can be used as a matcher for objects so the matcher
209
+ # can return true or false (hash version)
210
+ view :department, "User's department only" do |user|
211
+ { department_id: user.id }
212
+ end
213
+ view :future_projects, "User's department only",
214
+ # returns a condition to be applied to a collection of objects
215
+ -> (_) {
216
+ ['starts_at > ?', Date.today]
217
+ end,
218
+ # returns true/false based on a condition -- to use this, you must pass in
219
+ # an instance of an object in order to check the permission
220
+ -> (user, project) {
221
+ project.starts_at > Date.today
222
+ }
223
+
224
+ action :read, 'Read projects', views: [:department, :future_projects]
225
+ end
226
+ ```
227
+
228
+ If you have a set of actions with the same set of views, you can use a
229
+ `with_defaults` block to simplify the writing:
230
+
231
+ ```ruby
232
+ with_defaults views: [:department, :small_volumes] do
233
+ action :read, 'Read projects'
234
+ action :approve, 'Approve projects'
235
+ end
236
+ ```
237
+
238
+ ### Permission
239
+
240
+ Permission is what is granted to a role, or a user. It grants the ability to perform an *action*,
241
+ on a limited scope (*view*) of the *subject*.
242
+
243
+ ## CanCan Integration
244
+
245
+ Consent provides a [CanCan](https://github.com/CanCanCommunity/cancancan#readme) ability (Consent::Ability)
246
+ that can be initialized with a group of granted permissions. You can initialize a `Consent::Ability` with:
247
+
248
+ ```ruby
249
+ Consent::Ability.new(*context, super_user: <true|false>, apply_defaults: <true|false>, permissions: [Consent::Permission, ...])
250
+ ```
251
+
252
+ - `*context` is what is given to the view evaluating permission rules. That is typically a user;
253
+ - `super_user` makes the ability to respond to `true` to any `can?` questions, and yields no
254
+ restrictions in any `accessible_by` and `accessible_through` queries;
255
+ - `apply_defaults` grants actions with the `default_view` set automatically.
256
+ - `permissions` is a collection of permissions to grant to the user
257
+
258
+ ### Manually consent permissions
259
+
260
+ You can manually grant permissions with `consent`. You could possibly subclass
261
+ `Consent::Ability` to consent some specific permissions by default:
262
+
263
+ ```ruby
264
+ class MyAbility < Consent::Ability
265
+ def initialize(...)
266
+ super(...)
267
+
268
+ consent action: :read, subject: Project, view: :department
269
+ end
270
+ end
271
+ ```
272
+
273
+ You can also consent full-access by not specifying the view:
274
+
275
+ ```ruby
276
+ consent action: :read, subject: Project
277
+ ```
278
+
279
+ Consenting the same permission multiple times is handled as a Union by CanCanCan:
280
+
281
+ ```ruby
282
+ class MyAbility < Consent::Ability
283
+ def initialize(user)
284
+ super user
285
+
286
+ consent :read, Project, :department
287
+ consent :read, Project, :future_projects
288
+ end
289
+ end
290
+
291
+ user = User.new(department_id: 13)
292
+ ability = MyAbility.new(user)
293
+
294
+ Project.accessible_by(ability, :read).to_sql
295
+ => SELECT * FROM projects WHERE ((department_id = 13) OR (starts_at > '2021-04-06'))
296
+ ```
297
+
298
+ ## Special subject and actions
299
+
300
+ ### :manage
301
+
302
+ Whenever the `:manage` action is granted to a user through `consent` or `can`, that means that all actions in that subject are automatically granted with the same restrictions. Take the following example:
303
+
304
+ ```ruby
305
+ Consent.define User, "User permissions" do
306
+ view :all, "All users"
307
+ view :self, "Own user" do |user|
308
+ { id: user.id }
309
+ end
310
+
311
+ action :manage, "Manage users", views: %i[self all]
312
+ action :update, "Manage users"
313
+ end
314
+
315
+ > ability = Consent::Ability.new(User.new(id: 123))
316
+ > ability.consent subject: User, action: :manage, view: :self
317
+ > User.accessible_by(ability, :manage).to_sql
318
+ => "SELECT `users`.* FROM `users` WHERE `users`.`id` = 123"
319
+ > User.accessible_by(ability, :view).to_sql
320
+ => "SELECT `users`.* FROM `users` WHERE `users`.`id` = 123"
321
+ ```
322
+
323
+ ### :all
324
+
325
+ In CanCan, `:all` is a special subject which means `all subjects`. Whatever is granted to `:all` is applied to all subjects. I.e.:
326
+
327
+ ```ruby
328
+ > ability = Consent::Ability.new(User.new(id: 123))
329
+ > User.accessible_by(ability, :update).to_sql
330
+ => "SELECT `users`.* FROM `users` WHERE 1=0"
331
+ > ability.can :update, :all
332
+ > User.accessible_by(ability, :update).to_sql
333
+ => "SELECT `users`.* FROM `users`"
334
+ ```
335
+
336
+ ## Rails Integration
337
+
338
+ Consent is integrated into Rails with `Consent::Engine`. To define where
339
+ your permission files will be, use `config.consent.path`. This defaults to
340
+ `#{Rails.root}/app/permissions/` to conform to Rails' standards.
341
+
342
+ ## Development
343
+
344
+ After checking out the repo, run `bin/setup` to install dependencies. Then, run
345
+ `rake spec` to run the tests. You can also run `bin/console` for an interactive
346
+ prompt that will allow you to experiment.
347
+
348
+ To install this gem onto your local machine, run `bundle exec rake install`. To
349
+ release a new version, update the version number in `version.rb`, and then run
350
+ `bundle exec rake release`, which will create a git tag for the version, push
351
+ git commits and tags, and push the `.gem` file to [rubygems.org](https://rubygems.org).
352
+
353
+ ## Contributing
354
+
355
+ Bug reports and pull requests are welcome on GitHub at https://github.com/powerhome/consent.
@@ -1,15 +1,64 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module Consent
4
- # Defines a CanCan(Can)::Ability class based on a permissions hash
4
+ #
5
+ # Defines a CanCan(Can)::Ability class based on Consent::Permissions
6
+ #
5
7
  class Ability
6
8
  include CanCan::Ability
7
9
 
8
- def initialize(*args, apply_defaults: true)
9
- @context = *args
10
+ #
11
+ # Initialize a Consent::Ability consenting all the given permissions.
12
+ #
13
+ # When super_user is set to true, it grants `:manage :all`, which is understood by
14
+ # CanCan as a keyword to allow everything with no restrictions.
15
+ #
16
+ # If apply_defaults is set to true, Consent::Ability will grant the default views
17
+ # defined in the permissions.
18
+ #
19
+ # I.e.:
20
+ #
21
+ # Consent.define Project, 'Projects' do
22
+ # view :department, "User's department only" do |user|
23
+ # { department_id: user.id }
24
+ # end
25
+ # view :self, "User's own projects" do |user|
26
+ # { user_id: user.id }
27
+ # end
28
+ #
29
+ # action :close, views: %i[department self], default_view: :self
30
+ # end
31
+ #
32
+ # Consent::Ability.new(user, permissions: user&.permissions,
33
+ # super_user: user&.super_user?,
34
+ # apply_defaults: user.present?)
35
+ #
36
+ # @param [*] *context the view context, usually the user and some additional information
37
+ # @param [Array<Consent::Permission>] permissions the list of permissions to grant
38
+ # @param [Boolean] super_user whether Consent should grant :manage :all
39
+ # @param [Boolean] apply_defaults whether Consent should grant default views
40
+ #
41
+ def initialize(*context, permissions: nil, super_user: false, apply_defaults: true)
42
+ @context = *context
43
+
10
44
  apply_defaults! if apply_defaults
45
+ can :manage, :all if super_user
46
+
47
+ permissions&.each do |permission|
48
+ consent(**permission.slice(:subject, :action, :view).symbolize_keys)
49
+ end
11
50
  end
12
51
 
52
+ # Consents a subject/action/view to the ability
53
+ #
54
+ # `consent!` will add a `can` permission to the ability based on the
55
+ # view rules defined in the Consent definitions.
56
+ #
57
+ # @param [Class,Symbol] subject the target subject of the action
58
+ # @param [Symbol] action the action being granted on the subject
59
+ # @param [Symbol,nil] view the conditions/rules on which the action is granted
60
+ # @raises Consent::ViewNotFound when the view key doesn't exist in the context
61
+ #
13
62
  def consent!(subject: nil, action: nil, view: nil)
14
63
  view = case view
15
64
  when Consent::View
@@ -24,13 +73,55 @@ module Consent
24
73
  )
25
74
  end
26
75
 
76
+ # Consents a subject/action/view to the ability
77
+ #
78
+ # @see ::Consent::Ability#consent
79
+ #
27
80
  def consent(**kwargs)
28
81
  consent!(**kwargs)
29
82
  rescue Consent::ViewNotFound
30
83
  nil
31
84
  end
32
85
 
33
- private
86
+ # Returns a hash where the keys are the given permissions, and the values
87
+ # are either true or false, representing their ability to perform the given
88
+ # permision
89
+ #
90
+ # @param [Array<String>,String,nil] permissions an array of the requested permissions
91
+ # @return [Hash<String,Boolean>] the hash with the results
92
+ def to_h(permissions = nil)
93
+ Array(permissions).reduce({}) do |result, permission|
94
+ result.merge permission => can?(permission)
95
+ end
96
+ end
97
+
98
+ # Check if the user has permission to perform a given action on an object.
99
+ #
100
+ # can? :destroy, @project
101
+ #
102
+ # You can also pass the class instead of an instance (if you don't have one handy).
103
+ #
104
+ # can? :create, Project
105
+ #
106
+ # You can also check with string form of the permission:
107
+ #
108
+ # can? "project/create"
109
+ #
110
+ # For more info, check the documentation of [CanCan::Ability]
111
+ def can?(action_or_pair, subject = nil, *args)
112
+ action, subject = extract_action_subject(action_or_pair, subject)
113
+ super action, subject, *args
114
+ end
115
+
116
+ # @private
117
+ def relation_model_adapter(model_class, action_or_pair, subject, relation)
118
+ action, subject = extract_action_subject(action_or_pair, subject)
119
+ ::CanCan::ModelAdapters::AbstractAdapter
120
+ .adapter_class(model_class)
121
+ .new(model_class, relation_rules(model_class, action, subject, relation))
122
+ end
123
+
124
+ private
34
125
 
35
126
  def apply_defaults!
36
127
  Consent.subjects.each do |subject|
@@ -45,5 +136,23 @@ module Consent
45
136
  end
46
137
  end
47
138
  end
139
+
140
+ def relation_rules(model_class, action, subject, relation)
141
+ relevant_rules(action, subject).map do |rule|
142
+ unless rule.conditions.is_a?(Hash)
143
+ raise ::CanCan::Error, "accessible_through is only available with hash conditions"
144
+ end
145
+
146
+ conditions = rule.conditions.dig(*Array(relation))
147
+ ::CanCan::Rule.new(rule.base_behavior, action, model_class, conditions, rule.block)
148
+ end
149
+ end
150
+
151
+ def extract_action_subject(action_or_string_pair, subject)
152
+ return action_or_string_pair, subject if subject
153
+
154
+ subject_key, _, action_key = action_or_string_pair.rpartition("/")
155
+ [action_key.to_sym, Consent::SubjectCoder.load(subject_key)]
156
+ end
48
157
  end
49
158
  end
data/lib/consent/dsl.rb CHANGED
@@ -1,6 +1,7 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module Consent
4
+ # @private
4
5
  class DSL # :nodoc:
5
6
  attr_reader :subject
6
7
 
@@ -1,26 +1,29 @@
1
1
  # frozen_string_literal: true
2
2
 
3
- require 'consent/reloader'
3
+ require "consent"
4
4
 
5
5
  module Consent
6
6
  # Plugs consent permission load to the Rails class loading cycle
7
- class Railtie < Rails::Railtie
7
+ class Engine < Rails::Engine
8
8
  config.before_configuration do |app|
9
- default_path = app.root.join('app', 'permissions')
10
- config.consent = Consent::Reloader.new(
11
- default_path,
12
- ActiveSupport::Dependencies.mechanism
13
- )
9
+ default_path = app.root.join("app", "permissions")
10
+ config.consent = Consent::Reloader.new(default_path)
14
11
  end
15
12
 
16
13
  config.after_initialize do |app|
17
14
  app.config.consent.execute
18
15
  end
19
16
 
20
- initializer 'initialize consent permissions reloading' do |app|
17
+ initializer "consent.reloader" do |app|
21
18
  app.reloaders << config.consent
22
19
  ActiveSupport::Dependencies.autoload_paths -= config.consent.paths
23
20
  config.to_prepare { app.config.consent.execute }
24
21
  end
22
+
23
+ initializer "consent.accessible_through" do
24
+ ActiveSupport.on_load(:active_record) do
25
+ include Consent::ModelAdditions
26
+ end
27
+ end
25
28
  end
26
29
  end
@@ -0,0 +1,64 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Consent
4
+ #
5
+ # Accessible Through logic
6
+ #
7
+ # This module adds the accessible_through class method to a model.
8
+ # It is included in the activerecord base classes by Consent::Engine.
9
+ #
10
+ # @see Consent::ModelAdditions::ClassMethods#accessible_through
11
+ #
12
+ module ModelAdditions
13
+ module ClassMethods
14
+ # Provides a scope within the model to find instances of the model that are accessible
15
+ # by the given ability through a given relation in the main subject
16
+ #
17
+ # I.E.:
18
+ #
19
+ # Given the following scenario
20
+ #
21
+ # class User
22
+ # belongs_to :territory
23
+ # end
24
+ #
25
+ # Consent.define User, "User permissions" do
26
+ # view :territory do |user|
27
+ # { territory: { id: user.territory_id } }
28
+ # end
29
+ # view :visible_territories do |user|
30
+ # { territory: { id: user.visible_territory_ids } }
31
+ # end
32
+ #
33
+ # action :contact, views: %i[all no_access territory visible_territories]
34
+ # end
35
+ #
36
+ # This would give you a list of territories that the given ability can
37
+ # contact their users:
38
+ #
39
+ # > user = User.new(territory_id: 13, visible_territory_ids: [2, 3, 4])
40
+ # > ability = Consent::Ability.new(user.to_session_user)
41
+ # > ability.consent view: :territory, action: :contact, subject: User
42
+ # > Territory.accessible_through(ability, :contact, User).to_sql
43
+ # => SELECT * FROM territories WHERE id = 13
44
+ # > ability.consent view: :visible_territories, action: :contact, subject: User
45
+ # > Territory.accessible_through(ability, :contact, User).to_sql
46
+ # => SELECT * FROM territories WHERE ((id = 13) OR (id IN (2, 3, 4)))
47
+ #
48
+ # @param ability [Consent::Ability] ability performing the query
49
+ # @param action_or_pair [Symbol,String] the name of the action or a subject/action pair
50
+ # @param subject [Class,Symbol,nil] the subject in which the action is, when action_or_pair is just the action
51
+ # @param relation [Symbol,Array<Symbol>] the relation or path to the relation
52
+ #
53
+ def accessible_through(ability, action_or_pair, subject = nil, relation: nil)
54
+ relation ||= model_name.element.to_sym
55
+ ability.relation_model_adapter(self, action_or_pair, subject, relation)
56
+ .database_records
57
+ end
58
+ end
59
+
60
+ def self.included(base)
61
+ base.extend ClassMethods
62
+ end
63
+ end
64
+ end