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.
- checksums.yaml +7 -0
- data/MIT-LICENSE +20 -0
- data/README.md +377 -0
- data/Rakefile +6 -0
- data/app/assets/javascripts/current_scope/application.js +229 -0
- data/app/assets/stylesheets/current_scope/application.css +917 -0
- data/app/controllers/current_scope/application_controller.rb +96 -0
- data/app/controllers/current_scope/events_controller.rb +14 -0
- data/app/controllers/current_scope/role_assignments_controller.rb +190 -0
- data/app/controllers/current_scope/roles_controller.rb +256 -0
- data/app/controllers/current_scope/scoped_role_assignments_controller.rb +165 -0
- data/app/controllers/current_scope/subjects_controller.rb +75 -0
- data/app/helpers/current_scope/application_helper.rb +261 -0
- data/app/models/concerns/current_scope/storable_keys.rb +101 -0
- data/app/models/current_scope/application_record.rb +5 -0
- data/app/models/current_scope/current.rb +74 -0
- data/app/models/current_scope/event.rb +136 -0
- data/app/models/current_scope/role.rb +106 -0
- data/app/models/current_scope/role_assignment.rb +42 -0
- data/app/models/current_scope/role_permission.rb +9 -0
- data/app/models/current_scope/scoped_role_assignment.rb +98 -0
- data/app/views/current_scope/events/index.html.erb +41 -0
- data/app/views/current_scope/roles/edit.html.erb +229 -0
- data/app/views/current_scope/roles/index.html.erb +55 -0
- data/app/views/current_scope/roles/members.html.erb +114 -0
- data/app/views/current_scope/roles/new.html.erb +19 -0
- data/app/views/current_scope/scoped_role_assignments/new.html.erb +144 -0
- data/app/views/current_scope/shared/access_denied.html.erb +30 -0
- data/app/views/current_scope/subjects/index.html.erb +124 -0
- data/app/views/layouts/current_scope/application.html.erb +56 -0
- data/config/routes.rb +13 -0
- data/db/migrate/20260710000001_create_current_scope_tables.rb +31 -0
- data/db/migrate/20260710000002_create_current_scope_events.rb +32 -0
- data/db/migrate/20260714000001_add_description_to_current_scope_roles.rb +5 -0
- data/db/migrate/20260805000001_widen_current_scope_polymorphic_ids.rb +165 -0
- data/lib/current_scope/configuration.rb +613 -0
- data/lib/current_scope/context.rb +41 -0
- data/lib/current_scope/engine.rb +202 -0
- data/lib/current_scope/gating_reflection.rb +113 -0
- data/lib/current_scope/gating_tripwire.rb +84 -0
- data/lib/current_scope/grant_diagnosis.rb +216 -0
- data/lib/current_scope/guard.rb +750 -0
- data/lib/current_scope/mutation_guard.rb +90 -0
- data/lib/current_scope/parent_chain.rb +397 -0
- data/lib/current_scope/permission_catalog.rb +146 -0
- data/lib/current_scope/permission_grid.rb +132 -0
- data/lib/current_scope/permissions.rb +94 -0
- data/lib/current_scope/resolver.rb +669 -0
- data/lib/current_scope/schema_guard.rb +223 -0
- data/lib/current_scope/scopeable.rb +38 -0
- data/lib/current_scope/sod_preflight.rb +380 -0
- data/lib/current_scope/test_helpers.rb +53 -0
- data/lib/current_scope/version.rb +3 -0
- data/lib/current_scope.rb +432 -0
- data/lib/generators/current_scope/install/install_generator.rb +114 -0
- data/lib/generators/current_scope/install/templates/initializer.rb +175 -0
- data/lib/tasks/current_scope_tasks.rake +454 -0
- 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,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
|