petergate 3.1.1 → 4.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.
@@ -0,0 +1,34 @@
1
+ module Petergate
2
+ # One `access` declaration: the scope it authorizes against, the role => actions
3
+ # rules, and the denial message set alongside them.
4
+ #
5
+ # `scope` is the value as written -- a Class for `access Employee`, a Symbol
6
+ # for `access :member`. It is deliberately not resolved to a Devise scope
7
+ # here: under STI several classes resolve to the same Devise scope, and rules
8
+ # are keyed by what was declared so they stay distinct.
9
+ #
10
+ # `defaulted` marks a rule that named no scope, so it follows whatever
11
+ # `petergate_scope` the controller reading it has. That is what makes
12
+ # `petergate_scope` in a base controller govern rules declared above it, and
13
+ # it is why the scope is resolved when rules are read rather than written --
14
+ # rewriting the key at declaration time cannot see the subclasses to come, and
15
+ # cannot tell a defaulted :user from one someone asked for by name.
16
+ Rule = Struct.new(:scope, :rules, :message, :defaulted, keyword_init: true) do
17
+ # The scope this rule authorizes against, for the controller reading it.
18
+ def scope_for(controller_class)
19
+ defaulted ? controller_class.petergate_scope : scope
20
+ end
21
+ end
22
+
23
+ # The hash key a defaulted rule is filed under. A sentinel rather than the
24
+ # current default, so it stays distinct from `access :user, ...` and cannot
25
+ # collide with a rule declared for a named scope.
26
+ DEFAULT_SCOPE = :"petergate.default_scope"
27
+
28
+ # Raised when a declared scope has no authentication helper behind it, which
29
+ # nearly always means a missing `devise_for` or a typo.
30
+ #
31
+ # StandardError rather than NameError: applications rescue NameError around
32
+ # autoloading and would swallow this.
33
+ class MissingScopeError < StandardError; end
34
+ end
@@ -1,3 +1,3 @@
1
1
  module Petergate
2
- VERSION = "3.1.1"
2
+ VERSION = "4.0.0"
3
3
  end
data/lib/petergate.rb CHANGED
@@ -1,8 +1,109 @@
1
1
  require "petergate/version"
2
2
  require "petergate/railtie"
3
+ require "petergate/rule"
3
4
  require 'petergate/action_controller/base'
4
5
  require 'petergate/active_record/base'
5
6
 
6
7
  module Petergate
8
+ # The Devise scope a declared petergate scope resolves to.
9
+ #
10
+ # A Symbol is taken at face value: it names the Devise mapping (or, without
11
+ # Devise, whatever `current_<name>` the application provides). A Class is
12
+ # looked up in Devise's mappings -- an exact match first, so a model with its
13
+ # own `devise_for` wins over an ancestor's, which is what `find_scope!` alone
14
+ # gets wrong for STI. A class with no mapping of its own resolves to its
15
+ # nearest mapped ancestor: under STI that is the parent's scope, which is
16
+ # correct, because there is only one login.
17
+ #
18
+ # Devise.mappings is read lazily, never at boot: on Rails 8 it calls
19
+ # reload_routes_unless_loaded.
20
+ def self.devise_scope_for(declared)
21
+ return declared unless declared.is_a?(Class)
22
+ return derived_scope_for(declared) unless defined?(::Devise) && ::Devise.respond_to?(:mappings)
7
23
 
24
+ mappings = ::Devise.mappings
25
+ exact = mappings.each_value.find { |mapping| mapping.to == declared }
26
+ return exact.name if exact
27
+
28
+ # The ancestor walk find_scope! does internally, minus the raise -- so no
29
+ # rescue is needed to turn "no mapping" into the fallback.
30
+ #
31
+ # A method-level `rescue StandardError` here used to cover ::Devise.mappings
32
+ # above as well, and reading that loads routes on Rails 8. A NameError in
33
+ # routes.rb came back as a MissingScopeError naming the scope, which sends
34
+ # whoever reads it after the wrong bug. A routes error now surfaces as
35
+ # itself.
36
+ #
37
+ # Falling through means Devise is installed but covers nothing this class
38
+ # descends from, so authentication for it is hand-rolled -- which the README
39
+ # supports. Fall back to the class's own name.
40
+ mappings.each_value.find { |mapping| declared <= mapping.to }&.name ||
41
+ derived_scope_for(declared)
42
+ end
43
+
44
+ # Roles already warned about, and the lock guarding it. Created at load time:
45
+ # a lazy `||=` would itself race on the authorization path.
46
+ @warned_roles = {}
47
+ @warned_roles_lock = Mutex.new
48
+
49
+ # Reports a stored role that the record's own class does not define.
50
+ #
51
+ # Once per class and role: this is read on every authorization check, and a
52
+ # warning per request would be noise rather than a signal. Kernel#warn rather
53
+ # than ActiveSupport::Deprecation -- nothing is deprecated, and the data is
54
+ # wrong now.
55
+ def self.warn_about_unavailable_roles(klass, rejected)
56
+ rejected.each do |role|
57
+ key = [klass.name, role]
58
+
59
+ # Read before locking. #roles is read once per declared rule on every
60
+ # request, so for an application whose data has drifted -- exactly the
61
+ # audience this warning is for -- taking the mutex unconditionally would
62
+ # serialize every thread on it forever, long after the warning had been
63
+ # printed. The lock guards the write only, and losing the race costs one
64
+ # duplicate line.
65
+ next if @warned_roles.key?(key)
66
+
67
+ # Reached from the authorization path of every request, so without this
68
+ # concurrent threads could mutate the hash while another iterates it.
69
+ first_time = @warned_roles_lock.synchronize do
70
+ next false if @warned_roles.key?(key)
71
+
72
+ @warned_roles[key] = true
73
+ end
74
+ next unless first_time
75
+
76
+ warn "petergate: #{klass.name} is ignoring the stored role #{role.inspect}. " +
77
+ if role.is_a?(String) && klass::ROLES.include?(role.to_sym)
78
+ "#{klass.name} declares #{role.to_sym.inspect}, but the column holds it as a " \
79
+ "string. petergate stores roles as symbols, so this row was written by something " \
80
+ "other than `roles=` -- raw SQL, update_column, or an import."
81
+ else
82
+ "#{klass.name} does not declare it; #{klass.name}::ROLES is #{klass::ROLES.inspect}. " \
83
+ "A record whose type changed, or a role removed from a petergate declaration, " \
84
+ "leaves roles behind in the column."
85
+ end
86
+ end
87
+ end
88
+
89
+ # The scope name a class implies when Devise cannot answer.
90
+ #
91
+ # Derived from the STI *base* class, so subclasses sharing one table share one
92
+ # scope -- `Employee < User` asks `current_user`, which is the whole point of
93
+ # STI -- while an independent model gets a scope of its own.
94
+ def self.derived_scope_for(klass)
95
+ base = klass.respond_to?(:base_class) ? klass.base_class : klass
96
+ base.model_name.singular_route_key.to_sym
97
+ rescue StandardError
98
+ :user
99
+ end
100
+
101
+ # The scope name this class would have entirely on its own, ignoring any
102
+ # hierarchy. Used only to tell "resolved to something else's login" from
103
+ # "resolved to its own", which the missing-scope message reports differently.
104
+ def self.own_scope_name_for(klass)
105
+ klass.model_name.singular_route_key.to_sym
106
+ rescue StandardError
107
+ nil
108
+ end
8
109
  end
data/petergate.gemspec CHANGED
@@ -24,7 +24,25 @@ Gem::Specification.new do |spec|
24
24
  spec.files = `git ls-files -z`.split("\x0").reject { |p| p.start_with?("test/", "gemfiles/", ".github/", "assets/") }
25
25
  spec.executables = spec.files.grep(%r{^bin/}) { |f| File.basename(f) }
26
26
  spec.require_paths = ["lib"]
27
- spec.post_install_message = "NOTICE: As of version 1.5.0, the :admin role has been changed to :root_admin."
27
+
28
+ # The floor Rails 8.0 and 8.1 already set, so this takes nothing away that
29
+ # the dependencies below did not. Ruby 4.0 is covered by CI as well; nothing
30
+ # here is pinned to a major.
31
+ spec.required_ruby_version = ">= 3.2.0"
32
+ spec.post_install_message = <<~NOTICE
33
+ NOTICE: 4.0.0 has three breaking changes. All of them can only take access
34
+ away, never grant it.
35
+
36
+ * `roles` returns only roles the record's own class defines. A role left in
37
+ the column by a `type` change, or by being dropped from a petergate
38
+ declaration, no longer grants. Roles set through `roles=` are unaffected.
39
+ * A subclass declaring `access` now runs the check at its own position in
40
+ the callback chain, so filters declared above it run before the denial.
41
+ * `:all` and `except:` cover fewer methods: `all_actions` no longer counts
42
+ methods that were never actions.
43
+
44
+ See CHANGELOG.md.
45
+ NOTICE
28
46
 
29
47
  spec.add_development_dependency "bundler", "> 1.7"
30
48
  spec.add_development_dependency "rake", ">= 12.3"
metadata CHANGED
@@ -1,7 +1,7 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: petergate
3
3
  version: !ruby/object:Gem::Version
4
- version: 3.1.1
4
+ version: 4.0.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - Isaac Sloan
@@ -104,6 +104,7 @@ extra_rdoc_files: []
104
104
  files:
105
105
  - ".gitignore"
106
106
  - ".ruby-version"
107
+ - CHANGELOG.md
107
108
  - Gemfile
108
109
  - LICENSE.txt
109
110
  - README.md
@@ -114,6 +115,7 @@ files:
114
115
  - lib/petergate/action_controller/base.rb
115
116
  - lib/petergate/active_record/base.rb
116
117
  - lib/petergate/railtie.rb
118
+ - lib/petergate/rule.rb
117
119
  - lib/petergate/version.rb
118
120
  - lib/templates/rails/scaffold_controller/controller.rb
119
121
  - petergate.gemspec
@@ -123,8 +125,19 @@ licenses:
123
125
  metadata:
124
126
  source_code_uri: https://github.com/elorest/petergate
125
127
  bug_tracker_uri: https://github.com/elorest/petergate/issues
126
- post_install_message: 'NOTICE: As of version 1.5.0, the :admin role has been changed
127
- to :root_admin.'
128
+ post_install_message: |
129
+ NOTICE: 4.0.0 has three breaking changes. All of them can only take access
130
+ away, never grant it.
131
+
132
+ * `roles` returns only roles the record's own class defines. A role left in
133
+ the column by a `type` change, or by being dropped from a petergate
134
+ declaration, no longer grants. Roles set through `roles=` are unaffected.
135
+ * A subclass declaring `access` now runs the check at its own position in
136
+ the callback chain, so filters declared above it run before the denial.
137
+ * `:all` and `except:` cover fewer methods: `all_actions` no longer counts
138
+ methods that were never actions.
139
+
140
+ See CHANGELOG.md.
128
141
  rdoc_options: []
129
142
  require_paths:
130
143
  - lib
@@ -132,14 +145,14 @@ required_ruby_version: !ruby/object:Gem::Requirement
132
145
  requirements:
133
146
  - - ">="
134
147
  - !ruby/object:Gem::Version
135
- version: '0'
148
+ version: 3.2.0
136
149
  required_rubygems_version: !ruby/object:Gem::Requirement
137
150
  requirements:
138
151
  - - ">="
139
152
  - !ruby/object:Gem::Version
140
153
  version: '0'
141
154
  requirements: []
142
- rubygems_version: 3.6.9
155
+ rubygems_version: 4.0.16
143
156
  specification_version: 4
144
157
  summary: Authorization system allowing verbose easy read controller syntax.
145
158
  test_files: []