consent 1.0.1 → 2.0.0

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