consent 0.6.0 → 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.
Files changed (44) hide show
  1. checksums.yaml +4 -4
  2. data/.gitignore +14 -5
  3. data/.rubocop.yml +9 -0
  4. data/.rubocop_todo.yml +9 -11
  5. data/Gemfile +5 -1
  6. data/Rakefile +9 -3
  7. data/app/models/concerns/consent/authorizable.rb +94 -0
  8. data/app/models/consent/application_record.rb +7 -0
  9. data/app/models/consent/history.rb +20 -0
  10. data/app/models/consent/permission.rb +71 -0
  11. data/config.ru +9 -0
  12. data/consent.gemspec +24 -18
  13. data/db/migrate/20211104225614_create_nitro_auth_authorization_permissions.rb +19 -0
  14. data/db/migrate/20220420135558_create_nitro_auth_authorization_histories.rb +15 -0
  15. data/doc/dependency_decisions.yml +3 -0
  16. data/docs/CHANGELOG.md +23 -0
  17. data/docs/README.md +355 -0
  18. data/lib/consent/ability.rb +148 -6
  19. data/lib/consent/action.rb +8 -5
  20. data/lib/consent/dsl.rb +3 -1
  21. data/lib/consent/{railtie.rb → engine.rb} +11 -8
  22. data/lib/consent/model_additions.rb +64 -0
  23. data/lib/consent/permission_migration.rb +139 -0
  24. data/lib/consent/reloader.rb +6 -5
  25. data/lib/consent/rspec/consent_action.rb +65 -0
  26. data/lib/consent/rspec/consent_view.rb +76 -0
  27. data/lib/consent/rspec.rb +13 -73
  28. data/lib/consent/subject.rb +0 -9
  29. data/lib/consent/subject_coder.rb +29 -0
  30. data/lib/consent/symbol_adapter.rb +18 -0
  31. data/lib/consent/version.rb +1 -1
  32. data/lib/consent.rb +34 -32
  33. data/lib/generators/consent/permissions_generator.rb +5 -5
  34. data/mkdocs.yml +5 -0
  35. data/renovate.json +18 -0
  36. metadata +113 -33
  37. data/.rspec +0 -2
  38. data/.ruby-version +0 -1
  39. data/.travis.yml +0 -18
  40. data/LICENSE +0 -21
  41. data/README.md +0 -245
  42. data/TODO.md +0 -1
  43. data/lib/consent/permission.rb +0 -35
  44. data/lib/consent/permissions.rb +0 -41
@@ -0,0 +1,139 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Consent
4
+ # Permission migration helper module
5
+ module PermissionMigration
6
+ # Copy permissions from one existing permission into a new permission selecting with
7
+ # attrs would be overrided.
8
+ #
9
+ # I.e.:
10
+ #
11
+ # copy_permissions(
12
+ # from: { subject: :sale, action: :view },
13
+ # override: { subject: :project }
14
+ # )
15
+ #
16
+ # @param from [Hash] a hash with `:subject` and `:action` to select which permissions to copy
17
+ # @param override [Hash] hash to specify which fields/values to override
18
+ #
19
+ def copy_permissions(from:, override:)
20
+ raise ArgumentError, "Subject and Action are always required" if from[:subject].blank? || from[:action].blank?
21
+
22
+ ::Consent::Permission.to(**from).each do |permission|
23
+ ::Consent::Permission.create!(
24
+ permission.slice(:subject, :action, :view, :role_id).merge(override)
25
+ )
26
+ end
27
+ end
28
+
29
+ # Grant a permission to a collection of roles.
30
+ #
31
+ # I.e.:
32
+ #
33
+ # grant_permission(
34
+ # subject: :view_installer_pay_report,
35
+ # action: ProjectTask,
36
+ # role_ids: [2, 7, 140]
37
+ # )
38
+ #
39
+ # @param subject [symbol] the permission's subject
40
+ # @param action [Class, symbol] the permission's action
41
+ # @param role_ids [Array<Integer>] the collection of role_ids to grant the permission to
42
+ # @param view [String, nil] the view level, or access level, that the permission will be
43
+ # assigned at. If not specified, this will default to true ("1")
44
+ #
45
+ def grant_permission(subject:, action:, role_ids:, view: "1")
46
+ role_ids.each do |role_id|
47
+ ::Consent::Permission.create!(subject: subject,
48
+ action: action,
49
+ role_id: role_id,
50
+ view: view)
51
+ end
52
+ end
53
+
54
+ # Removes a permission from a collection of roles.
55
+ #
56
+ # I.e.:
57
+ #
58
+ # remove_permission(
59
+ # subject: :view,
60
+ # action: User,
61
+ # role_ids: [78, 12]
62
+ # )
63
+ #
64
+ # @param subject [symbol] the permission's subject
65
+ # @param action [Class, symbol] the permission's action
66
+ # @param role_ids [Array<Integer>] the collection of role_ids to grant the permission to
67
+ #
68
+ def remove_permission(subject:, action:, role_ids:)
69
+ role_ids.each do |role_id|
70
+ permission = ::Consent::Permission.find_by(subject: subject,
71
+ action: action,
72
+ role_id: role_id)
73
+ permission.destroy!
74
+ end
75
+ end
76
+
77
+ # Batch updates permission data
78
+ #
79
+ # * CAUTION *
80
+ # Updating a permission in a migration means that for some time the old permission
81
+ # will be broken in production. So, you might lock out people between the permission
82
+ # running and your code getting deployed/restarted in the webservers.
83
+ #
84
+ # Example:
85
+ # - Page A is only displayed to users that `can? :view, Candidate`
86
+ # - If you're willing to rename the `view` action to be `view_candidates`
87
+ # - Then you could go with a permission like this
88
+ # update_permissions(
89
+ # from: { subject: :candidate, action: :view },
90
+ # to: { action: :view_candidates }
91
+ # )
92
+ # - And you'll have to change the permission check to be `can? :view_candidates, Candidate`
93
+ # - When you merge your PR, then the migration will run first, and later on your code will
94
+ # reach production.
95
+ # - Between that time, the page that uses that permission will be unreachable since
96
+ # `can? :view, Candidate` doesn't exists anymore in the DB.
97
+ #
98
+ # I.e.:
99
+ #
100
+ # Renames a subject affecting all grantted permissions keeping everything else
101
+ #
102
+ # update_permissions(
103
+ # from: { subject: :sale },
104
+ # to: { subject: :project }
105
+ # )
106
+ #
107
+ # Moves an action from a subject to another keeping the view
108
+ #
109
+ # update_permissions(
110
+ # from: { subject: :sale, action: :perform },
111
+ # to: { subject: :project }
112
+ # )
113
+ #
114
+ # Rename an action within a subject keeping the view
115
+ #
116
+ # update_permissions(
117
+ # from: { subject: :sale, action: :read },
118
+ # to: { action: :inspect }
119
+ # )
120
+ #
121
+ # Rename a view within a subject and action context
122
+ #
123
+ # update_permissions(
124
+ # from: { subject: :sale, action: :read, view: :territory },
125
+ # to: { view: :department_territory }
126
+ # )
127
+ #
128
+ # @param from [Hash] a hash with `:subject`, `:action`, and `:view` to match the affected permissions
129
+ # @param to [Hash] a hash with `:subject`, `:action`, and/or `:view` with the desired change
130
+ #
131
+ def update_permissions(from:, to:)
132
+ raise ArgumentError, "Subject is always required" if from[:subject].blank?
133
+
134
+ ::Consent::Permission.to(**from).find_each do |permission|
135
+ permission.update(to)
136
+ end
137
+ end
138
+ end
139
+ end
@@ -2,20 +2,21 @@
2
2
 
3
3
  module Consent
4
4
  # Rails file reloader to detect permission changes and apply them to consent
5
+ # @private
5
6
  class Reloader
6
7
  attr_reader :paths
8
+
7
9
  delegate :updated?, :execute, :execute_if_updated, to: :updater
8
10
 
9
- def initialize(default_path, mechanism)
11
+ def initialize(default_path)
10
12
  @paths = [default_path]
11
- @mechanism = mechanism
12
13
  end
13
14
 
14
- private
15
+ private
15
16
 
16
17
  def reload!
17
18
  Consent.subjects.clear
18
- Consent.load_subjects! paths, @mechanism
19
+ Consent.load_subjects! paths
19
20
  end
20
21
 
21
22
  def updater
@@ -24,7 +25,7 @@ module Consent
24
25
 
25
26
  def globs
26
27
  pairs = paths.map { |path| [path.to_s, %w[rb]] }
27
- Hash[pairs]
28
+ pairs.to_h
28
29
  end
29
30
  end
30
31
  end
@@ -0,0 +1,65 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "consent"
4
+ RSpec::Support.require_rspec_support "fuzzy_matcher"
5
+
6
+ module Consent
7
+ module Rspec
8
+ # @private
9
+ class ConsentAction
10
+ def initialize(action_key)
11
+ @action_key = action_key
12
+ end
13
+
14
+ def with_views(*views)
15
+ @views = views
16
+ self
17
+ end
18
+
19
+ def description
20
+ message = "consents action #{@action_key}"
21
+ "#{message} with views #{@views}" if @views
22
+ end
23
+
24
+ def matches?(subject_key)
25
+ @subject_key = subject_key
26
+ @action = Consent.find_action(@subject_key, @action_key)
27
+ if @action && @views
28
+ RSpec::Support::FuzzyMatcher.values_match?(
29
+ @action.views.keys.sort,
30
+ @views.sort
31
+ )
32
+ else
33
+ !@action.nil?
34
+ end
35
+ end
36
+
37
+ def failure_message
38
+ failure_message_base "to"
39
+ end
40
+
41
+ def failure_message_when_negated
42
+ failure_message_base "to not"
43
+ end
44
+
45
+ private
46
+
47
+ def failure_message_base(failure) # rubocop:disable Metrics/MethodLength
48
+ message = format(
49
+ "expected %<skey>s (%<sclass>s) %<failure> provide action %<action>s",
50
+ skey: @subject_key.to_s, sclass: @subject_key.class,
51
+ action: @action_key, failure: failure
52
+ )
53
+
54
+ if @action && @views
55
+ format(
56
+ "%<message>s with views %<views>s, but actual views are %<keys>p",
57
+ message: message, views: @views, keys: @action.views.keys
58
+ )
59
+ else
60
+ message
61
+ end
62
+ end
63
+ end
64
+ end
65
+ end
@@ -0,0 +1,76 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Consent
4
+ module Rspec
5
+ # @private
6
+ class ConsentView
7
+ def initialize(view_key, conditions)
8
+ @conditions = comparable_conditions(conditions) if conditions
9
+ @view_key = view_key
10
+ end
11
+
12
+ def to(*context)
13
+ @context = context
14
+ self
15
+ end
16
+
17
+ def description
18
+ message = "consents view #{@view_key}"
19
+ "#{message} with conditions #{@conditions}" if @conditions
20
+ end
21
+
22
+ def with_conditions(conditions)
23
+ @conditions = comparable_conditions(conditions)
24
+ self
25
+ end
26
+
27
+ def matches?(subject_key)
28
+ @subject_key = subject_key
29
+ @target = Consent.find_subjects(subject_key)
30
+ .filter_map { |subject| subject.views[@view_key]&.conditions(*@context) }
31
+ .map { |c| comparable_conditions(c) }
32
+ @target.include?(@conditions)
33
+ end
34
+
35
+ def failure_message
36
+ failure_message_base "to"
37
+ end
38
+
39
+ def failure_message_when_negated
40
+ failure_message_base "to not"
41
+ end
42
+
43
+ private
44
+
45
+ def comparable_conditions(conditions)
46
+ return conditions.to_sql if conditions.respond_to?(:to_sql)
47
+
48
+ conditions
49
+ end
50
+
51
+ def failure_message_base(failure) # rubocop:disable Metrics/MethodLength
52
+ message = format(
53
+ 'expected %<skey>s (%<sclass>s) %<fail>s provide view %<view>s with`\
54
+ `%<conditions>p, but',
55
+ skey: @subject_key.to_s, sclass: @subject_key.class,
56
+ view: @view_key, conditions: @conditions, fail: failure
57
+ )
58
+
59
+ if @target.any?
60
+ format(
61
+ "%<message>s conditions are %<conditions>p",
62
+ message: message, conditions: @target
63
+ )
64
+ else
65
+ actual_views = Consent.find_subjects(@subject_key)
66
+ .map(&:views)
67
+ .map(&:keys).flatten
68
+ format(
69
+ "%<message>s available views are %<views>p",
70
+ message: message, views: actual_views
71
+ )
72
+ end
73
+ end
74
+ end
75
+ end
76
+ end
data/lib/consent/rspec.rb CHANGED
@@ -1,6 +1,9 @@
1
1
  # frozen_string_literal: true
2
2
 
3
- require 'consent'
3
+ require "consent"
4
+
5
+ require_relative "rspec/consent_action"
6
+ require_relative "rspec/consent_view"
4
7
 
5
8
  module Consent
6
9
  # RSpec helpers for consent. Given permissions are loaded,
@@ -20,7 +23,11 @@ module Consent
20
23
  # let(:user) { double(department_id: 15) }
21
24
  #
22
25
  # it do
23
- # is_expected.to consent_view(:department, department_id: 15).to(user)
26
+ # is_expected.to(
27
+ # consent_view(:department)
28
+ # .with_conditions(department_id: 15)
29
+ # .to(user)
30
+ # )
24
31
  # end
25
32
  # it { is_expected.to consent_action(:read) }
26
33
  # it { is_expected.to consent_action(:update).with_views(:department) }
@@ -29,79 +36,12 @@ module Consent
29
36
  # Find more examples at:
30
37
  # https://github.com/powerhome/consent
31
38
  module Rspec
32
- extend RSpec::Matchers::DSL
33
-
34
- matcher :consent_action do |action_key|
35
- chain :with_views do |*views|
36
- @views = views
37
- end
38
-
39
- match do |subject_key|
40
- action = Consent.find_action(subject_key, action_key)
41
- if action && @views
42
- values_match?(action.view_keys.sort, @views.sort)
43
- else
44
- !action.nil?
45
- end
46
- end
47
-
48
- failure_message do |subject_key|
49
- action = Consent.find_action(subject_key, action_key)
50
- message = format(
51
- 'expected %<skey>s (%<sclass>s) to provide action %<action>s',
52
- skey: subject_key.to_s, sclass: subject.class, action: action_key
53
- )
54
-
55
- if action && @views
56
- format(
57
- '%<message>s with views %<views>s, but actual views are %<keys>p',
58
- message: message, views: @views, keys: action.view_keys
59
- )
60
- else
61
- message
62
- end
63
- end
39
+ def consent_view(view_key, conditions = nil)
40
+ ConsentView.new(view_key, conditions)
64
41
  end
65
42
 
66
- matcher :consent_view do |view_key, conditions|
67
- chain :to do |*context|
68
- @context = context
69
- end
70
-
71
- match do |subject_key|
72
- view = Consent.find_view(subject_key, view_key)
73
- if conditions
74
- view&.conditions(*@context).eql?(conditions)
75
- else
76
- !view.nil?
77
- end
78
- end
79
-
80
- failure_message do |subject_key|
81
- view = Consent.find_view(subject_key, view_key)
82
- message = format(
83
- 'expected %<skey>s (%<sclass>s) to provide view %<view>s with` \
84
- `%<conditions>p, but',
85
- skey: subject_key.to_s, sclass: subject.class,
86
- view: view_key, conditions: conditions
87
- )
88
-
89
- if view && conditions
90
- actual_conditions = view.conditions(*@context)
91
- format(
92
- '%<message>s conditions are %<conditions>p',
93
- message: message, conditions: actual_conditions
94
- )
95
- else
96
- actual_views = Consent.find_subjects(subject_key)
97
- .map(&:views)
98
- .map(&:keys).flatten
99
- format(
100
- '%<message>s available views are %<views>p',
101
- message: message, views: actual_views
102
- )
103
- end
104
- end
43
+ def consent_action(action_key)
44
+ ConsentAction.new(action_key)
105
45
  end
106
46
  end
107
47
  end
@@ -10,14 +10,5 @@ module Consent
10
10
  @actions = []
11
11
  @views = Consent.default_views.clone
12
12
  end
13
-
14
- def permission_key
15
- ActiveSupport::Inflector.underscore(@key.to_s).to_sym
16
- end
17
-
18
- def view_for(action, key)
19
- view = @views.keys & action.view_keys & [key]
20
- @views[view.first] || @views[action.default_view]
21
- end
22
13
  end
23
14
  end
@@ -0,0 +1,29 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Consent
4
+ # Coder for ability subjects
5
+ module SubjectCoder
6
+ module_function
7
+
8
+ # Loads the serialized key (snake case string) as a valid
9
+ # permission subject (a constant or a symbol)
10
+ #
11
+ # @param key [String] snake case string representing a subject
12
+ # @return [Module,Class,Symbol]
13
+ def load(key)
14
+ return nil unless key
15
+
16
+ constant = key.camelize.safe_constantize
17
+ constant.is_a?(Class) ? constant : key.to_sym
18
+ end
19
+
20
+ # Dumps a serialized key (snake case string) from a valid
21
+ # permission subject (a constant or a symbol)
22
+ #
23
+ # @param key [Module,Class,Symbol] the subject key
24
+ # @return [String]
25
+ def dump(key)
26
+ key.to_s.underscore
27
+ end
28
+ end
29
+ end
@@ -0,0 +1,18 @@
1
+ # frozen_string_literal: true
2
+
3
+ module CanYouReally
4
+ # @private
5
+ class SymbolAdapter < ::CanCan::ModelAdapters::AbstractAdapter
6
+ def self.for_class?(subject)
7
+ subject.is_a?(Symbol) || subject == Symbol
8
+ end
9
+
10
+ def self.override_conditions_hash_matching?(_subject, _conditions)
11
+ true
12
+ end
13
+
14
+ def self.matches_conditions_hash?(_subject, _conditions)
15
+ true
16
+ end
17
+ end
18
+ end
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module Consent
4
- VERSION = '0.6.0'
4
+ VERSION = "2.0.0"
5
5
  end
data/lib/consent.rb CHANGED
@@ -1,20 +1,30 @@
1
1
  # frozen_string_literal: true
2
2
 
3
- require 'consent/version'
4
- require 'consent/subject'
5
- require 'consent/view'
6
- require 'consent/action'
7
- require 'consent/dsl'
8
- require 'consent/permission'
9
- require 'consent/permissions'
10
- require 'consent/ability' if defined?(CanCan)
11
- require 'consent/railtie' if defined?(Rails)
3
+ require "cancancan"
4
+
5
+ require "consent/ability"
6
+ require "consent/action"
7
+ require "consent/dsl"
8
+ require "consent/model_additions"
9
+ require "consent/reloader"
10
+ require "consent/subject_coder"
11
+ require "consent/subject"
12
+ require "consent/symbol_adapter"
13
+ require "consent/version"
14
+ require "consent/view"
12
15
 
13
16
  # Consent makes defining permissions easier by providing a clean,
14
- # concise DSL for authorization so that all abilities do not have
15
- # to be in your `Ability` class.
17
+ # concise DSL for authorization so that your `Ability` isn't bloated
18
+ # with all logic.
16
19
  module Consent
17
20
  FULL_ACCESS = %w[1 true].freeze
21
+ NO_ACCESS = :no_access
22
+
23
+ # Custom table_name_prefix for the Consent tables.
24
+ # Default: "consent_"
25
+ cattr_accessor(:table_name_prefix) { "consent_" }
26
+
27
+ ViewNotFound = Class.new(StandardError)
18
28
 
19
29
  # Default views available to every permission
20
30
  #
@@ -38,7 +48,7 @@ module Consent
38
48
  #
39
49
  # @return [Array<Consent::Subject>]
40
50
  def self.find_subjects(subject_key)
41
- @subjects.find_all do |subject|
51
+ subjects.find_all do |subject|
42
52
  subject.key.eql?(subject_key)
43
53
  end
44
54
  end
@@ -47,21 +57,20 @@ module Consent
47
57
  #
48
58
  # @return [Consent::Action,nil]
49
59
  def self.find_action(subject_key, action_key)
50
- Consent.find_subjects(subject_key)
51
- .map(&:actions).flatten
52
- .find do |action|
53
- action.key.eql?(action_key)
54
- end
60
+ find_subjects(subject_key)
61
+ .flat_map(&:actions)
62
+ .find do |action|
63
+ action.key.eql?(action_key)
64
+ end
55
65
  end
56
66
 
57
67
  # Finds a view within a subject context
58
68
  #
59
69
  # @return [Consent::View,nil]
60
- def self.find_view(subject_key, view_key)
61
- views = Consent.find_subjects(subject_key)
62
- .map(&:views)
63
- .reduce({}, &:merge)
64
- views[view_key]
70
+ def self.find_view(subject_key, action_key, view_key)
71
+ find_action(subject_key, action_key)&.then do |action|
72
+ action.views[view_key] || raise(Consent::ViewNotFound)
73
+ end
65
74
  end
66
75
 
67
76
  # Loads all permission (ruby) files from the given directory
@@ -69,9 +78,9 @@ module Consent
69
78
  #
70
79
  # @param paths [Array<String,#to_s>] paths where the ruby files are located
71
80
  # @param mechanism [:require,:load] mechanism to load the files
72
- def self.load_subjects!(paths, mechanism = :require)
73
- permission_files = paths.map { |dir| File.join(dir, '*.rb') }
74
- Dir[*permission_files].each(&Kernel.method(mechanism))
81
+ def self.load_subjects!(paths)
82
+ permission_files = paths.map { |dir| File.join(dir, "*.rb") }
83
+ Dir[*permission_files].each { |file| Kernel.load(file) }
75
84
  end
76
85
 
77
86
  # Defines a subject with the given key, label and options
@@ -90,11 +99,4 @@ module Consent
90
99
  DSL.build(subject, defaults, &block)
91
100
  end
92
101
  end
93
-
94
- # Maps a permissions hash to a Consent::Permissions
95
- #
96
- # @return [Consent::Permissions]
97
- def self.permissions(permissions)
98
- Permissions.new(permissions)
99
- end
100
102
  end
@@ -2,19 +2,19 @@
2
2
 
3
3
  module Consent
4
4
  class PermissionsGenerator < Rails::Generators::NamedBase # :nodoc:
5
- source_root File.expand_path('templates', __dir__)
5
+ source_root File.expand_path("templates", __dir__)
6
6
  argument :description, type: :string, required: false
7
7
 
8
8
  def create_permissions
9
9
  template(
10
- 'permissions.rb.erb',
11
- "app/permissions/#{file_path}.rb",
10
+ "permissions.rb.erb",
11
+ "app/permissions/#{plural_file_name}.rb",
12
12
  assigns: { description: description }
13
13
  )
14
14
 
15
15
  template(
16
- 'permissions_spec.rb.erb',
17
- "spec/permissions/#{file_path}_spec.rb"
16
+ "permissions_spec.rb.erb",
17
+ "spec/permissions/#{plural_file_name}_spec.rb"
18
18
  )
19
19
  end
20
20
  end
data/mkdocs.yml ADDED
@@ -0,0 +1,5 @@
1
+ site_name: Consent
2
+ nav:
3
+ - "Home": "README.md"
4
+ plugins:
5
+ - techdocs-core
data/renovate.json ADDED
@@ -0,0 +1,18 @@
1
+ {
2
+ "extends": ["config:base", "group:allNonMajor"],
3
+ "lockFileMaintenance": {
4
+ "enabled": true
5
+ },
6
+ "labels": ["dependencies"],
7
+ "timezone": "America/New_York",
8
+ "packageRules": [
9
+ {
10
+ "matchUpdateTypes": ["minor", "patch", "pin", "digest"],
11
+ "automerge": true
12
+ },
13
+ {
14
+ "matchDepTypes": ["devDependencies"],
15
+ "automerge": true
16
+ }
17
+ ]
18
+ }