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,90 @@
|
|
|
1
|
+
module CurrentScope
|
|
2
|
+
# The read-only-while-impersonating gate. A SEPARATE before_action from the
|
|
3
|
+
# permission check, on purpose: the engine's management UI does
|
|
4
|
+
# skip_before_action :current_scope_check!, and a gate folded into that check
|
|
5
|
+
# would exempt the highest-value surface (grid/role/grant mutations). Guard
|
|
6
|
+
# includes this so the gate runs BEFORE the permission check; the engine
|
|
7
|
+
# ApplicationController includes it directly so skipping the permission check
|
|
8
|
+
# never skips the gate.
|
|
9
|
+
#
|
|
10
|
+
# An impersonated session is read-only by default: any non-GET/HEAD request is
|
|
11
|
+
# denied while a real actor stands behind a different effective subject. The
|
|
12
|
+
# endpoints that MUST run while impersonating — a host's stop-impersonation,
|
|
13
|
+
# sign-out, and sign-in actions — opt out with:
|
|
14
|
+
#
|
|
15
|
+
# skip_before_action :current_scope_mutation_guard!
|
|
16
|
+
#
|
|
17
|
+
# (Method spoofing is not a concern: Rack::MethodOverride only upgrades POST,
|
|
18
|
+
# and mutating actions are verb-pinned by routing.)
|
|
19
|
+
module MutationGuard
|
|
20
|
+
extend ActiveSupport::Concern
|
|
21
|
+
|
|
22
|
+
included do
|
|
23
|
+
before_action :current_scope_mutation_guard!
|
|
24
|
+
rescue_from CurrentScope::AccessDenied, with: :current_scope_denied
|
|
25
|
+
end
|
|
26
|
+
|
|
27
|
+
private
|
|
28
|
+
|
|
29
|
+
def current_scope_mutation_guard!
|
|
30
|
+
return if request.get? || request.head?
|
|
31
|
+
return if CurrentScope.config.allow_mutations_while_impersonating
|
|
32
|
+
return unless current_scope_impersonating?
|
|
33
|
+
|
|
34
|
+
key = "#{controller_path}##{action_name}"
|
|
35
|
+
raise CurrentScope::AccessDenied.new(
|
|
36
|
+
key,
|
|
37
|
+
reason: :impersonation_gate,
|
|
38
|
+
permission: key,
|
|
39
|
+
subject: CurrentScope::Current.user
|
|
40
|
+
)
|
|
41
|
+
end
|
|
42
|
+
|
|
43
|
+
# A real actor stands behind a distinct effective subject (act-as).
|
|
44
|
+
# Delegates to the one definition on Current (not the Permissions mixin,
|
|
45
|
+
# which isn't mixed in everywhere this gate runs) so the gate and the
|
|
46
|
+
# view-level read-only signal share a single authoritative predicate.
|
|
47
|
+
def current_scope_impersonating?
|
|
48
|
+
CurrentScope::Current.impersonating?
|
|
49
|
+
end
|
|
50
|
+
|
|
51
|
+
# Denials render forbidden and surface the machine-readable reason on the
|
|
52
|
+
# response so refusals are diagnosable by clients and tests.
|
|
53
|
+
#
|
|
54
|
+
# The ONLY place the reason header is written. Every denial in the gem —
|
|
55
|
+
# the Guard's, this gate's, and the engine's own front door — raises
|
|
56
|
+
# AccessDenied and lands here, so a denial cannot exist that forgets the
|
|
57
|
+
# header. (The engine's front door used to render its own `head :forbidden`
|
|
58
|
+
# and was exactly that denial: no header, no body. #23.)
|
|
59
|
+
def current_scope_denied(exception = nil)
|
|
60
|
+
reason = exception.respond_to?(:reason) ? exception.reason : nil
|
|
61
|
+
response.headers["X-Current-Scope-Reason"] = reason.to_s if reason
|
|
62
|
+
# Mirror the header into the server log so log-based triage can distinguish
|
|
63
|
+
# no_grant from sod_veto without reading response headers (#39).
|
|
64
|
+
permission = exception.respond_to?(:permission) ? exception.permission : nil
|
|
65
|
+
Rails.logger&.info(
|
|
66
|
+
"[CurrentScope] denied #{permission || '(unknown)'} (#{reason || 'unknown'}) → 403"
|
|
67
|
+
)
|
|
68
|
+
current_scope_render_denied(reason)
|
|
69
|
+
end
|
|
70
|
+
|
|
71
|
+
# How a denial renders — the one part a controller may vary. Default: a
|
|
72
|
+
# bodyless 403, which is what a host app's denial has always been and must
|
|
73
|
+
# stay. This runs inside HOST controllers (Guard mixes it in), so rendering
|
|
74
|
+
# a body here would push an engine-shaped response into an app that never
|
|
75
|
+
# asked for one, with no layout or view to render it in. The engine's own
|
|
76
|
+
# controller overrides this to explain itself; nothing else should.
|
|
77
|
+
#
|
|
78
|
+
# `current_scope_`-prefixed like every other method this concern mixes into
|
|
79
|
+
# a host controller (current_scope_check!, current_scope_denied,
|
|
80
|
+
# current_scope_mutation_guard!, current_scope_record). A bare
|
|
81
|
+
# `render_access_denied` is a name a host app could plausibly already have,
|
|
82
|
+
# and we would silently call theirs.
|
|
83
|
+
#
|
|
84
|
+
# Takes the reason: an overrider that renders a body needs to know WHICH
|
|
85
|
+
# denial it is answering, or it will explain the wrong one.
|
|
86
|
+
def current_scope_render_denied(_reason = nil)
|
|
87
|
+
head :forbidden
|
|
88
|
+
end
|
|
89
|
+
end
|
|
90
|
+
end
|
|
@@ -0,0 +1,397 @@
|
|
|
1
|
+
require "set"
|
|
2
|
+
|
|
3
|
+
module CurrentScope
|
|
4
|
+
# The ONE answer to "what are this record's declared ancestors?" (#108).
|
|
5
|
+
#
|
|
6
|
+
# A model opts in with `current_scope_parent :project`. The resolver, failing a
|
|
7
|
+
# direct scoped-grant match, walks that chain and matches grants against the
|
|
8
|
+
# ancestors instead. Flat stays the default: a model that declares nothing gets
|
|
9
|
+
# an empty chain and a byte-identical decision.
|
|
10
|
+
#
|
|
11
|
+
# WHY A MACRO, when every other host hook (current_scope_record,
|
|
12
|
+
# current_scope_model, current_scope_initiator, current_scope_sod_bypassed?) is
|
|
13
|
+
# a plain method: those answer a per-INSTANCE question, so returning a value is
|
|
14
|
+
# enough. This one must also produce a QUERYABLE key — Resolver#scope_for builds
|
|
15
|
+
# `where(<foreign_key> => granted_ancestor_ids)`, and a method handing back a
|
|
16
|
+
# parent instance cannot yield a foreign key without loading every candidate
|
|
17
|
+
# row. Naming an association gives both derivations (the walk and the query)
|
|
18
|
+
# from one declaration, so the gate and the list cannot drift. The departure is
|
|
19
|
+
# deliberate and documented in the README rather than left to surprise a reader.
|
|
20
|
+
#
|
|
21
|
+
# DECLARATION ERRORS RAISE. DATA NEVER DOES. That split is load-bearing and was
|
|
22
|
+
# learned the hard way in review: the first cut raised ConfigurationError when a
|
|
23
|
+
# record's chain was too deep or looped, which meant two UPDATEs on a parent_id
|
|
24
|
+
# column — no code change at all — could 500 a live request, escape report
|
|
25
|
+
# mode's "never breaks a request" promise, and print a fix ("remove one of the
|
|
26
|
+
# current_scope_parent declarations") pointing at code that was correct. So:
|
|
27
|
+
# a bad DECLARATION raises when the host writes it (at the macro for most
|
|
28
|
+
# shapes; from validate_declarations! after load for a custom association
|
|
29
|
+
# primary key, which needs reflection.klass); bad or over-deep DATA truncates
|
|
30
|
+
# the walk, denies (fail-closed), and warns once.
|
|
31
|
+
module ParentChain
|
|
32
|
+
# A private ceiling, not a config knob: nobody can pick a default for a knob
|
|
33
|
+
# before a host declares a chain deep enough to need it. Raise it when one
|
|
34
|
+
# asks.
|
|
35
|
+
#
|
|
36
|
+
# Truncating past it is FAIL-CLOSED — fewer ancestors means fewer grants
|
|
37
|
+
# match, so the answer is a denial, never an escalation. It is also the only
|
|
38
|
+
# option that keeps the gate and the list agreeing: Resolver#ancestor_scope_for
|
|
39
|
+
# walks CLASSES, and a legitimate self-referential chain (Project belongs_to
|
|
40
|
+
# :parent, class_name: "Project") never terminates at the class level however
|
|
41
|
+
# shallow the data is. A ceiling that raised on one side and truncated on the
|
|
42
|
+
# other is exactly the divergence this constant exists to prevent.
|
|
43
|
+
MAX_PARENT_DEPTH = 5
|
|
44
|
+
|
|
45
|
+
# Installed on ActiveRecord::Base via ActiveSupport.on_load — the standard
|
|
46
|
+
# engine idiom for an acts_as_*-style declaration. Deliberately NOT hung off
|
|
47
|
+
# CurrentScope::Scopeable, whose contract is "BROWSE-ONLY — it does NOT gate
|
|
48
|
+
# access" (scopeable.rb:3) and whose `included` hook would also register the
|
|
49
|
+
# model in the scoped-role picker as a side effect of declaring a parent.
|
|
50
|
+
module Declaration
|
|
51
|
+
def current_scope_parent(association_name)
|
|
52
|
+
reflection = reflect_on_association(association_name)
|
|
53
|
+
|
|
54
|
+
if reflection.nil?
|
|
55
|
+
raise ConfigurationError,
|
|
56
|
+
"#{name}.current_scope_parent(#{association_name.inspect}) names an " \
|
|
57
|
+
"association that does not exist. Declare `belongs_to " \
|
|
58
|
+
"#{association_name.inspect}` first, or name the association that " \
|
|
59
|
+
"reaches the record scoped grants are held on."
|
|
60
|
+
end
|
|
61
|
+
|
|
62
|
+
unless reflection.belongs_to?
|
|
63
|
+
raise ConfigurationError,
|
|
64
|
+
"#{name}.current_scope_parent(#{association_name.inspect}) must name a " \
|
|
65
|
+
"belongs_to association — #{association_name.inspect} is a " \
|
|
66
|
+
"#{reflection.macro}. A scoped grant is held on ONE parent record, so " \
|
|
67
|
+
"the chain walks upward one owner at a time. Declare it on the CHILD " \
|
|
68
|
+
"instead — `current_scope_parent` in #{reflection.klass.name} — so a " \
|
|
69
|
+
"grant held here reaches its #{association_name}."
|
|
70
|
+
end
|
|
71
|
+
|
|
72
|
+
if reflection.polymorphic?
|
|
73
|
+
raise ConfigurationError,
|
|
74
|
+
"#{name}.current_scope_parent(#{association_name.inspect}) names a " \
|
|
75
|
+
"polymorphic belongs_to, which is not supported: the parent's class is " \
|
|
76
|
+
"not knowable without loading every candidate row, so scope_for could " \
|
|
77
|
+
"not build its query. Either name a concrete belongs_to if this model " \
|
|
78
|
+
"has one, or keep this model flat and grant on it directly."
|
|
79
|
+
end
|
|
80
|
+
|
|
81
|
+
# The walk loads the parent THROUGH the association, so a scope on it is
|
|
82
|
+
# applied; ancestor_scope_for rebuilds the join from the foreign key
|
|
83
|
+
# alone, so the scope never reaches that SQL. The two then disagree in
|
|
84
|
+
# the fail-OPEN direction — the gate denies while the collection-read
|
|
85
|
+
# gate (which is scope_for(...).exists?) allows and the list renders the
|
|
86
|
+
# rows. Refusing the shape is total; supporting it would mean building
|
|
87
|
+
# the arm with joins(reflection.name) so one definition feeds both.
|
|
88
|
+
if reflection.scope
|
|
89
|
+
raise ConfigurationError,
|
|
90
|
+
"#{name}.current_scope_parent(#{association_name.inspect}) names a SCOPED " \
|
|
91
|
+
"belongs_to. The per-record walk would apply that scope and the collection " \
|
|
92
|
+
"query would not, so the gate and the list would disagree about the same " \
|
|
93
|
+
"record. Declare an unscoped belongs_to for the chain."
|
|
94
|
+
end
|
|
95
|
+
|
|
96
|
+
# Resolver#scope_for is handed the COLLECTION type a controller names,
|
|
97
|
+
# which for STI is the base class, while the per-record gate reads the
|
|
98
|
+
# declaration off the instance's own class. A chain declared on a
|
|
99
|
+
# subclass would therefore be walked by the gate and invisible to the
|
|
100
|
+
# list — the two disagreeing about the same record. Refusing the shape
|
|
101
|
+
# removes that entire class of drift instead of documenting it.
|
|
102
|
+
if self != base_class
|
|
103
|
+
raise ConfigurationError,
|
|
104
|
+
"#{name}.current_scope_parent(#{association_name.inspect}) is declared on " \
|
|
105
|
+
"an STI subclass. Declare it on #{base_class.name} instead: scoped grants " \
|
|
106
|
+
"store the base class, and the collection query is built from it, so a " \
|
|
107
|
+
"subclass-only chain would open records the list could never show."
|
|
108
|
+
end
|
|
109
|
+
|
|
110
|
+
self.current_scope_parent_association = association_name.to_sym
|
|
111
|
+
CurrentScope::ParentChain.register(self)
|
|
112
|
+
end
|
|
113
|
+
|
|
114
|
+
def current_scope_parent_declared?
|
|
115
|
+
!current_scope_parent_association.nil?
|
|
116
|
+
end
|
|
117
|
+
end
|
|
118
|
+
|
|
119
|
+
class << self
|
|
120
|
+
# Declared classes, by NAME so dev-mode reloading never pins a stale
|
|
121
|
+
# constant (same reason CurrentScope::Scopeable stores strings).
|
|
122
|
+
def register(klass)
|
|
123
|
+
declared_names << klass.name if klass.name
|
|
124
|
+
end
|
|
125
|
+
|
|
126
|
+
def declared_names
|
|
127
|
+
@declared_names ||= Set.new
|
|
128
|
+
end
|
|
129
|
+
|
|
130
|
+
# Run once at boot (and on reload) rather than on the request path.
|
|
131
|
+
# validate_key! needs reflection.klass, which cannot resolve inside the
|
|
132
|
+
# macro without breaking an ordinary forward reference between two models
|
|
133
|
+
# that name each other — so the check has to happen later. Later must not
|
|
134
|
+
# mean "on the first gated request", because that is a deploy that boots
|
|
135
|
+
# green and 500s on real traffic. (cubic P2, ie-predictability P1)
|
|
136
|
+
#
|
|
137
|
+
# A single call sees only the classes loaded so far — in EVERY environment,
|
|
138
|
+
# not just development. That is why the engine calls it twice; see below.
|
|
139
|
+
#
|
|
140
|
+
# WORKS IN WAVES, and both halves of that are load-bearing.
|
|
141
|
+
#
|
|
142
|
+
# It cannot iterate the Set directly: validate_key! resolves
|
|
143
|
+
# reflection.klass, which AUTOLOADS the parent model, and a parent that
|
|
144
|
+
# declares a chain of its own registers itself from its class body —
|
|
145
|
+
# mutating the very Set being iterated. Ruby answers that with "can't add
|
|
146
|
+
# a new key into hash during iteration", a RuntimeError out of to_prepare
|
|
147
|
+
# for any host whose declared chain points at another declaring model. The
|
|
148
|
+
# dummy's Report -> Project is exactly that shape.
|
|
149
|
+
#
|
|
150
|
+
# It cannot iterate ONE snapshot either: a model that registers mid-pass
|
|
151
|
+
# would then wait for a later call of this method. Within one call there
|
|
152
|
+
# is no second chance, so it keeps taking snapshots until no new name
|
|
153
|
+
# appears — the walk is what loads those models, so it is also what must
|
|
154
|
+
# finish checking them. Terminates because the registry is finite and
|
|
155
|
+
# `validated` only grows. (#133 review — cubic)
|
|
156
|
+
#
|
|
157
|
+
# CALLED TWICE, on purpose (#139). From to_prepare, which railties runs
|
|
158
|
+
# BEFORE :eager_load! ("This needs to happen before eager load so it
|
|
159
|
+
# happens in exactly the same point regardless of config.eager_load") — so
|
|
160
|
+
# that pass sees only what was already loaded, and earns its keep in
|
|
161
|
+
# development by re-running on every reload. And from after_initialize
|
|
162
|
+
# where eager loading is on, which is the authoritative pass: every
|
|
163
|
+
# declaring model that was eager-loaded (and thus registered) is
|
|
164
|
+
# validated. Models outside eager_load_paths or marked do_not_eager_load
|
|
165
|
+
# still register only when first loaded, and nothing on the request path
|
|
166
|
+
# re-checks them. Idempotent, so running twice costs a second walk over a
|
|
167
|
+
# set that raises on the first bad entry either way.
|
|
168
|
+
def validate_declarations!
|
|
169
|
+
validated = Set.new
|
|
170
|
+
|
|
171
|
+
while (pending = declared_names.to_a.reject { |name| validated.include?(name) }).any?
|
|
172
|
+
pending.each do |name|
|
|
173
|
+
validated << name
|
|
174
|
+
klass = name.safe_constantize
|
|
175
|
+
next if klass.nil?
|
|
176
|
+
|
|
177
|
+
reflection = klass.reflect_on_association(klass.current_scope_parent_association)
|
|
178
|
+
validate_key!(klass, reflection) if reflection
|
|
179
|
+
end
|
|
180
|
+
end
|
|
181
|
+
end
|
|
182
|
+
|
|
183
|
+
# The ancestors a scoped grant may be matched against, nearest parent
|
|
184
|
+
# first, root last. Empty for an unopted model, a class, or an unsaved
|
|
185
|
+
# record — all three are "nothing to walk", not an error.
|
|
186
|
+
def ancestors_for(record)
|
|
187
|
+
return [] unless record.respond_to?(:new_record?) && record.persisted?
|
|
188
|
+
|
|
189
|
+
unless declared?(record.class)
|
|
190
|
+
reject_method_form!(record.class)
|
|
191
|
+
return []
|
|
192
|
+
end
|
|
193
|
+
|
|
194
|
+
walk(record)
|
|
195
|
+
end
|
|
196
|
+
|
|
197
|
+
# Whether this class (or an STI ancestor it inherits from) opted in.
|
|
198
|
+
# `klass < ActiveRecord::Base` rather than a bare respond_to? duck-type:
|
|
199
|
+
# the macro is installed on ActiveRecord::Base, so anything else answering
|
|
200
|
+
# this trio is a coincidence, and honouring it would let a non-AR class
|
|
201
|
+
# steer grant matching. Mirrors the record-less branch's collection_type?
|
|
202
|
+
# guard.
|
|
203
|
+
def declared?(klass)
|
|
204
|
+
klass.is_a?(Class) && klass < ActiveRecord::Base &&
|
|
205
|
+
klass.respond_to?(:current_scope_parent_declared?) &&
|
|
206
|
+
klass.current_scope_parent_declared?
|
|
207
|
+
end
|
|
208
|
+
|
|
209
|
+
# The declared association reflection, or nil. scope_for reads this for the
|
|
210
|
+
# foreign key; it must never re-derive the name itself.
|
|
211
|
+
# Resolved from base_class, ALWAYS. The declaration is refused on an STI
|
|
212
|
+
# subclass, but a subclass can still OVERRIDE the association it inherits
|
|
213
|
+
# (a different class_name or foreign key). The per-record gate reads the
|
|
214
|
+
# instance's class and the collection query reads the base, so resolving
|
|
215
|
+
# per-class would let those two walk different associations for the same
|
|
216
|
+
# record — the drift the STI refusal exists to prevent, re-entering by the
|
|
217
|
+
# back door. One declared base reflection feeds both. (cubic P1)
|
|
218
|
+
def reflection_for(klass)
|
|
219
|
+
unless declared?(klass)
|
|
220
|
+
# Both paths funnel through here, so this is where the method-form
|
|
221
|
+
# mistake has to be caught. Checking it only in ancestors_for meant the
|
|
222
|
+
# member gate raised while scope_for silently omitted parent grants —
|
|
223
|
+
# the collection answering :no_grant with no diagnosis. (qodo 3)
|
|
224
|
+
reject_method_form!(klass)
|
|
225
|
+
return nil
|
|
226
|
+
end
|
|
227
|
+
|
|
228
|
+
base = klass.base_class
|
|
229
|
+
base.reflect_on_association(base.current_scope_parent_association)
|
|
230
|
+
end
|
|
231
|
+
|
|
232
|
+
# Reset between reloads/tests so a truncation warning is not latched by a
|
|
233
|
+
# run that has since been fixed (same reason the gating tripwire resets).
|
|
234
|
+
def reset_warnings!
|
|
235
|
+
@warned = nil
|
|
236
|
+
end
|
|
237
|
+
|
|
238
|
+
private
|
|
239
|
+
|
|
240
|
+
# Checked HERE, not at declaration time, because it needs reflection.klass
|
|
241
|
+
# and forcing that constant to resolve inside the macro breaks a perfectly
|
|
242
|
+
# ordinary forward reference between two models that name each other. It is
|
|
243
|
+
# still a DECLARATION error, so it raises: no row value can reach it.
|
|
244
|
+
#
|
|
245
|
+
# Rebuilding the join (ancestor_scope_for) assumes the parent is matched on
|
|
246
|
+
# its own primary key. With `primary_key:` set, the child's foreign key
|
|
247
|
+
# holds some other column's values, so joining on the primary key would
|
|
248
|
+
# match a DIFFERENT parent row — a grant on X acting on Y, in the arm that
|
|
249
|
+
# IS the collection-read gate.
|
|
250
|
+
def validate_key!(klass, reflection)
|
|
251
|
+
return if reflection.polymorphic?
|
|
252
|
+
return if reflection.association_primary_key.to_s == reflection.klass.primary_key.to_s
|
|
253
|
+
|
|
254
|
+
raise ConfigurationError,
|
|
255
|
+
"#{klass.name}.current_scope_parent(#{reflection.name.inspect}) names a " \
|
|
256
|
+
"belongs_to with a custom association primary key " \
|
|
257
|
+
"(#{reflection.association_primary_key}). The collection query joins on " \
|
|
258
|
+
"#{reflection.klass.name}'s primary key, so it would match the wrong rows. " \
|
|
259
|
+
"Declare a chain that keys on the primary key."
|
|
260
|
+
end
|
|
261
|
+
|
|
262
|
+
def warned
|
|
263
|
+
@warned ||= Set.new
|
|
264
|
+
end
|
|
265
|
+
|
|
266
|
+
def walk(record)
|
|
267
|
+
ancestors = []
|
|
268
|
+
seen = [ identity(record) ]
|
|
269
|
+
current = record
|
|
270
|
+
|
|
271
|
+
while (reflection = reflection_for(current.class))
|
|
272
|
+
parent = load_parent(current, reflection)
|
|
273
|
+
|
|
274
|
+
# Normal data, all three: an optional belongs_to with no owner, a
|
|
275
|
+
# parent built in memory but never saved (it can hold no scoped grant),
|
|
276
|
+
# and one destroyed earlier in this request — the association cache
|
|
277
|
+
# would otherwise keep handing back the destroyed object, and matching
|
|
278
|
+
# a grant against it would open access to a record that is gone, which
|
|
279
|
+
# is the one-hop-up form of the AE4 rule.
|
|
280
|
+
break unless walkable?(parent)
|
|
281
|
+
|
|
282
|
+
key = identity(parent)
|
|
283
|
+
if seen.include?(key)
|
|
284
|
+
warn_truncated(record, :loop, seen + [ key ])
|
|
285
|
+
break
|
|
286
|
+
end
|
|
287
|
+
|
|
288
|
+
ancestors << parent
|
|
289
|
+
seen << key
|
|
290
|
+
current = parent
|
|
291
|
+
|
|
292
|
+
if ancestors.size >= MAX_PARENT_DEPTH
|
|
293
|
+
# Only a chain that actually CONTINUES past the ceiling was
|
|
294
|
+
# truncated. A chain of exactly five hops is valid and lost nothing,
|
|
295
|
+
# and warning here would latch [class, :depth] and swallow a later
|
|
296
|
+
# genuine over-depth warning for the same model. (qodo 2 / cubic P3)
|
|
297
|
+
warn_truncated(record, :depth, seen) if next_parent(current)
|
|
298
|
+
break
|
|
299
|
+
end
|
|
300
|
+
end
|
|
301
|
+
|
|
302
|
+
ancestors
|
|
303
|
+
end
|
|
304
|
+
|
|
305
|
+
# Whether the walk would have continued — used only to decide whether a
|
|
306
|
+
# ceiling stop actually TRUNCATED anything.
|
|
307
|
+
def next_parent(record)
|
|
308
|
+
reflection = reflection_for(record.class)
|
|
309
|
+
return nil unless reflection
|
|
310
|
+
|
|
311
|
+
parent = load_parent(record, reflection)
|
|
312
|
+
walkable?(parent) ? parent : nil
|
|
313
|
+
end
|
|
314
|
+
|
|
315
|
+
# The three terminal shapes, in ONE place so the walk and the
|
|
316
|
+
# would-it-have-continued check cannot disagree about what counts as a
|
|
317
|
+
# stop. A terminal parent means the walk ended normally and truncated
|
|
318
|
+
# nothing, so it must not warn — and must not latch [class, :depth] and
|
|
319
|
+
# swallow the next real over-depth warning. (cubic P3, second round)
|
|
320
|
+
def walkable?(parent)
|
|
321
|
+
!parent.nil? && !parent.new_record? && !parent.destroyed?
|
|
322
|
+
end
|
|
323
|
+
|
|
324
|
+
# F. Read the parent WITHOUT triggering a lazy load. A host running
|
|
325
|
+
# `strict_loading` would otherwise get ActiveRecord::StrictLoadingViolationError
|
|
326
|
+
# from inside the gate — a 500 on ordinary data, which is the failure this
|
|
327
|
+
# module exists to avoid. Uses the already-loaded target when there is one,
|
|
328
|
+
# so `includes(:project)` still pays off. (cubic P2)
|
|
329
|
+
def load_parent(record, reflection)
|
|
330
|
+
# A preloaded target is only trustworthy when the record's own class
|
|
331
|
+
# resolves the SAME association object the base declared. An STI subclass
|
|
332
|
+
# that overrides the inherited association would otherwise make the gate
|
|
333
|
+
# walk a different parent than scope_for queries — resolving the
|
|
334
|
+
# reflection from base_class is not enough on its own, because
|
|
335
|
+
# `record.association(name)` still resolves on the runtime subclass.
|
|
336
|
+
# (cubic P1, second round)
|
|
337
|
+
if record.class.reflect_on_association(reflection.name).equal?(reflection)
|
|
338
|
+
association = record.association(reflection.name)
|
|
339
|
+
return association.target if association.loaded?
|
|
340
|
+
end
|
|
341
|
+
|
|
342
|
+
foreign_key = record[reflection.foreign_key]
|
|
343
|
+
return nil if foreign_key.nil?
|
|
344
|
+
|
|
345
|
+
reflection.klass.find_by(reflection.klass.primary_key => foreign_key)
|
|
346
|
+
end
|
|
347
|
+
|
|
348
|
+
# Grants store the polymorphic base_class, so identity must too — otherwise
|
|
349
|
+
# an STI parent and its base would read as different records and a loop
|
|
350
|
+
# through them would not be seen.
|
|
351
|
+
def identity(record)
|
|
352
|
+
[ record.class.base_class.name, record.id ]
|
|
353
|
+
end
|
|
354
|
+
|
|
355
|
+
# Truncation denies rather than allows, so it can never escalate — but a
|
|
356
|
+
# denial nobody can explain is its own failure, hence the warning.
|
|
357
|
+
#
|
|
358
|
+
# The latch keys on the KIND (:loop / :depth), never on the rendered chain:
|
|
359
|
+
# the chain text carries record ids, so keying on it would mean a fresh
|
|
360
|
+
# entry per record — a warning on every row and a Set that grows without
|
|
361
|
+
# bound in a long-running process. At most two entries per class.
|
|
362
|
+
def warn_truncated(record, kind, chain)
|
|
363
|
+
key = [ record.class.base_class.name, kind ]
|
|
364
|
+
return if warned.include?(key)
|
|
365
|
+
|
|
366
|
+
warned << key
|
|
367
|
+
reason = kind == :loop ? "a loop in the data" : "a chain longer than #{MAX_PARENT_DEPTH}"
|
|
368
|
+
Rails.logger&.warn(
|
|
369
|
+
"[CurrentScope] current_scope_parent stopped walking #{record.class.name} " \
|
|
370
|
+
"early because of #{reason} (#{chain_text(chain)}). Grants held above that " \
|
|
371
|
+
"point will not match, so this can only DENY, never allow. Fix the data (or " \
|
|
372
|
+
"shorten the chain) if a subject is missing access they should have."
|
|
373
|
+
)
|
|
374
|
+
end
|
|
375
|
+
|
|
376
|
+
def chain_text(keys)
|
|
377
|
+
keys.map { |type, id| "#{type}##{id}" }.join(" -> ")
|
|
378
|
+
end
|
|
379
|
+
|
|
380
|
+
# A host that writes `def current_scope_parent = project` has written a
|
|
381
|
+
# method that nothing calls: the declaration is a class macro. Every other
|
|
382
|
+
# current_scope_* hook IS an instance method, so this is the mistake the
|
|
383
|
+
# name invites, and silence would be indistinguishable from "flat by
|
|
384
|
+
# design". This one DOES raise: it is a declaration error, reachable only
|
|
385
|
+
# by writing it, and no row value can trigger it.
|
|
386
|
+
def reject_method_form!(klass)
|
|
387
|
+
return unless klass.method_defined?(:current_scope_parent)
|
|
388
|
+
|
|
389
|
+
raise ConfigurationError,
|
|
390
|
+
"#{klass.name} defines an INSTANCE method " \
|
|
391
|
+
"`current_scope_parent`, but the declaration is a class-level macro " \
|
|
392
|
+
"and nothing reads that method. Replace it with " \
|
|
393
|
+
"`current_scope_parent :the_association` in the class body."
|
|
394
|
+
end
|
|
395
|
+
end
|
|
396
|
+
end
|
|
397
|
+
end
|
|
@@ -0,0 +1,146 @@
|
|
|
1
|
+
# Enumerable#to_set (key_set, the routed-key cache) is stdlib `set`, which a
|
|
2
|
+
# full Rails boot happens to load — a host on active_support.bare would
|
|
3
|
+
# NoMethodError at catalog construction without the explicit require.
|
|
4
|
+
require "set"
|
|
5
|
+
|
|
6
|
+
module CurrentScope
|
|
7
|
+
# Derives the permission set from the host's routes: one permission per
|
|
8
|
+
# controller#action pair. There is no table to maintain — add a controller
|
|
9
|
+
# and its actions appear in the grid on next boot/reload.
|
|
10
|
+
class PermissionCatalog
|
|
11
|
+
def keys
|
|
12
|
+
@keys ||= derive
|
|
13
|
+
end
|
|
14
|
+
|
|
15
|
+
# { "reports" => ["approve", "create", ...], ... } for the role-editor grid.
|
|
16
|
+
def grouped
|
|
17
|
+
keys.group_by { |key| key.split("#").first }
|
|
18
|
+
.transform_values { |ks| ks.map { |k| k.split("#").last } }
|
|
19
|
+
end
|
|
20
|
+
|
|
21
|
+
# Hot: the Guard asks this on EVERY gated request, and a role save asks it
|
|
22
|
+
# once per staged key. Set lookup, not Array#include? — the array is sorted
|
|
23
|
+
# for display, and scanning it linearly made every request pay for the size
|
|
24
|
+
# of the host's route table. Memoized alongside `keys` and dropped with it
|
|
25
|
+
# on reset!.
|
|
26
|
+
def include?(key)
|
|
27
|
+
key_set.include?(key)
|
|
28
|
+
end
|
|
29
|
+
|
|
30
|
+
# The ONE parse of config.sod_bypass_permission's action segment — the grid
|
|
31
|
+
# view and the ungated task read it here rather than re-splitting the
|
|
32
|
+
# string themselves (a looser split("#").last silently turns a malformed
|
|
33
|
+
# "reports#" into "reports" and mislabels the break-glass cell; #79 review).
|
|
34
|
+
#
|
|
35
|
+
# `split("#", -1)` keeps the trailing empty field, so a malformed "reports#"
|
|
36
|
+
# yields "" and is caught here rather than silently becoming "reports" and
|
|
37
|
+
# injecting "reports#reports". Blank raises instead of skipping: the host
|
|
38
|
+
# turned break-glass ON, so a permission nobody can hold means the veto can
|
|
39
|
+
# never be lifted and the feature is inert — an undiagnosable deny, which is
|
|
40
|
+
# exactly what this engine promises not to do. (A boot-time check for this
|
|
41
|
+
# config belongs with #40.)
|
|
42
|
+
def bypass_action
|
|
43
|
+
segments = CurrentScope.config.sod_bypass_permission.to_s.split("#", -1)
|
|
44
|
+
# Exactly a bare action or one controller#action. More hashes would pass
|
|
45
|
+
# a last-segment check while the resolver reads the ORIGINAL full string —
|
|
46
|
+
# the catalog would inject a key nobody can be granted under, and the
|
|
47
|
+
# veto could never be lifted. (#79 review)
|
|
48
|
+
if segments.empty? || segments.size > 2 || segments.any?(&:blank?)
|
|
49
|
+
raise ConfigurationError,
|
|
50
|
+
"config.allow_sod_bypass is on, but config.sod_bypass_permission " \
|
|
51
|
+
"(#{CurrentScope.config.sod_bypass_permission.inspect}) is not a bare action or a " \
|
|
52
|
+
"single controller#action. Name the permission the record's initiator must hold " \
|
|
53
|
+
"to break glass (the default is \"bypass_sod\"), or set config.allow_sod_bypass = false."
|
|
54
|
+
end
|
|
55
|
+
|
|
56
|
+
segments.last
|
|
57
|
+
end
|
|
58
|
+
|
|
59
|
+
# Is this key derived from a real route (as opposed to the injected
|
|
60
|
+
# break-glass key)? The distinction matters to anything making an
|
|
61
|
+
# inertness claim: a ROUTED action named like the bypass permission is an
|
|
62
|
+
# ordinary action and gets no exemption, while the injected key is live on
|
|
63
|
+
# any row. (#79 review)
|
|
64
|
+
def routed?(key)
|
|
65
|
+
keys # derive memoizes @routed_key_set as a side effect
|
|
66
|
+
@routed_key_set.include?(key)
|
|
67
|
+
end
|
|
68
|
+
|
|
69
|
+
private
|
|
70
|
+
|
|
71
|
+
def key_set
|
|
72
|
+
@key_set ||= keys.to_set
|
|
73
|
+
end
|
|
74
|
+
|
|
75
|
+
def derive
|
|
76
|
+
routed = Rails.application.routes.routes.filter_map { |route|
|
|
77
|
+
controller = route.defaults[:controller]
|
|
78
|
+
action = route.defaults[:action]
|
|
79
|
+
next unless controller && action
|
|
80
|
+
next if CurrentScope.config.excluded_controllers.any? { |re| controller.match?(re) }
|
|
81
|
+
|
|
82
|
+
"#{controller}##{action}"
|
|
83
|
+
}.uniq
|
|
84
|
+
|
|
85
|
+
@routed_key_set = routed.to_set
|
|
86
|
+
(routed + bypass_keys(routed)).uniq.sort
|
|
87
|
+
end
|
|
88
|
+
|
|
89
|
+
# Break-glass is the one permission that isn't an action you can route: it
|
|
90
|
+
# gates the SoD veto rather than a request. So a purely route-derived catalog
|
|
91
|
+
# can never contain it — which left the shipped "grantable, editable in the
|
|
92
|
+
# role grid" claim false, and break-glass reachable only through full_access
|
|
93
|
+
# (defeating the point of a *scoped* trusted-approver role) or a console
|
|
94
|
+
# insert. The catalog is the single definition of what is grantable — the
|
|
95
|
+
# grid reads `grouped`, the role setter and the Guard read `include?` — so
|
|
96
|
+
# injecting the virtual key here makes the cell render AND the save stick,
|
|
97
|
+
# with no special case in either. (#21)
|
|
98
|
+
#
|
|
99
|
+
# Emitted only where it could mean something: break-glass on, and a
|
|
100
|
+
# controller that actually routes an SoD action. Off by default, so the
|
|
101
|
+
# catalog is byte-for-byte the routed set unless a host opts in.
|
|
102
|
+
#
|
|
103
|
+
# Route- and config-derived only — deliberately no model introspection. The
|
|
104
|
+
# precise set would be "controllers whose SoD-gated model defines an
|
|
105
|
+
# initiator", but discovering that means loading application models, which
|
|
106
|
+
# is expensive, boot-order fragile, and against the catalog's whole design.
|
|
107
|
+
#
|
|
108
|
+
# The key is built from the controller's LAST path segment, because that is
|
|
109
|
+
# what the resolver will ask for: it derives the bypass key from the
|
|
110
|
+
# RECORD's route_key (permission_key → record.model_name.route_key), never
|
|
111
|
+
# from the controller path. Under Rails' resource conventions those agree —
|
|
112
|
+
# Admin::ReportsController's last segment "reports" IS Report's route_key —
|
|
113
|
+
# so keying off the whole path would inject "admin/reports#bypass_sod" while
|
|
114
|
+
# the resolver looks up "reports#bypass_sod", leaving break-glass ungrantable
|
|
115
|
+
# for every namespaced SoD controller and handing the admin a cell that
|
|
116
|
+
# silently does nothing. Namespaced admin controllers are common enough that
|
|
117
|
+
# this is the difference between the fix working and not.
|
|
118
|
+
#
|
|
119
|
+
# A namespace-only resource therefore gets its bypass cell on a "reports"
|
|
120
|
+
# row that no controller routes — the grid renders it aligned, blank
|
|
121
|
+
# everywhere else. Slightly odd to look at, and correct: it is the key the
|
|
122
|
+
# resolver actually reads.
|
|
123
|
+
#
|
|
124
|
+
# The irreducible limit: a controller named differently from the records it
|
|
125
|
+
# acts on (an `approvals` controller approving `Invoice`s) still injects
|
|
126
|
+
# `approvals#bypass_sod` while the live key is `invoices#bypass_sod`.
|
|
127
|
+
# Closing that needs to know the SoD-gated model, i.e. introspection.
|
|
128
|
+
# Tracked at OQ-2.
|
|
129
|
+
def bypass_keys(routed)
|
|
130
|
+
return [] unless CurrentScope.config.allow_sod_bypass
|
|
131
|
+
|
|
132
|
+
sod_actions = CurrentScope.config.sod_actions
|
|
133
|
+
return [] if sod_actions.empty?
|
|
134
|
+
|
|
135
|
+
routed.group_by { |key| key.split("#").first }
|
|
136
|
+
.filter_map { |controller, keys|
|
|
137
|
+
actions = keys.map { |k| k.split("#").last }
|
|
138
|
+
"#{controller.split('/').last}##{bypass_action}" if actions.intersect?(sod_actions)
|
|
139
|
+
}
|
|
140
|
+
end
|
|
141
|
+
|
|
142
|
+
# The action segment of config.sod_bypass_permission — tolerating either a
|
|
143
|
+
# bare action ("bypass_sod") or a full key ("reports#bypass_sod").
|
|
144
|
+
#
|
|
145
|
+
end
|
|
146
|
+
end
|