current_scope 0.5.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 (58) hide show
  1. checksums.yaml +7 -0
  2. data/MIT-LICENSE +20 -0
  3. data/README.md +377 -0
  4. data/Rakefile +6 -0
  5. data/app/assets/javascripts/current_scope/application.js +229 -0
  6. data/app/assets/stylesheets/current_scope/application.css +917 -0
  7. data/app/controllers/current_scope/application_controller.rb +96 -0
  8. data/app/controllers/current_scope/events_controller.rb +14 -0
  9. data/app/controllers/current_scope/role_assignments_controller.rb +190 -0
  10. data/app/controllers/current_scope/roles_controller.rb +256 -0
  11. data/app/controllers/current_scope/scoped_role_assignments_controller.rb +165 -0
  12. data/app/controllers/current_scope/subjects_controller.rb +75 -0
  13. data/app/helpers/current_scope/application_helper.rb +261 -0
  14. data/app/models/concerns/current_scope/storable_keys.rb +101 -0
  15. data/app/models/current_scope/application_record.rb +5 -0
  16. data/app/models/current_scope/current.rb +74 -0
  17. data/app/models/current_scope/event.rb +136 -0
  18. data/app/models/current_scope/role.rb +106 -0
  19. data/app/models/current_scope/role_assignment.rb +42 -0
  20. data/app/models/current_scope/role_permission.rb +9 -0
  21. data/app/models/current_scope/scoped_role_assignment.rb +98 -0
  22. data/app/views/current_scope/events/index.html.erb +41 -0
  23. data/app/views/current_scope/roles/edit.html.erb +229 -0
  24. data/app/views/current_scope/roles/index.html.erb +55 -0
  25. data/app/views/current_scope/roles/members.html.erb +114 -0
  26. data/app/views/current_scope/roles/new.html.erb +19 -0
  27. data/app/views/current_scope/scoped_role_assignments/new.html.erb +144 -0
  28. data/app/views/current_scope/shared/access_denied.html.erb +30 -0
  29. data/app/views/current_scope/subjects/index.html.erb +124 -0
  30. data/app/views/layouts/current_scope/application.html.erb +56 -0
  31. data/config/routes.rb +13 -0
  32. data/db/migrate/20260710000001_create_current_scope_tables.rb +31 -0
  33. data/db/migrate/20260710000002_create_current_scope_events.rb +32 -0
  34. data/db/migrate/20260714000001_add_description_to_current_scope_roles.rb +5 -0
  35. data/db/migrate/20260805000001_widen_current_scope_polymorphic_ids.rb +165 -0
  36. data/lib/current_scope/configuration.rb +613 -0
  37. data/lib/current_scope/context.rb +41 -0
  38. data/lib/current_scope/engine.rb +202 -0
  39. data/lib/current_scope/gating_reflection.rb +113 -0
  40. data/lib/current_scope/gating_tripwire.rb +84 -0
  41. data/lib/current_scope/grant_diagnosis.rb +216 -0
  42. data/lib/current_scope/guard.rb +750 -0
  43. data/lib/current_scope/mutation_guard.rb +90 -0
  44. data/lib/current_scope/parent_chain.rb +397 -0
  45. data/lib/current_scope/permission_catalog.rb +146 -0
  46. data/lib/current_scope/permission_grid.rb +132 -0
  47. data/lib/current_scope/permissions.rb +94 -0
  48. data/lib/current_scope/resolver.rb +669 -0
  49. data/lib/current_scope/schema_guard.rb +223 -0
  50. data/lib/current_scope/scopeable.rb +38 -0
  51. data/lib/current_scope/sod_preflight.rb +380 -0
  52. data/lib/current_scope/test_helpers.rb +53 -0
  53. data/lib/current_scope/version.rb +3 -0
  54. data/lib/current_scope.rb +432 -0
  55. data/lib/generators/current_scope/install/install_generator.rb +114 -0
  56. data/lib/generators/current_scope/install/templates/initializer.rb +175 -0
  57. data/lib/tasks/current_scope_tasks.rake +454 -0
  58. metadata +123 -0
@@ -0,0 +1,53 @@
1
+ module CurrentScope
2
+ # Test support for host apps:
3
+ #
4
+ # include CurrentScope::TestHelpers
5
+ #
6
+ # with_current_user(users(:alice)) do
7
+ # assert component_allows_approve?
8
+ # end
9
+ #
10
+ # with_current_user(users(:bob), actor: users(:admin)) do # act-as
11
+ # assert impersonating?
12
+ # end
13
+ #
14
+ # CurrentAttributes resets between examples, so nothing set here can leak.
15
+ module TestHelpers
16
+ # Snapshot/restore the RAW attributes rather than using Current.set: the
17
+ # actor reader falls back to user, and Object#with (which set uses) would
18
+ # snapshot that fallback and restore a stale actor. Saving the underlying
19
+ # hash restores the true prior state.
20
+ def with_current_user(user, actor: nil)
21
+ # dup: on Rails 8.0 `attributes` returns the LIVE hash, so without a copy
22
+ # the assignments below would mutate our own snapshot.
23
+ previous = CurrentScope::Current.attributes.dup
24
+ CurrentScope::Current.user = user
25
+ CurrentScope::Current.actor = actor
26
+ yield
27
+ ensure
28
+ # Version-robust restore: `attributes = previous` clears absent keys on
29
+ # Rails 8.1 but NOT on 8.0 (leaving the block's user set), so reset first,
30
+ # then re-apply the snapshot. Works identically on the whole >= 8.0 floor.
31
+ CurrentScope::Current.reset
32
+ previous.each { |name, value| CurrentScope::Current.public_send(:"#{name}=", value) }
33
+ end
34
+
35
+ # Seed a real org-wide grant for request/system specs. Unlike
36
+ # with_current_user (which only sets Current.user in-process, and is
37
+ # overwritten by Context's before_action on a real request), this persists a
38
+ # RoleAssignment row that survives the request cycle, so a host can test its
39
+ # own controllers behind the gate. It does NOT authenticate — the host still
40
+ # signs the subject in through its own auth. Bang-suffixed like the engine's
41
+ # other DB-mutating helpers (seed_defaults!, Event.record!). Returns the
42
+ # assignment.
43
+ def grant_role!(subject, role:)
44
+ CurrentScope::RoleAssignment.create!(subject: subject, role: role)
45
+ end
46
+
47
+ # The scoped-grant companion: seed a role held on ONE specific record.
48
+ # Returns the scoped assignment.
49
+ def grant_scoped_role!(subject, role:, record:)
50
+ CurrentScope::ScopedRoleAssignment.create!(subject: subject, role: role, resource: record)
51
+ end
52
+ end
53
+ end
@@ -0,0 +1,3 @@
1
+ module CurrentScope
2
+ VERSION = "0.5.0"
3
+ end
@@ -0,0 +1,432 @@
1
+ require "current_scope/version"
2
+ require "current_scope/configuration"
3
+ require "current_scope/permission_catalog"
4
+ require "current_scope/permission_grid"
5
+ require "current_scope/resolver"
6
+ require "current_scope/permissions"
7
+ require "current_scope/context"
8
+ require "current_scope/scopeable"
9
+ require "current_scope/parent_chain"
10
+ require "current_scope/grant_diagnosis"
11
+ require "current_scope/mutation_guard"
12
+ require "current_scope/guard"
13
+ require "current_scope/gating_tripwire"
14
+ require "current_scope/gating_reflection"
15
+ require "current_scope/sod_preflight"
16
+ require "current_scope/schema_guard"
17
+ require "current_scope/engine"
18
+
19
+ module CurrentScope
20
+ # Raised when the resolver denies an action gated by Guard (or when the
21
+ # management UI is accessed without a full-access role). Accessors:
22
+ #
23
+ # #permission — denied key (stable API for branded 403s). Defaults to the
24
+ # positional message when permission: is omitted so legacy
25
+ # raise sites still populate it. Prefer this over #message.
26
+ # #message — StandardError message (positional arg). Gem raise sites pass
27
+ # the key as both message and permission; they can diverge if
28
+ # a caller passes an explicit permission: keyword.
29
+ # #reason — machine-readable cause (also on X-Current-Scope-Reason)
30
+ # #record — the record under decision when the gate had one; nil for
31
+ # collection / impersonation-gate denials
32
+ # #subject — effective subject when known; nil if none was in scope
33
+ #
34
+ # Reasons surfaced by current_scope_denied:
35
+ #
36
+ # :sod_veto — the record's initiator can't perform an SoD action on it
37
+ # :no_grant — nothing granted the permission (the default deny)
38
+ # :model_undeclared — a record-less deny that a scoped grant would have
39
+ # opened, had the controller declared current_scope_model
40
+ # to bind it to a type (#50). Fail-closed, with the fix named.
41
+ # :model_invalid — its sibling: current_scope_model WAS declared but
42
+ # returned something the shape guard refuses (a String,
43
+ # an instance, an abstract class — not a concrete AR
44
+ # class). Same cell, different fix, so a different label.
45
+ # :impersonation_gate — a mutation while impersonating, which is read-only
46
+ # :not_full_access — the engine's management UI, which only full_access enters
47
+ #
48
+ # Every denial in the gem raises this and lands in current_scope_denied, so a
49
+ # denial cannot exist that forgets its reason. (:sod_bypassed is the one
50
+ # audited ALLOW, so it is set by the Guard rather than raised here.)
51
+ class AccessDenied < StandardError
52
+ attr_reader :reason, :permission, :record, :subject
53
+
54
+ def initialize(message = nil, reason: nil, permission: nil, record: nil, subject: nil)
55
+ super(message)
56
+ @reason = reason
57
+ # permission defaults to message so older raise sites and branded-403
58
+ # recipes that only pass the key positionally still populate #permission.
59
+ @permission = permission || message
60
+ @record = record
61
+ @subject = subject
62
+ end
63
+ end
64
+
65
+ # Raised when the host is wired up wrong (missing hook, bad config). Always
66
+ # raised loudly — an authorization library must never turn a configuration
67
+ # mistake into a silent allow or an undiagnosable deny.
68
+ class ConfigurationError < StandardError; end
69
+
70
+ # The width of the polymorphic grant id columns (#151). Named here so the
71
+ # length guard and the migration cannot drift apart; the migration keeps its own
72
+ # copy because a migration must not depend on the gem's runtime constants.
73
+ KEY_LIMIT = 64
74
+
75
+ class << self
76
+ def config
77
+ @config ||= Configuration.new
78
+ end
79
+
80
+ def configure
81
+ yield config
82
+ end
83
+
84
+ def resolver
85
+ @resolver ||= Resolver.new
86
+ end
87
+
88
+ def catalog
89
+ @catalog ||= PermissionCatalog.new
90
+ end
91
+
92
+ def reset_catalog!
93
+ @catalog = nil
94
+ end
95
+
96
+ # The cross-controller nudge warns once per site (see below). That latch is
97
+ # per-process, so it must be clearable: a leaked one silently disarms the
98
+ # warning for every later test and makes the suite order-dependent. Also
99
+ # cleared on engine to_prepare, since a reload can change what's routed.
100
+ def reset_cross_controller_warnings!
101
+ @cross_controller_warned = nil
102
+ end
103
+
104
+ # Models that opted into the scoped-role picker via CurrentScope::Scopeable.
105
+ # Stored as class-name strings and resolved lazily so dev-mode reloading
106
+ # never pins a stale constant. Rebuilt from scratch on every engine
107
+ # to_prepare (see reset_scopeable_registry!).
108
+ def scopeable_registry
109
+ @scopeable_registry ||= Set.new
110
+ end
111
+
112
+ def register_scopeable(model_name)
113
+ scopeable_registry << model_name.to_s
114
+ end
115
+
116
+ def scopeable_resources
117
+ scopeable_registry.map(&:constantize).sort_by(&:name)
118
+ end
119
+
120
+ def reset_scopeable_registry!
121
+ @scopeable_registry = Set.new
122
+ end
123
+
124
+ # The single entry point behind every allowed_to? call.
125
+ # `action` is either a full permission key ("admin/reports#approve") or a
126
+ # bare action name resolved against `record`'s route key, falling back to
127
+ # `controller_path`.
128
+ def allowed?(action, subject:, record: nil, controller_path: nil, actor: nil, model: nil)
129
+ resolver.allow?(
130
+ subject: subject,
131
+ permission: permission_key(action, record: record, controller_path: controller_path),
132
+ record: record,
133
+ actor: actor,
134
+ model: model
135
+ )
136
+ end
137
+
138
+ # The list-side companion to allowed?. Returns a chainable relation of the
139
+ # records of `model` the subject may act on under `permission` — same
140
+ # grants, same fail-closed rules as the per-record gate. `permission` is a
141
+ # resolved key ("projects#index"); the mixin derives the default.
142
+ def scope_for(subject:, model:, permission:)
143
+ resolver.scope_for(subject: subject, model: model, permission: permission)
144
+ end
145
+
146
+ # THE human-label fallback chain, shared by the UI helpers
147
+ # (ApplicationHelper#current_scope_label) and the audit ledger
148
+ # (Event.label_for) — one definition, so a record can never render as
149
+ # "Apollo" on screen while being frozen into the ledger as "Project #7".
150
+ # Chain: the record's own current_scope_label (Scopeable provides one) →
151
+ # human identifiers (name/email/title) → "Model #id" → to_s. Returns nil
152
+ # for nil; callers choose their own nil presentation ("(none)" in views).
153
+ def label_for(record)
154
+ return if record.nil?
155
+ return record.current_scope_label if record.respond_to?(:current_scope_label)
156
+
157
+ name = record.try(:name).presence || record.try(:email).presence || record.try(:title).presence
158
+ return name if name
159
+ return "#{record.model_name.human} ##{record.id}" if record.respond_to?(:model_name)
160
+
161
+ record.to_s
162
+ end
163
+
164
+ def permission_key(action, record: nil, controller_path: nil)
165
+ action = action.to_s
166
+ return action if action.include?("#")
167
+
168
+ if record.respond_to?(:model_name)
169
+ route_key = record.model_name.route_key
170
+ # When the current controller handles this record type (possibly under
171
+ # a namespace — admin/reports for a Report), its path is the key the
172
+ # Guard enforces, so prefer it: the view must agree with the gate.
173
+ return "#{controller_path}##{action}" if controller_path&.split("/")&.last == route_key
174
+
175
+ warn_on_cross_controller_derivation(action, route_key, controller_path)
176
+ return "#{route_key}##{action}"
177
+ end
178
+ return "#{controller_path}##{action}" if controller_path
179
+
180
+ raise ArgumentError,
181
+ "cannot derive a permission key for #{action.inspect} — pass a record, " \
182
+ "a full \"controller#action\" string, or call from a controller/view"
183
+ end
184
+
185
+
186
+ # Impersonation boundary events. The impersonated identity is an EXPLICIT
187
+ # argument (not read from the ambient pair): at act-as START the ambient
188
+ # actor still equals the effective user — Current re-resolves next request —
189
+ # so an ambient-only recorder would lose who was impersonated. Call these
190
+ # from the host's start/stop-impersonation endpoints.
191
+ def record_impersonation_started!(subject)
192
+ require_actor_method!
193
+ Event.record!(event: "impersonation.started", target: subject)
194
+ end
195
+
196
+ def record_impersonation_stopped!(subject)
197
+ require_actor_method!
198
+ Event.record!(event: "impersonation.stopped", target: subject)
199
+ end
200
+
201
+ # Creates the two baseline roles every install needs: an Owner with
202
+ # full_access (present and future permissions) and a Member baseline.
203
+ # Call from db/seeds.rb.
204
+ def seed_defaults!
205
+ Role.find_or_create_by!(name: "Owner") { |r| r.full_access = true }
206
+ Role.find_or_create_by!(name: "Member")
207
+ end
208
+
209
+ # Bootstrap the first admin: assign a role (default: the full_access Owner)
210
+ # to `subject` as its one org-wide role. Idempotent — re-running sets the
211
+ # same subject's org role to `role` rather than creating a duplicate (which
212
+ # the one-role-per-subject uniqueness would reject anyway). Backs the
213
+ # `current_scope:grant` rake task, so a fresh install doesn't need a console.
214
+ #
215
+ # Seeds the default Owner/Member roles ONLY on the default path — the name
216
+ # promises "assign a role", so a caller granting an explicit role must not
217
+ # get a full-access Owner row created in their roles table as a side effect.
218
+ #
219
+ # Audit (#30): when the org role actually changes, records one
220
+ # `org_role.assigned` / `org_role.changed` event, self-attributed to the
221
+ # grantee with `details.source = "bootstrap"`. Same-role re-grants are a
222
+ # no-op event-wise. Direct model writes and TestHelpers do not go through
223
+ # this path and are not recorded (documented intentionally).
224
+ def grant!(subject, role: nil)
225
+ role ||= begin
226
+ seed_defaults!
227
+ Role.find_by!(name: "Owner")
228
+ end
229
+
230
+ RoleAssignment.transaction do
231
+ assignment = RoleAssignment.find_or_initialize_by(subject: subject)
232
+ prior_role = assignment.persisted? ? assignment.role : nil
233
+ assignment.update!(role: role)
234
+
235
+ if prior_role.nil?
236
+ Event.record!(
237
+ event: "org_role.assigned",
238
+ target: subject,
239
+ details: { role: role.name, source: "bootstrap" },
240
+ actor: subject,
241
+ subject: subject
242
+ )
243
+ elsif prior_role.id != role.id
244
+ Event.record!(
245
+ event: "org_role.changed",
246
+ target: subject,
247
+ details: { from: prior_role.name, to: role.name, source: "bootstrap" },
248
+ actor: subject,
249
+ subject: subject
250
+ )
251
+ end
252
+
253
+ assignment
254
+ end
255
+ end
256
+
257
+ # Resolve a stored polymorphic type token to its class. `*_type` is a Rails
258
+ # STORAGE TOKEN, not necessarily a constant name: `polymorphic_name` can be
259
+ # overridden and `store_full_class_name = false` shortens it, so
260
+ # `safe_constantize` would return nil or resolve the wrong class.
261
+ # Returns nil for a token that no longer resolves, which callers treat as
262
+ # "nothing to check" — a stale type is #90's inert grant, not a key problem.
263
+ def polymorphic_class(type, owner: ActiveRecord::Base)
264
+ return if type.blank?
265
+
266
+ owner.polymorphic_class_for(type)
267
+ rescue NameError
268
+ # A token Rails cannot reverse by constantizing (an overridden
269
+ # polymorphic_name, or store_full_class_name = false) stays INERT. Inferring
270
+ # the owner from the current descendant set is a guess that goes wrong when a
271
+ # token is reused after its original model was removed or renamed: it would
272
+ # attach an old grant to a different model's records, the exact #151 harm.
273
+ # Safe reverse-resolution needs an explicit persisted mapping, tracked in #155.
274
+ nil
275
+ end
276
+
277
+ # #151. `subject_id` and `resource_id` are string columns, so ANY single-value
278
+ # primary key stores whole — an integer as "1", a UUID as "7f00aaaa-…". What
279
+ # still cannot be stored is a key that is not one value: a composite key is an
280
+ # array, and a model with no primary key names no record at all. Grants on
281
+ # those are refused rather than written as something that identifies the wrong
282
+ # row, or nothing.
283
+ def storable_key?(klass)
284
+ klass.primary_key.is_a?(String)
285
+ end
286
+
287
+ # Whether a connection speaks MySQL, in one place. MySQL is the only adapter
288
+ # #151 has to treat specially (its default collation folds case and accents,
289
+ # and it needs CHAR rather than TEXT in a cast), and three separate copies of
290
+ # this regex would be three chances to disagree.
291
+ #
292
+ # Takes a CONNECTION rather than reading ActiveRecord::Base's: in a host that
293
+ # puts the grant tables on a different database from its subject models, the
294
+ # answer differs per connection, and asking the wrong one produces a cast or
295
+ # a collation the target server rejects.
296
+ def mysql?(connection = ActiveRecord::Base.connection)
297
+ connection.adapter_name.match?(/mysql|trilogy|maria/i)
298
+ end
299
+
300
+ # #151, VALUE side. storable_key? asks whether the CLASS can be named by one
301
+ # id; this asks whether THIS id is a legal one for that class.
302
+ #
303
+ # The columns hold any string now, so a grant can be written naming a
304
+ # bigint-keyed model with a UUID. Nothing about the write looks wrong — and
305
+ # the read path then casts that string back into the model's own key type,
306
+ # where String#to_i turns "7f00aaaa-…" into 7 and the grant reaches record 7,
307
+ # which it never named. That is #151 again, moved from the write side to the
308
+ # read side by the very widening that fixed the write side.
309
+ #
310
+ # A canonical id is one that survives a round trip through its own key type.
311
+ # Every id the engine itself writes is canonical by construction (it stores
312
+ # `record.id` through exactly this cast), so this rejects only ids that could
313
+ # not have come from a real record: "7f00aaaa-…" for a bigint key, "007" for
314
+ # any key (it would match record 7), "7" for a Postgres uuid key.
315
+ def canonical_key?(klass, value)
316
+ # A blank id names no record. The column accepts "", but "" is a canonical
317
+ # key for nothing — bless it and a directly-inserted grant with an empty id
318
+ # would resolve to whatever "" casts to for the key type. Fail closed here,
319
+ # in the guard, rather than relying on a caller or the adapter to drop it.
320
+ return false if value.to_s.empty?
321
+
322
+ key = klass.primary_key
323
+ return false unless key.is_a?(String)
324
+
325
+ type = klass.type_for_attribute(key)
326
+ cast = type.cast(value)
327
+ type.serialize(cast) # integer types raise when the value exceeds the column range
328
+ cast.to_s == value.to_s
329
+ rescue StandardError
330
+ # Cannot introspect the key type (no connection, no table, exotic type):
331
+ # refuse rather than guess. Callers use this to DENY, so failing here
332
+ # fails closed.
333
+ false
334
+ end
335
+
336
+ # A key that does not FIT is as dangerous as one that is not a single value.
337
+ # MySQL outside strict mode truncates silently, so two keys sharing a 64-char
338
+ # prefix would collapse into one identity — #151 by another route. Checked in
339
+ # Ruby so every adapter fails the same way instead of depending on sql_mode.
340
+ def key_too_long?(value)
341
+ value.to_s.length > KEY_LIMIT
342
+ end
343
+
344
+ def unstorable_key_error(klass, role: "subject")
345
+ key = begin
346
+ klass.primary_key
347
+ rescue StandardError
348
+ nil
349
+ end
350
+ shape = key.is_a?(Array) ? "a composite primary key (#{key.inspect})" : "no primary key"
351
+
352
+ "#{klass.name} has #{shape}, and CurrentScope stores a #{role} id as one value. " \
353
+ "A grant needs to name exactly one record. Use a model with a single-column " \
354
+ "primary key — integer or UUID both work."
355
+ end
356
+
357
+ private
358
+
359
+ # The documented namespaced/custom-named controller foot-gun (#41): the short
360
+ # form derived a DIFFERENT key than the gate on this controller enforces, so a
361
+ # view can show a link that 403s (or hide one that works). Silent, and the
362
+ # symptom appears nowhere near the cause.
363
+ #
364
+ # THIS SIGNAL IS AMBIGUOUS AND CANNOT BE MADE PRECISE. Two callers produce
365
+ # byte-identical inputs here:
366
+ #
367
+ # DashboardController renders Reports; allowed_to?(:show, report) is meant
368
+ # to mirror THIS controller's gate (dashboard#show) -> foot-gun.
369
+ # DocumentsController lists documents with links to reports;
370
+ # allowed_to?(:show, report) genuinely means reports#show -> correct.
371
+ #
372
+ # Both have a controller path that doesn't end in the record's route_key, and
373
+ # both route "{controller_path}##{action}". Nothing at the call site
374
+ # distinguishes intent. An earlier draft treated the catalog hit as proof of
375
+ # the foot-gun and warned "they disagree" — which is a false positive on every
376
+ # row of the second case. (#59/#61 review, cubic)
377
+ #
378
+ # So: warn ONCE per (controller_path, action, route_key), and say plainly that
379
+ # either reading may be right. One line per distinct site is a hint; one line
380
+ # per row is noise people learn to filter — and a diagnostic that cries wolf is
381
+ # worse than none, which is the whole thesis of this PR.
382
+ #
383
+ # ponytail: derivation is a hot path (every view helper call), so the flag is
384
+ # checked FIRST — off costs one boolean and never touches the catalog.
385
+ def warn_on_cross_controller_derivation(action, route_key, controller_path)
386
+ return unless config.warn_on_cross_controller_derivation
387
+ return if controller_path.nil? || controller_path.empty?
388
+ # A "log-only" diagnostic that raises isn't log-only. catalog reads
389
+ # Rails.application.routes, so a host that forces the flag on outside a
390
+ # booted Rails must get silence, not a NameError out of key derivation.
391
+ # (#61 review, qodo)
392
+ return unless defined?(Rails) && Rails.respond_to?(:application) && Rails.application
393
+
394
+ gate_key = "#{controller_path}##{action}"
395
+ return unless catalog.include?(gate_key)
396
+ return unless cross_controller_warning_unseen?(gate_key, route_key)
397
+
398
+ Rails.logger&.warn(
399
+ "[CurrentScope] allowed_to?(#{action.to_sym.inspect}, <#{route_key.singularize.camelize}>) on " \
400
+ "#{controller_path} derived \"#{route_key}##{action}\", but the gate here enforces " \
401
+ "\"#{gate_key}\". If you meant this controller's own gate, they disagree — pass the explicit " \
402
+ "key: allowed_to?(\"#{gate_key}\"). If you're asking about a different resource than this " \
403
+ "controller handles, the derived key is correct and this is expected. Warned once per site."
404
+ )
405
+ end
406
+
407
+ # ponytail: a plain Set, not a Mutex — worst case under a race is one extra
408
+ # line, and a flood is the thing being prevented. Dev/test only by default.
409
+ def cross_controller_warning_unseen?(gate_key, route_key)
410
+ @cross_controller_warned ||= Set.new
411
+ @cross_controller_warned.add?("#{gate_key}|#{route_key}") ? true : false
412
+ end
413
+
414
+ # A2: the boundary events are the one place a host declares it is actually
415
+ # impersonating. If actor_method is unset there, the entire act-as security
416
+ # model is silently inert — so fail LOUD instead of recording an
417
+ # impersonation with no real actor behind it. (The permission path can't
418
+ # detect this: with actor_method nil, actor falls back to user, so
419
+ # impersonating? is always false and a per-request check would nag every
420
+ # RBAC-only host. This seam only fires when the host declares intent.)
421
+ def require_actor_method!
422
+ return unless config.actor_method.nil?
423
+
424
+ raise ConfigurationError,
425
+ "impersonation boundary event recorded while config.actor_method is unset. " \
426
+ "Act-as security is inert without it: the read-only-while-impersonating " \
427
+ "MutationGuard never engages, the SoD :either veto can't fire, and audit rows " \
428
+ "are attributed to the impersonated subject instead of the real actor. Set " \
429
+ "config.actor_method to the controller method that returns the real actor."
430
+ end
431
+ end
432
+ end
@@ -0,0 +1,114 @@
1
+ module CurrentScope
2
+ module Generators
3
+ class InstallGenerator < Rails::Generators::Base
4
+ source_root File.expand_path("templates", __dir__)
5
+
6
+ def copy_initializer
7
+ template "initializer.rb", "config/initializers/current_scope.rb"
8
+ end
9
+
10
+ def mount_engine
11
+ route 'mount CurrentScope::Engine => "/current_scope"'
12
+ end
13
+
14
+ def show_next_steps
15
+ # Keep this list in lockstep with README Installation and
16
+ # docs/site/quickstart.md (#25). Three paths must not disagree.
17
+ say <<~NEXT
18
+
19
+ CurrentScope installed. Next steps (canonical quickstart):
20
+
21
+ 1. bin/rails current_scope:install:migrations && bin/rails db:migrate
22
+ 2. Include the concerns in ApplicationController (Context first):
23
+ include CurrentScope::Context
24
+ include CurrentScope::Guard
25
+ 3. Skip the gate on sign-in / public endpoints (or nobody can log in):
26
+ class SessionsController < ApplicationController
27
+ # Declared form: the role grid shows WHY the gate is off.
28
+ # A bare skip_before_action :current_scope_check! still works,
29
+ # but the grid marks it as an unexplained "gate not run".
30
+ current_scope_skip_gate!(reason: "sign-in must run without a grant")
31
+ # If you use impersonation, also skip the mutation guard on
32
+ # sign-in/out or POSTs while acting-as stay blocked:
33
+ skip_before_action :current_scope_mutation_guard!
34
+ end
35
+ Skipping leaves that controller ungated by CurrentScope —
36
+ use your own auth there (security checklist:
37
+ https://davidteren.github.io/current_scope/).
38
+ 4. Bootstrap the first admin (Member starts with zero permissions):
39
+ bin/rails current_scope:grant SUBJECT_ID=YOUR_USER_ID
40
+ # or: CurrentScope.grant!(User.first) # upserts Owner; not RoleAssignment.create!
41
+ 5. Manage roles at /current_scope (full-access subjects only).
42
+ 6. A denial is 403 with X-Current-Scope-Reason (no_grant, sod_veto, …).
43
+
44
+ Full guide: https://davidteren.github.io/current_scope/quickstart.html
45
+
46
+ NEXT
47
+
48
+ say_retrofit_warning if existing_app?
49
+ end
50
+
51
+ # An ABSOLUTE URL, not a repo-relative path. This prints in the HOST's
52
+ # terminal, in the HOST's app directory: "docs/guides/..." would resolve
53
+ # against their app, where it does not exist — and the gemspec ships only
54
+ # {app,config,db,lib} + README, so it is not in the installed gem either.
55
+ # Shipping docs/ wouldn't fix it (nobody reads docs out of a gem's install
56
+ # dir); a URL is the only form that resolves from where the reader is
57
+ # standing, and terminals make it clickable. (#64 review, qodo)
58
+ #
59
+ # Points at blob/main deliberately: the guide does not exist at the last
60
+ # release tag (it landed after v0.2.0), so a version-pinned URL would 404
61
+ # today. Revisit pinning to "blob/v#{VERSION}" at the next release, when a
62
+ # tag containing the guide exists. (#71 review, qodo)
63
+ GUIDE_PATH = "docs/guides/adopting-in-an-existing-app.md".freeze
64
+ GUIDE_URL = "https://github.com/davidteren/current_scope/blob/main/#{GUIDE_PATH}".freeze
65
+
66
+ private
67
+
68
+ # Adding a fail-closed gate to an app that already has controllers means
69
+ # every one of them starts denying: nothing is granted yet. That is the
70
+ # engine working, but it reads as "the gem broke my app" — and it lands
71
+ # AFTER step 2 above, when the suite is already red and the person is
72
+ # already reaching for git revert. Say it before that happens.
73
+ def say_retrofit_warning
74
+ say <<~RETROFIT, :yellow
75
+ Heads up — this app already has controllers.
76
+
77
+ Step 2 mounts a FAIL-CLOSED gate: anything not granted is denied. No
78
+ grants exist yet, so your controller specs will go red and your users
79
+ will get 403s. Nothing is misconfigured when that happens; it is the
80
+ gate doing its job on an app that hasn't been granted anything.
81
+
82
+ To retrofit incrementally instead, set this before step 2:
83
+
84
+ config.enforcement = :report # config/initializers/current_scope.rb
85
+
86
+ The gate then logs what it WOULD deny and lets it through. Run your
87
+ suite, then read the gaps back as a starter role grid:
88
+
89
+ bin/rails current_scope:report
90
+
91
+ Seed the roles it names, watch it empty out, then flip to :enforce.
92
+ One line back at any point.
93
+
94
+ The full retrofit guide — callback ordering vs. your authentication,
95
+ the Devise recipe, the skip_before_action fail-open trap, and a
96
+ rollout ladder:
97
+
98
+ #{GUIDE_URL}
99
+
100
+ RETROFIT
101
+ end
102
+
103
+ # ponytail: "does this app have controllers of its own" — the closed set
104
+ # is app/controllers/*.rb minus the one Rails generates for every app.
105
+ # A fresh `rails new` has only application_controller.rb, so it gets the
106
+ # clean install message; anything more means a real app is being
107
+ # retrofitted. Wrong guess costs a paragraph of advice, not correctness.
108
+ def existing_app?
109
+ controllers = Dir.glob(File.join(destination_root, "app/controllers/**/*_controller.rb"))
110
+ controllers.reject { |f| File.basename(f) == "application_controller.rb" }.any?
111
+ end
112
+ end
113
+ end
114
+ end