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.
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: acd5072223d85700246df5acd1445b292a918cf85a3b51b4ee42ae03c3648630
4
- data.tar.gz: d084364761c8234ac5db4cc4434e88337913f07dea42cf7d2d3fdc15b3b8261d
3
+ metadata.gz: 1e7b356d1fe3ebefd2d991eb56b317cb6eca56f6840107598fd3a5fa6eac33d4
4
+ data.tar.gz: 2d63adb685f6dd63476e21a7f51fa52f10687e87be3155b010c629503bb48619
5
5
  SHA512:
6
- metadata.gz: ffef6582b1bcd3d55d01c4655dff65a7d12ee47fbefeb32ba2339911db89827923a05e876396261c00849f966ecb36b3470247c057eb99dc60cf0822c8ee26f5
7
- data.tar.gz: 9c4e7e84b9e07811651f6cd172f4b32b92540bd47a24a2ec986f028d64b7a3786d3a800aefe879f9d81f04700360a05f007d36148a22dea8b80b5456cc2d938e
6
+ metadata.gz: b23d89a01df2e68c8257b2e3380703617afb3d13fff7703e3f32de2ac2d34c0c148103af0c12185cc5041d10f90717aa5fa9193ad58352af76e5b9ad663c8dc5
7
+ data.tar.gz: 5e16dde928a7581e4b5123c23639fde74a773473ff976559317732552adad7c99bcbe22e6ebc0e397e91673b2a41c62da7e6f8958e5752a5bf569e1668c30f90
data/.ruby-version CHANGED
@@ -1 +1 @@
1
- 3.2.0
1
+ 4.0.6
data/CHANGELOG.md ADDED
@@ -0,0 +1,142 @@
1
+ # Changelog
2
+
3
+ ## 4.0.0
4
+
5
+ ### Breaking
6
+
7
+ Three things behave differently for code that already exists, which is what
8
+ makes this 4.0.0 rather than a point release.
9
+
10
+ All three can only take access away, never grant it, and each fails visibly
11
+ when it bites -- a denial, not a silently widened door. An upgrade cannot
12
+ quietly let someone in who was previously refused, which is the direction an
13
+ authorization library should fail in. That is worth knowing before upgrading,
14
+ but it describes the shape of the risk rather than making the release a
15
+ smaller one than it is.
16
+
17
+ - **An application whose stored roles have drifted from its declared ones will
18
+ see those roles stop granting access.** That means a role left behind by an
19
+ STI `type` change, or one removed from a `petergate` declaration while rows
20
+ still carry it. See `roles` under Changed.
21
+
22
+ Roles written through `roles=` or `role=` are unaffected. Reaching this needs
23
+ a value the setter never wrote -- from `update_column`, raw SQL, a fixture or
24
+ an import.
25
+
26
+ - **A subclass that declares `access` now runs petergate's check at its own
27
+ position in the callback chain**, not the parent's. Work in a `before_action`
28
+ declared above it now runs before the denial rather than after. See
29
+ `petergate_check_access!` under Changed.
30
+
31
+ - **`:all` and `except:` cover fewer methods**, because `all_actions` no longer
32
+ counts things that were never actions. An application whose rules were
33
+ granting one of those was granting a method nobody can route to. See
34
+ `all_actions` under Changed.
35
+
36
+ ### Added
37
+
38
+ - `access` can authorize against a model other than `User`. A controller names
39
+ the model with `petergate_scope`, inherited by its subclasses, or a single
40
+ rule set names one as `access`'s first argument:
41
+
42
+ ```ruby
43
+ class Staff::BaseController < ApplicationController
44
+ petergate_scope Employee
45
+ end
46
+
47
+ class InvoicesController < ApplicationController
48
+ access Vendor, supplier: :all
49
+ access Employee, admin: [:index]
50
+ end
51
+ ```
52
+
53
+ One declaration covers both ways an application has several kinds of user:
54
+ a subclass sharing one login through single table inheritance, and a
55
+ separately authenticated model with a login of its own. Which one is in play
56
+ is resolved from Devise's mappings at request time rather than configured.
57
+
58
+ Several `access` calls in one class body are OR'd -- whichever scope is
59
+ satisfied grants the action. A subclass declaring `access` replaces everything
60
+ it inherited, exactly as before, so narrowing a parent's rules in a subclass
61
+ still narrows them rather than adding an alternative way in.
62
+
63
+ Type matching is exact: `access Employee` does not admit a `Manager < Employee`.
64
+
65
+ - The denial message can be given as a string before the rules --
66
+ `access "Staff only", admin: :all` -- so it too is out of the rules hash.
67
+
68
+ - `logged_in?` and `user_logged_in?` take a `scope:` keyword. Both default to
69
+ the controller's own scope.
70
+
71
+ - The install generator takes a model name: `rails g petergate:install Employee`,
72
+ with an optional `--table-name`. With no argument its output is unchanged.
73
+
74
+ - `Petergate::MissingScopeError`, raised when a declared scope has no
75
+ authentication helper behind it, rather than failing quietly.
76
+
77
+ ### Changed
78
+
79
+ - **`roles` now returns only roles the record's own class defines.** `roles=`
80
+ has always filtered against `available_roles`, but the reader did not, so a
81
+ role left in the column by an STI `type` change -- or by a role being dropped
82
+ from a `petergate` declaration -- kept authorizing. This closes that without a
83
+ migration. Each ignored role is warned about once, naming the class and the
84
+ role.
85
+
86
+ Roles set through `roles=` or `role=` are unaffected, in either storage mode.
87
+ A multi-role column holding *strings* rather than symbols is rejected rather
88
+ than normalized, so this cannot start granting a role that previously matched
89
+ nothing -- see Breaking.
90
+
91
+ - `all_actions` returns only actions. Rails' `action_methods` includes public
92
+ methods inherited from a concrete superclass, so overriding `current_user` in
93
+ `ApplicationController` -- or using `devise_group` -- put those names into
94
+ `:all` and `except:` rules as though they were actions. It is now memoized
95
+ against Rails' own `action_methods`, making it faster than before the fix.
96
+
97
+ - **petergate's check runs at the position of the `access` call that declared
98
+ the rules**, for every class that declares them, and there is now exactly one
99
+ callback however many times `access` is called.
100
+
101
+ On 3.1.1 a parent and a subclass each declaring `access` registered two, and
102
+ the parent's ran first -- so a request was refused before any `before_action`
103
+ the subclass declared had run. That made `access` unusable in a subclass that
104
+ has to set its own authentication up first:
105
+
106
+ ```ruby
107
+ class Api::BaseController < ApplicationController
108
+ before_action :authenticate_from_token
109
+ access admin: :all # now runs after the token lookup
110
+ end
111
+ ```
112
+
113
+ An application relying on the old ordering -- an early filter written knowing
114
+ petergate had already refused anonymous requests -- should move that filter
115
+ below the `access` call. The callback is a named method rather than a block,
116
+ so `skip_before_action :petergate_check_access!` can also reach it, which it
117
+ could not before.
118
+
119
+ ### Deprecated
120
+
121
+ - `controller_rules` and `controller_message` as a way to reach the rules.
122
+ petergate's own callback no longer calls either, and neither can describe a
123
+ controller with more than one scope: `controller_rules` returns a single
124
+ rule's hash, so an application still calling
125
+ `permissions(self.class.controller_rules)` from its own filter checks one
126
+ scope of several without saying so. Declare the rules with `access` and let
127
+ petergate evaluate them.
128
+
129
+ - `message:` inside the `access` rules hash. Pass the message as a string before
130
+ the rules instead. The key still works, though a positional string wins when
131
+ both are given, but it warns, naming the file and line to change.
132
+
133
+ ### Fixed
134
+
135
+ - `user_logged_in?` is registered as a view helper. It has always been
136
+ documented as one, but calling it from a view raised `NoMethodError`.
137
+
138
+ - The install generator no longer sleeps for a second per run.
139
+
140
+ ## 3.1.1 and earlier
141
+
142
+ Not recorded here; see the commit history.
data/Gemfile CHANGED
@@ -10,6 +10,18 @@ gem "rails", "~> 8.1"
10
10
  gem "sqlite3", "~> 2.0"
11
11
  gem "rake", ">= 13.0"
12
12
 
13
+ # json 3.0 (released 2026-09-07) made JSON.parse's options keyword-only, while
14
+ # ActiveSupport 8.1's ActiveSupport::JSON.decode still passes them positionally
15
+ # -- which breaks encrypted cookies with metadata, and so any test that reads a
16
+ # flash message. Only Rails 8.1 reaches that path.
17
+ #
18
+ # Remove once activesupport ships the fix. What to check is one line --
19
+ # activesupport/lib/active_support/json/decoding.rb: 8.1.3.1 has
20
+ # `::JSON.parse(json, options)`, and the fix reads `::JSON.parse(json, **options)`,
21
+ # which is already what rails main carries:
22
+ # https://github.com/rails/rails/blob/main/activesupport/lib/active_support/json/decoding.rb
23
+ gem "json", "< 3"
24
+
13
25
  # Devise is not a dependency of the gem -- petergate only needs the three
14
26
  # authentication methods the README documents. It is here so one test can prove
15
27
  # a real Devise app satisfies that contract.
data/README.md CHANGED
@@ -11,9 +11,13 @@
11
11
 
12
12
  Requirements
13
13
  ------
14
- Rails 7.1 through 8.1 on Ruby 3.2 through 3.4 are covered by CI. Older Rails
14
+ Rails 7.1 through 8.1 on Ruby 3.2 through 4.0 are covered by CI. Older Rails
15
15
  versions are permitted by the gemspec but are not verified.
16
16
 
17
+ The gemspec requires Ruby 3.2, which is the floor Rails 8.0 sets. `.ruby-version`
18
+ names the newest covered Ruby rather than the oldest, so development happens on
19
+ the version most likely to surface a deprecation first.
20
+
17
21
  Installation
18
22
  ------
19
23
  ##### Get the gem
@@ -31,9 +35,6 @@ Or install it yourself as:
31
35
 
32
36
  ##### Prerequisites: Setup Authentication (Devise)
33
37
 
34
- The generator writes into `app/models/user.rb`, so a model named `User` is the
35
- supported setup.
36
-
37
38
  If you're using [devise](https://github.com/heartcombo/devise) you're in luck,
38
39
  otherwise you'll have to add the following methods to your project:
39
40
 
@@ -51,6 +52,12 @@ there.
51
52
 
52
53
  This will add a migration and insert petergate into your User model.
53
54
 
55
+ The model defaults to `User`; pass another to configure it instead. The model
56
+ has to exist already.
57
+
58
+ rails g petergate:install Employee # app/models/employee.rb
59
+ rails g petergate:install Employee --table-name=staff
60
+
54
61
  Usage
55
62
  ------
56
63
  #### User Model
@@ -114,8 +121,38 @@ visitors who aren't signed in. The value is one of:
114
121
  | `:all` | every action on the controller |
115
122
  | `{except: [:destroy]}` | every action except those |
116
123
 
117
- `:root_admin` is not a rule you write -- a user holding it bypasses the rules
118
- entirely.
124
+ ##### The `:root_admin` role
125
+
126
+ `:root_admin` is the one role name petergate treats specially. It is checked
127
+ before any rule, so a user holding it reaches every action on every controller
128
+ that uses `access`, and no rule ever names it:
129
+
130
+ ```ruby
131
+ class ArticlesController < ApplicationController
132
+ access all: [:index, :show], editor: :all
133
+ end
134
+ ```
135
+
136
+ An `:editor` gets what the rule says. A `:root_admin` gets all of it too,
137
+ without appearing in the rule at all.
138
+
139
+ It is not automatic, though. Like any other role it has to be declared before
140
+ anyone can hold it, because `roles=` drops anything the model does not define:
141
+
142
+ ```ruby
143
+ petergate(roles: [:root_admin, :editor], multiple: true)
144
+ ```
145
+
146
+ An application that never declares it has no such bypass, which is a reasonable
147
+ choice -- it exists for the account that must never be locked out of its own
148
+ admin area, not as a convenience for ordinary administrators. Prefer a normal
149
+ role you grant explicitly; reach for `:root_admin` when you specifically want an
150
+ account no `access` rule can shut out.
151
+
152
+ Two limits. It only applies where `access` is used: a controller with no rules
153
+ has nothing to bypass. And with several authentication scopes on one controller
154
+ it counts only within the scopes that controller declares, so a `:root_admin` in
155
+ one scope cannot walk into a controller whose rules are all about another.
119
156
 
120
157
  Rules declared on a parent controller are inherited by its subclasses, so a
121
158
  single `access` line on `ApplicationController` can cover a whole app.
@@ -124,6 +161,74 @@ single `access` line on `ApplicationController` can cover a whole app.
124
161
  refused request answers with a bare `403`, and an unauthenticated one with
125
162
  `401`, instead of redirecting.
126
163
 
164
+ #### Multiple authentication models
165
+
166
+ By default every rule is about `current_user`. An application with more than one
167
+ kind of signed-in person can say which one a rule means: `petergate_scope` sets
168
+ it for a whole controller tree, and `access` takes it as a first argument for a
169
+ single rule set.
170
+
171
+ ```ruby
172
+ class Staff::BaseController < ApplicationController
173
+ petergate_scope Employee # every controller below this authorizes employees
174
+ end
175
+
176
+ class Staff::PayrollController < Staff::BaseController
177
+ access admin: :all, support: [:index, :show]
178
+ end
179
+
180
+ class InvoicesController < ApplicationController
181
+ access Vendor, supplier: :all
182
+ access Employee, admin: [:index]
183
+ end
184
+ ```
185
+
186
+ The same declaration covers both ways of having several kinds of user, because
187
+ petergate reads which one you have off Devise's mappings:
188
+
189
+ | Declared | Resolves to | Reads |
190
+ | --- | --- | --- |
191
+ | an STI subclass, e.g. `Employee < User` | the parent's scope | `current_user`, required to be exactly an `Employee` |
192
+ | a separately mapped model, e.g. `Vendor` | its own scope | `current_vendor` |
193
+
194
+ Matching is exact: `access Employee` does not admit a `Manager < Employee`, so
195
+ each kind names itself. A Symbol scope -- `access :member, ...` -- names a Devise
196
+ mapping directly and does no type check, which is what you want for
197
+ `devise_for :users, singular: :member`, where no class carries the name.
198
+
199
+ Several `access` calls in one class body are **OR**'d: whichever scope is
200
+ satisfied grants the action. A subclass declaring `access` replaces everything it
201
+ inherited, so narrowing a parent's rules in a subclass still narrows them rather
202
+ than adding another way in.
203
+
204
+ The denial message is positional too -- `access Employee, "Staff only",
205
+ support: :all` -- so the rules hash holds nothing but roles, and no name is
206
+ reserved.
207
+
208
+ ##### Which login a refused person sees
209
+
210
+ petergate works this out rather than asking you to configure it:
211
+
212
+ | | |
213
+ | --- | --- |
214
+ | the scope holds nobody | `unauthorized!` -- that scope's `authenticate_*!` |
215
+ | the scope holds someone of the wrong kind | `forbidden!` |
216
+
217
+ So a customer who reaches an employee-only page under STI is refused outright:
218
+ there is one login and they are already through it. A customer who reaches a
219
+ `Vendor` page is sent to the vendor login instead, because that scope really is
220
+ empty -- and with Devise they can sign in there without losing the session they
221
+ already have, since Warden keys sessions per scope.
222
+
223
+ With several scopes declared and nobody signed in to any of them, the login
224
+ comes from `petergate_scope`, or from the first scope declared if the controller
225
+ has no `petergate_scope` of its own.
226
+
227
+ If a scope has no `current_*` behind it at all, petergate raises
228
+ `Petergate::MissingScopeError` rather than failing quietly. The message says
229
+ whether the class needs a `devise_for` of its own, shares a login with a parent
230
+ class, or is not an authenticatable model at all.
231
+
127
232
  Inside your views you can use logged_in?(:admin, :customer, :etc) to show or hide content.
128
233
 
129
234
  ```erb
@@ -133,6 +238,15 @@ Inside your views you can use logged_in?(:admin, :customer, :etc) to show or hid
133
238
  `logged_in?` tests roles. To ask only whether anyone is signed in, without
134
239
  caring which role they hold, use `user_logged_in?`.
135
240
 
241
+ Both resolve against the controller's own scope, and both take a `scope:` to ask
242
+ about another one. Both are type-exact, so under `petergate_scope Employee` a
243
+ signed-in `Manager < Employee` answers `false` -- it asks "is an Employee signed
244
+ in", not "is anybody":
245
+
246
+ ```erb
247
+ <%= link_to "Payroll", payroll_path if logged_in?(:admin, scope: Employee) %>
248
+ ```
249
+
136
250
  If you need to access available roles within your project you can by calling:
137
251
 
138
252
  ```ruby
@@ -141,7 +255,27 @@ User.first.available_roles # the same list, from an instance
141
255
  ```
142
256
 
143
257
  `ROLES` is a constant on the model, so it is also reachable from your own
144
- instance methods. A subclass shares its parent's roles.
258
+ instance methods. A subclass shares its parent's roles unless it calls
259
+ `petergate` itself, which gives it a vocabulary of its own -- useful with single
260
+ table inheritance, where each kind of user needs different roles:
261
+
262
+ ```ruby
263
+ class User < ApplicationRecord
264
+ petergate(roles: [:customer], multiple: true)
265
+ end
266
+
267
+ class Employee < User
268
+ petergate(roles: [:admin, :support], multiple: true)
269
+ end
270
+
271
+ Employee::ROLES # => [:admin, :support, :user]
272
+ ```
273
+
274
+ `roles` only ever returns roles the record's own class defines. A role in the
275
+ column that the class does not define is ignored, and warned about once. This
276
+ matters when a record's `type` changes, or when a role is dropped from a
277
+ `petergate` declaration: the column outlives whatever wrote it, and a leftover
278
+ role must not keep granting access.
145
279
 
146
280
  #### Denying access yourself
147
281
 
@@ -169,21 +303,28 @@ which has no format negotiation to offer.
169
303
  ##### The denial message
170
304
 
171
305
  `forbidden!` takes one for a single call, and `access` sets a default for the
172
- whole controller:
306
+ whole controller -- as a string before the rules:
173
307
 
174
308
  ```ruby
175
309
  forbidden! "Your account is suspended"
176
310
 
177
- access user: [:show, :index], message: "You shall not pass"
311
+ access "You shall not pass", user: [:show, :index]
312
+ access Employee, "Staff only", support: :all
178
313
  ```
179
314
 
315
+ Like the scope, it sits outside the rules hash so it cannot be confused with a
316
+ role.
317
+
318
+ The `message:` key is deprecated. It still works and a positional string wins if
319
+ both are given, but it warns, naming the file and line to change.
320
+
180
321
  The message is resolved in this order, first match winning:
181
322
 
182
323
  | | Source |
183
324
  | --- | --- |
184
325
  | 1 | the argument passed to `forbidden!` |
185
326
  | 2 | an `msg` request header |
186
- | 3 | the `message:` option on `access` |
327
+ | 3 | the message given to `access` (or the deprecated `message:` key) |
187
328
  | 4 | `"Permission Denied"` |
188
329
 
189
330
  Note the second entry: the `msg` header is read off the request, so a caller
@@ -7,19 +7,34 @@ module Petergate
7
7
  source_root File.expand_path("../templates", __FILE__)
8
8
 
9
9
  desc "Sets up rails project for Petergate Authorizations"
10
- def self.next_migration_number(path)
11
- sleep 1
12
- Time.now.utc.strftime("%Y%m%d%H%M%S")
10
+
11
+ argument :model_name, type: :string, default: "User", banner: "ModelName"
12
+ class_option :table_name, type: :string,
13
+ desc: "Table to add the roles column to (defaults to the model's table)"
14
+
15
+ # Rails' own idiom: a bare timestamp collides when the generator runs
16
+ # twice inside one second, which the old code papered over with a
17
+ # `sleep 1` on every run.
18
+ def self.next_migration_number(dirname)
19
+ # ::ActiveRecord -- inside module Petergate the bare name finds
20
+ # Petergate::ActiveRecord, the mixin namespace.
21
+ ::ActiveRecord::Migration.next_migration_number(current_migration_number(dirname) + 1)
13
22
  end
14
23
 
15
24
  def insert_into_user_model
16
- inject_into_file "app/models/user.rb", after: /^class\sUser < ActiveRecord::Base|^class User < ApplicationRecord/ do
25
+ unless File.exist?(File.join(destination_root, model_path))
26
+ raise Thor::Error, "#{model_path} does not exist. Generate the model first, " \
27
+ "or pass the name of one that does."
28
+ end
29
+
30
+ inject_into_file model_path, after: model_declaration_pattern do
17
31
  <<-'RUBY'
18
32
 
19
33
  ############################################################################################
20
34
  ## PeterGate Roles ##
21
35
  ## The :user role is added by default and shouldn't be included in this list. ##
22
- ## The :root_admin can access any page regardless of access settings. Use with caution! ##
36
+ ## Add :root_admin to this list for a role that reaches every action whatever ##
37
+ ## the access rules say. Nobody can hold it until it is declared here. ##
23
38
  ## The multiple option can be set to true if you need users to have multiple roles. ##
24
39
  petergate(roles: [:admin, :editor], multiple: false) ##
25
40
  ############################################################################################
@@ -38,12 +53,46 @@ module Petergate
38
53
  # end
39
54
  # end
40
55
 
56
+ # The destination filename is the only thing that sets a migration's class
57
+ # name -- Rails derives @migration_class_name from it -- so it has to
58
+ # carry the table name rather than being copied from the template's own.
41
59
  def create_migrations
42
- Dir["#{self.class.source_root}/migrations/*.rb"].sort.each do |filepath|
43
- name = File.basename(filepath)
44
- migration_template "migrations/#{name}", "db/migrate/#{name}"
45
- end
60
+ migration_template "migrations/add_roles_to_users.rb",
61
+ "db/migrate/add_roles_to_#{roles_table_name}.rb"
46
62
  end
63
+
64
+ private
65
+ def model_class_name
66
+ model_name.camelize
67
+ end
68
+
69
+ # `class Admin::User < ...` declares the demodulized name.
70
+ def bare_class_name
71
+ model_class_name.split("::").last
72
+ end
73
+
74
+ def model_path
75
+ File.join("app/models", "#{model_class_name.underscore}.rb")
76
+ end
77
+
78
+ # Rails writes a namespaced model either compactly --
79
+ # `class Admin::User < ApplicationRecord` -- or nested inside
80
+ # `module Admin`, where the line reads `class User < ...`. Match both.
81
+ def model_declaration_pattern
82
+ names = [model_class_name, bare_class_name].uniq.map { |n| Regexp.escape(n) }
83
+ /^\s*class\s+(?:#{names.join("|")})\s+<\s+\S+.*$/
84
+ end
85
+
86
+ # Ask the model where it actually lives. `Admin::User` is `users` unless
87
+ # the namespace defines a table_name_prefix, and only the class knows
88
+ # which -- `tableize` would guess `admin_users` either way.
89
+ def roles_table_name
90
+ return options[:table_name] if options[:table_name]
91
+
92
+ model_class_name.constantize.table_name
93
+ rescue StandardError
94
+ model_class_name.demodulize.tableize
95
+ end
47
96
  end
48
97
  end
49
98
  end
@@ -1,5 +1,5 @@
1
- class AddRolesToUsers < ActiveRecord::Migration<%= Rails::VERSION::MAJOR >= 5 ? "[#{Rails.version.to_f}]" : "" %>
1
+ class <%= migration_class_name %> < ActiveRecord::Migration<%= Rails::VERSION::MAJOR >= 5 ? "[#{Rails.version.to_f}]" : "" %>
2
2
  def change
3
- add_column :users, :roles, :string
3
+ add_column :<%= roles_table_name %>, :roles, :string
4
4
  end
5
5
  end