consent 1.0.1 → 2.0.0

Sign up to get free protection for your applications and to get access to all the features.
@@ -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
@@ -1,7 +1,7 @@
1
1
  # frozen_string_literal: true
2
2
 
3
- require 'consent'
4
- RSpec::Support.require_rspec_support 'fuzzy_matcher'
3
+ require "consent"
4
+ RSpec::Support.require_rspec_support "fuzzy_matcher"
5
5
 
6
6
  module Consent
7
7
  module Rspec
@@ -35,25 +35,25 @@ module Consent
35
35
  end
36
36
 
37
37
  def failure_message
38
- failure_message_base 'to'
38
+ failure_message_base "to"
39
39
  end
40
40
 
41
41
  def failure_message_when_negated
42
- failure_message_base 'to not'
42
+ failure_message_base "to not"
43
43
  end
44
44
 
45
- private
45
+ private
46
46
 
47
47
  def failure_message_base(failure) # rubocop:disable Metrics/MethodLength
48
48
  message = format(
49
- 'expected %<skey>s (%<sclass>s) %<failure> provide action %<action>s',
49
+ "expected %<skey>s (%<sclass>s) %<failure> provide action %<action>s",
50
50
  skey: @subject_key.to_s, sclass: @subject_key.class,
51
51
  action: @action_key, failure: failure
52
52
  )
53
53
 
54
54
  if @action && @views
55
55
  format(
56
- '%<message>s with views %<views>s, but actual views are %<keys>p',
56
+ "%<message>s with views %<views>s, but actual views are %<keys>p",
57
57
  message: message, views: @views, keys: @action.views.keys
58
58
  )
59
59
  else
@@ -27,23 +27,20 @@ module Consent
27
27
  def matches?(subject_key)
28
28
  @subject_key = subject_key
29
29
  @target = Consent.find_subjects(subject_key)
30
- .map do |subject|
31
- subject.views[@view_key]&.conditions(*@context)
32
- end
33
- .compact
34
- .map(&method(:comparable_conditions))
30
+ .filter_map { |subject| subject.views[@view_key]&.conditions(*@context) }
31
+ .map { |c| comparable_conditions(c) }
35
32
  @target.include?(@conditions)
36
33
  end
37
34
 
38
35
  def failure_message
39
- failure_message_base 'to'
36
+ failure_message_base "to"
40
37
  end
41
38
 
42
39
  def failure_message_when_negated
43
- failure_message_base 'to not'
40
+ failure_message_base "to not"
44
41
  end
45
42
 
46
- private
43
+ private
47
44
 
48
45
  def comparable_conditions(conditions)
49
46
  return conditions.to_sql if conditions.respond_to?(:to_sql)
@@ -61,15 +58,15 @@ module Consent
61
58
 
62
59
  if @target.any?
63
60
  format(
64
- '%<message>s conditions are %<conditions>p',
61
+ "%<message>s conditions are %<conditions>p",
65
62
  message: message, conditions: @target
66
63
  )
67
64
  else
68
- actual_views = Consent.find_subjects(subject_key)
65
+ actual_views = Consent.find_subjects(@subject_key)
69
66
  .map(&:views)
70
67
  .map(&:keys).flatten
71
68
  format(
72
- '%<message>s available views are %<views>p',
69
+ "%<message>s available views are %<views>p",
73
70
  message: message, views: actual_views
74
71
  )
75
72
  end
data/lib/consent/rspec.rb CHANGED
@@ -1,9 +1,9 @@
1
1
  # frozen_string_literal: true
2
2
 
3
- require 'consent'
3
+ require "consent"
4
4
 
5
- require_relative 'rspec/consent_action'
6
- require_relative 'rspec/consent_view'
5
+ require_relative "rspec/consent_action"
6
+ require_relative "rspec/consent_view"
7
7
 
8
8
  module Consent
9
9
  # RSpec helpers for consent. Given permissions are loaded,
@@ -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 = '1.0.1'
4
+ VERSION = "2.0.0"
5
5
  end
data/lib/consent.rb CHANGED
@@ -1,17 +1,29 @@
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/ability' if defined?(CanCan)
9
- 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"
10
15
 
11
16
  # Consent makes defining permissions easier by providing a clean,
12
- # concise DSL for authorization so that all abilities do not have
13
- # to be in your `Ability` class.
17
+ # concise DSL for authorization so that your `Ability` isn't bloated
18
+ # with all logic.
14
19
  module Consent
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
+
15
27
  ViewNotFound = Class.new(StandardError)
16
28
 
17
29
  # Default views available to every permission
@@ -56,7 +68,7 @@ module Consent
56
68
  #
57
69
  # @return [Consent::View,nil]
58
70
  def self.find_view(subject_key, action_key, view_key)
59
- find_action(subject_key, action_key)&.yield_self do |action|
71
+ find_action(subject_key, action_key)&.then do |action|
60
72
  action.views[view_key] || raise(Consent::ViewNotFound)
61
73
  end
62
74
  end
@@ -66,9 +78,9 @@ module Consent
66
78
  #
67
79
  # @param paths [Array<String,#to_s>] paths where the ruby files are located
68
80
  # @param mechanism [:require,:load] mechanism to load the files
69
- def self.load_subjects!(paths, mechanism = :require)
70
- permission_files = paths.map { |dir| File.join(dir, '*.rb') }
71
- 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) }
72
84
  end
73
85
 
74
86
  # Defines a subject with the given key, label and options
@@ -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 CHANGED
@@ -1,5 +1,18 @@
1
1
  {
2
- "extends": [
3
- "config:base"
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
+ }
4
17
  ]
5
18
  }