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,202 @@
|
|
|
1
|
+
module CurrentScope
|
|
2
|
+
class Engine < ::Rails::Engine
|
|
3
|
+
isolate_namespace CurrentScope
|
|
4
|
+
|
|
5
|
+
# An AccessDenied that escapes any Guard rescue (PORO, Context-only
|
|
6
|
+
# controller, re-raise) must 403, not 500. Never turns a deny into an
|
|
7
|
+
# allow — the exception already blocked the action (#39).
|
|
8
|
+
#
|
|
9
|
+
# ||= so a host that already set this mapping (e.g. :not_found to hide
|
|
10
|
+
# existence) in config/application.rb is not clobbered. before:
|
|
11
|
+
# action_dispatch.configure so ExceptionWrapper.rescue_responses picks the
|
|
12
|
+
# entry up when it merge!s the config hash.
|
|
13
|
+
initializer "current_scope.rescue_responses", before: "action_dispatch.configure" do |app|
|
|
14
|
+
app.config.action_dispatch.rescue_responses["CurrentScope::AccessDenied"] ||= :forbidden
|
|
15
|
+
end
|
|
16
|
+
|
|
17
|
+
# Belt for Rails upgrades: if action_dispatch.configure is renamed/reordered
|
|
18
|
+
# and the config merge is missed, still pin the class map. Use key? — the
|
|
19
|
+
# ExceptionWrapper hash defaults missing keys to :internal_server_error
|
|
20
|
+
# (truthy), so ||= would never write.
|
|
21
|
+
initializer "current_scope.rescue_responses_apply", after: "action_dispatch.configure" do
|
|
22
|
+
map = ActionDispatch::ExceptionWrapper.rescue_responses
|
|
23
|
+
map["CurrentScope::AccessDenied"] = :forbidden unless map.key?("CurrentScope::AccessDenied")
|
|
24
|
+
end
|
|
25
|
+
|
|
26
|
+
# The `current_scope_parent` declaration (#108). on_load rather than a
|
|
27
|
+
# host-included concern: it is an acts_as_*-style class macro, and hanging it
|
|
28
|
+
# off CurrentScope::Scopeable would both contradict that module's BROWSE-ONLY
|
|
29
|
+
# contract and register every parent-declaring model in the scoped-role
|
|
30
|
+
# picker as a side effect. instance_accessor: false because the declaration is
|
|
31
|
+
# a class-level fact and is only ever read through the class; an instance
|
|
32
|
+
# reader would blur that. (It does NOT collide with the method-form mistake
|
|
33
|
+
# ParentChain.reject_method_form! catches — that one is named
|
|
34
|
+
# current_scope_parent, a different method entirely.)
|
|
35
|
+
initializer "current_scope.parent_chain" do
|
|
36
|
+
ActiveSupport.on_load(:active_record) do
|
|
37
|
+
class_attribute :current_scope_parent_association,
|
|
38
|
+
instance_accessor: false,
|
|
39
|
+
default: nil
|
|
40
|
+
extend CurrentScope::ParentChain::Declaration
|
|
41
|
+
end
|
|
42
|
+
end
|
|
43
|
+
|
|
44
|
+
# Cross-field config invariants (e.g. bypass permission ∉ sod_actions) must
|
|
45
|
+
# run AFTER the host initializer has assigned every field — a writer on
|
|
46
|
+
# either attr alone is order-dependent. once, not on to_prepare (config
|
|
47
|
+
# does not change on code reload). #40.
|
|
48
|
+
config.after_initialize do
|
|
49
|
+
CurrentScope.config.validate!
|
|
50
|
+
# Two separate checks. The schema guard asks whether the DATABASE has the
|
|
51
|
+
# shape #151 requires (SchemaGuard); this one asks whether the configured
|
|
52
|
+
# subject CLASS can be named by one id. Different questions, different
|
|
53
|
+
# failure messages, so they are not folded together.
|
|
54
|
+
CurrentScope::SchemaGuard.check!
|
|
55
|
+
end
|
|
56
|
+
|
|
57
|
+
# Routes (and therefore the derived permission catalog) can change on
|
|
58
|
+
# every code reload in development, and reloaded host models must re-register
|
|
59
|
+
# as scopeable rather than pile up stale/duplicate entries. Both reset here,
|
|
60
|
+
# ahead of eager-load, so the registry rebuilds cleanly.
|
|
61
|
+
config.to_prepare do
|
|
62
|
+
# Resolve the host's reloadable subject model only from the reload-safe
|
|
63
|
+
# callback, not after_initialize. The write validation remains the
|
|
64
|
+
# fail-closed guarantee; this is the early diagnostic.
|
|
65
|
+
CurrentScope::Engine.validate_subject_key!
|
|
66
|
+
CurrentScope.reset_catalog!
|
|
67
|
+
CurrentScope.reset_scopeable_registry!
|
|
68
|
+
# The cross-controller nudge warns once per site; a reload can change what's
|
|
69
|
+
# routed, so a stale latch would hide a divergence the edit just created.
|
|
70
|
+
CurrentScope.reset_cross_controller_warnings!
|
|
71
|
+
# Same reason for the tripwire's :warn latch: a reload can change whether a
|
|
72
|
+
# controller#action is gated, and a stale latch would hand a dev running
|
|
73
|
+
# :warn a false all-clear right after the edit.
|
|
74
|
+
CurrentScope::GatingTripwire.reset_warnings!
|
|
75
|
+
# Same reason: a reload can change a declared chain, and a latched
|
|
76
|
+
# truncation warning would hide the one the edit just created.
|
|
77
|
+
CurrentScope::ParentChain.reset_warnings!
|
|
78
|
+
# Declaration validation that needs reflection.klass runs HERE, not on the
|
|
79
|
+
# request path, so a bad declaration is caught before traffic. This pass is
|
|
80
|
+
# the DEVELOPMENT one: to_prepare runs before :eager_load!, so it sees only
|
|
81
|
+
# models something else already loaded — but it re-runs on every reload, so
|
|
82
|
+
# a dev's coverage grows as they work. The authoritative pass is the
|
|
83
|
+
# eager-load one below. (#139)
|
|
84
|
+
CurrentScope::ParentChain.validate_declarations!
|
|
85
|
+
end
|
|
86
|
+
|
|
87
|
+
# #139: the pass that sees every declaring model that was eager-loaded.
|
|
88
|
+
#
|
|
89
|
+
# Railties runs :run_prepare_callbacks (the to_prepare above) BEFORE
|
|
90
|
+
# :eager_load!, with the source comment "This needs to happen before eager
|
|
91
|
+
# load so it happens in exactly the same point regardless of
|
|
92
|
+
# config.eager_load". So that pass reads a registry holding only whatever
|
|
93
|
+
# was already loaded — in production, close to nothing — and the ONE check
|
|
94
|
+
# it performs could be skipped entirely for a model nobody had touched yet.
|
|
95
|
+
#
|
|
96
|
+
# That check is not cosmetic. validate_key! is the only guard anywhere
|
|
97
|
+
# against a current_scope_parent on a belongs_to with a custom
|
|
98
|
+
# `primary_key:`, and nothing on the request path repeats it. Unvalidated,
|
|
99
|
+
# both scope_for and the unloaded load_parent walk key the parent on its
|
|
100
|
+
# primary key — comparing values from different columns. Both hide records
|
|
101
|
+
# the grant should reach AND surface / open unrelated ones whose
|
|
102
|
+
# foreign-key value happens to collide with a granted parent's id. With a
|
|
103
|
+
# numeric custom key that collision space is dense. See the
|
|
104
|
+
# characterization test in test/parent_chain_test.rb.
|
|
105
|
+
#
|
|
106
|
+
# Gated on eager_load, which is the whole point: where it is on, declaring
|
|
107
|
+
# models that were eager-loaded are already in the registry, so this pass
|
|
108
|
+
# does not walk the autoloader looking for *new* declarers. Where it is off
|
|
109
|
+
# (development), the registry is partial anyway and running this would
|
|
110
|
+
# safe_constantize reloadable names during initialization — the thing Rails
|
|
111
|
+
# tells you not to do, and which pins constants that the first reload then
|
|
112
|
+
# makes stale. Development keeps the to_prepare pass, which re-runs and
|
|
113
|
+
# catches up.
|
|
114
|
+
#
|
|
115
|
+
# Residual autoload of association TARGETS still exists: validate_key!
|
|
116
|
+
# resolves reflection.klass, so a parent model kept off the eager-load
|
|
117
|
+
# surface can load on first validation. That is the same partial-coverage
|
|
118
|
+
# bargain as do_not_eager_load for declaring models, and it is named in
|
|
119
|
+
# UPGRADING.md rather than implied away by "nothing here autoloads".
|
|
120
|
+
#
|
|
121
|
+
# after_initialize (not after: :eager_load!) so we sit in the same finisher
|
|
122
|
+
# window as other boot checks. Order among after_initialize blocks is
|
|
123
|
+
# registration order: a host that first loads a declaring model from a
|
|
124
|
+
# *later* after_initialize still misses this pass — same residual family,
|
|
125
|
+
# documented in UPGRADING.md.
|
|
126
|
+
config.after_initialize do |app|
|
|
127
|
+
CurrentScope::ParentChain.validate_declarations! if app.config.eager_load
|
|
128
|
+
end
|
|
129
|
+
|
|
130
|
+
# #133: a deploy must not boot green and 500 on the first gated request. An
|
|
131
|
+
# SoD action whose model defines no current_scope_initiator raises per
|
|
132
|
+
# request — in :report mode too, which is where it hurts most, because
|
|
133
|
+
# report mode is what a host turns on to survey live traffic without
|
|
134
|
+
# changing anything for users.
|
|
135
|
+
#
|
|
136
|
+
# WHEN this fires is "as soon as the routes exist", which is boot only where
|
|
137
|
+
# routes load during initialization. Railties ends set_routes_reloader_hook
|
|
138
|
+
# with `reloader.execute_unless_loaded if !app.routes.is_a?(Engine::
|
|
139
|
+
# LazyRouteSet) || app.config.eager_load` — so an eager-loading environment
|
|
140
|
+
# (production, staging, the environments a bake actually runs in) warns at
|
|
141
|
+
# boot, while development's lazy route set defers it to the first request or
|
|
142
|
+
# reload. Forcing the routes early to make "boot" literally true would
|
|
143
|
+
# defeat LazyRouteSet for every host to make one log line punctual.
|
|
144
|
+
#
|
|
145
|
+
# after_routes_loaded, NOT to_prepare, and that is load-bearing. The
|
|
146
|
+
# preflight reads CurrentScope.catalog, and the catalog MEMOIZES its
|
|
147
|
+
# derivation — so asking it before the routes are drawn caches an EMPTY
|
|
148
|
+
# permission set for the life of the process, and every gated request then
|
|
149
|
+
# raises "not in the permission catalog". Measured, not reasoned: the dummy
|
|
150
|
+
# app booted with 44 catalog keys and 0 with the check on to_prepare. This
|
|
151
|
+
# hook is the one place Rails guarantees the route set is complete, and it
|
|
152
|
+
# re-runs on every routes reload, so a dev edit is re-checked.
|
|
153
|
+
#
|
|
154
|
+
# WHAT THIS ACTUALLY EXECUTES, because "log-only" undersold it: one
|
|
155
|
+
# `klass.new` per routed SoD controller, to read its current_scope_model
|
|
156
|
+
# declaration (an instance method — there is no other way to ask). Declared
|
|
157
|
+
# MODELS are answered from the class and are NOT instantiated unless the
|
|
158
|
+
# scan is about to name one, so a host's `after_initialize` runs only for a
|
|
159
|
+
# model that is already a candidate finding. Failures are absorbed and surfaced through
|
|
160
|
+
# the scan Result's skipped list, so the risk is not a crash — it is that a side
|
|
161
|
+
# effect in a host constructor has already happened by the time the rescue
|
|
162
|
+
# runs. Nothing here writes to the database, and it is a no-op until a host
|
|
163
|
+
# opts into SoD (config.sod_actions defaults to []).
|
|
164
|
+
initializer "current_scope.sod_preflight" do |app|
|
|
165
|
+
app.config.after_routes_loaded do
|
|
166
|
+
CurrentScope::SodPreflight.warn!
|
|
167
|
+
end
|
|
168
|
+
end
|
|
169
|
+
|
|
170
|
+
# #151. subject_id/resource_id are string columns now, so an integer key and a
|
|
171
|
+
# UUID both store whole and there is nothing left to scan stored rows for.
|
|
172
|
+
# What a config value CAN still name is a subject class whose primary key is
|
|
173
|
+
# not one value — composite, or absent — which no grant could ever identify.
|
|
174
|
+
# Cheap early warning; the write validations are the guarantee.
|
|
175
|
+
#
|
|
176
|
+
# Silent when it cannot introspect (no connection, no table, unresolved
|
|
177
|
+
# class): "unknown" must not become "broken", or `rails db:create` on a fresh
|
|
178
|
+
# checkout would raise.
|
|
179
|
+
def self.validate_subject_key!
|
|
180
|
+
# Skip during database tasks, exactly as SchemaGuard does. This runs from
|
|
181
|
+
# to_prepare, which also fires during `rails db:migrate`; a composite or
|
|
182
|
+
# absent subject key would otherwise raise BEFORE the host can run the very
|
|
183
|
+
# migration that fixes it. The write-path validation stays the fail-closed
|
|
184
|
+
# guarantee, so skipping the early diagnostic here loses nothing.
|
|
185
|
+
return if CurrentScope::SchemaGuard.running_a_database_task?
|
|
186
|
+
|
|
187
|
+
klass = CurrentScope.config.subject_class
|
|
188
|
+
klass = klass.to_s.safe_constantize if klass.is_a?(String) || klass.is_a?(Symbol)
|
|
189
|
+
return unless klass.respond_to?(:primary_key)
|
|
190
|
+
return if CurrentScope.storable_key?(klass)
|
|
191
|
+
|
|
192
|
+
raise ConfigurationError, CurrentScope.unstorable_key_error(klass, role: "subject")
|
|
193
|
+
rescue ActiveRecord::ActiveRecordError
|
|
194
|
+
# This rescue is scoped to THIS check on purpose. It exists to keep an
|
|
195
|
+
# unknown subject class quiet; the schema guard must never share it, or a
|
|
196
|
+
# transient database error at boot would read as "all clear" and the host
|
|
197
|
+
# would serve with the escalation live — a security guard failing open.
|
|
198
|
+
Rails.logger&.warn("[CurrentScope] subject key check skipped — could not introspect the database (#151).")
|
|
199
|
+
nil
|
|
200
|
+
end
|
|
201
|
+
end
|
|
202
|
+
end
|
|
@@ -0,0 +1,113 @@
|
|
|
1
|
+
module CurrentScope
|
|
2
|
+
# Answers one question about a controller path: is it PROVEN that
|
|
3
|
+
# current_scope_check! never runs there? True only when the callback is
|
|
4
|
+
# ABSENT from the class's callback chain — the one thing the chain states
|
|
5
|
+
# unconditionally. Everything else is false:
|
|
6
|
+
#
|
|
7
|
+
# - callback present and unconditional → gated → false
|
|
8
|
+
# - callback present wearing a condition (skip_before_action only:) →
|
|
9
|
+
# UNPROVABLE without evaluating ActionFilter internals, which this class
|
|
10
|
+
# must never do (KTD-3) → false. The naive any?/none? presence check
|
|
11
|
+
# inverts exactly here — see the ProblemFrame characterization in
|
|
12
|
+
# test/gating_reflection_test.rb — and per-action guessing is how a
|
|
13
|
+
# fail-open gets reported as gated.
|
|
14
|
+
# - no controller class routed at that path → nothing to prove → false
|
|
15
|
+
#
|
|
16
|
+
# Absence is an EFFECT, not a cause: a bare skip_before_action, an inherited
|
|
17
|
+
# bare skip (#62), and never including Guard at all are the same verdict,
|
|
18
|
+
# because the chain looks the same. That is deliberate — the question is
|
|
19
|
+
# "does the gate run?", not "why not?".
|
|
20
|
+
#
|
|
21
|
+
# Named beside GatingTripwire: both interrogate the same subject (is this
|
|
22
|
+
# action gated?), the tripwire at request time, this one by reflection.
|
|
23
|
+
class GatingReflection
|
|
24
|
+
def ungated?(controller_path)
|
|
25
|
+
klass = controller_class_for(controller_path)
|
|
26
|
+
# A controller without the callbacks API at all (a bare ActionController::
|
|
27
|
+
# Metal without AbstractController::Callbacks) cannot run a before_action
|
|
28
|
+
# — the gate PROVABLY never runs there. Without this check the reflection
|
|
29
|
+
# would raise NoMethodError instead of answering. (#79 review)
|
|
30
|
+
return true unless klass.respond_to?(:_process_action_callbacks)
|
|
31
|
+
|
|
32
|
+
klass
|
|
33
|
+
._process_action_callbacks
|
|
34
|
+
.none? { |callback| callback.kind == :before && callback.filter == :current_scope_check! }
|
|
35
|
+
rescue ActionDispatch::MissingController
|
|
36
|
+
# A routed path with no controller class (a scaffolding leftover, a
|
|
37
|
+
# not-yet-written controller) proves nothing about gating. Silent false,
|
|
38
|
+
# matching the prove-or-stay-silent discipline of
|
|
39
|
+
# warn_on_cross_controller_derivation. Surface that shape via
|
|
40
|
+
# #missing_controller? instead — the grid badges it (#43).
|
|
41
|
+
false
|
|
42
|
+
end
|
|
43
|
+
|
|
44
|
+
# True when the path is routed but no controller class loads (stale route /
|
|
45
|
+
# typo). Distinct from ungated?: there is nothing to gate. Hitting the
|
|
46
|
+
# route is ActionDispatch::MissingController (a 500 in production), so the
|
|
47
|
+
# role grid flags the row (#43). Uses the same Rails path→class rule as
|
|
48
|
+
# #ungated? so the two answers cannot disagree on loadability.
|
|
49
|
+
def missing_controller?(controller_path)
|
|
50
|
+
controller_class_for(controller_path)
|
|
51
|
+
false
|
|
52
|
+
rescue ActionDispatch::MissingController
|
|
53
|
+
true
|
|
54
|
+
end
|
|
55
|
+
|
|
56
|
+
# Reason from current_scope_skip_gate!(reason:), or nil when ungated without
|
|
57
|
+
# a declaration (bare skip / never included). Meaningful when #ungated?
|
|
58
|
+
# is true (#76).
|
|
59
|
+
def declared_skip_reason(controller_path)
|
|
60
|
+
klass = controller_class_for(controller_path)
|
|
61
|
+
return nil unless klass.respond_to?(:current_scope_gate_skip_reason)
|
|
62
|
+
|
|
63
|
+
klass.current_scope_gate_skip_reason.presence
|
|
64
|
+
rescue ActionDispatch::MissingController
|
|
65
|
+
nil
|
|
66
|
+
end
|
|
67
|
+
|
|
68
|
+
# The controller class routed at this path, or nil when none is (a stale
|
|
69
|
+
# route, a not-yet-written controller). PUBLIC because SodPreflight (#133)
|
|
70
|
+
# asks the same path→class question, and a second copy would have to
|
|
71
|
+
# reimplement the NameError triage described below — the one thing that
|
|
72
|
+
# keeps a broken controller from being reported as a clean one.
|
|
73
|
+
#
|
|
74
|
+
# This is not a second way to ask the same question, and the difference is
|
|
75
|
+
# the whole point: `controller_class_for` RAISES MissingController — every
|
|
76
|
+
# private caller above rescues it into its own answer (`ungated?` → false,
|
|
77
|
+
# `missing_controller?` → true, `declared_skip_reason` → nil), because those
|
|
78
|
+
# three mean different things by "no class here". This one is the fourth such
|
|
79
|
+
# answer, spelled nil, and it delegates rather than reimplements, so there is
|
|
80
|
+
# still exactly one path→class rule. What must NOT be added is a public
|
|
81
|
+
# method that swallows the NameError too. (#133 review — cubic)
|
|
82
|
+
def controller_class(controller_path)
|
|
83
|
+
controller_class_for(controller_path)
|
|
84
|
+
rescue ActionDispatch::MissingController
|
|
85
|
+
nil
|
|
86
|
+
end
|
|
87
|
+
|
|
88
|
+
private
|
|
89
|
+
|
|
90
|
+
# Rails owns the path→class rule — camelize, namespacing, the Controller
|
|
91
|
+
# suffix, and crucially the NameError triage: a missing CONTROLLER constant
|
|
92
|
+
# becomes MissingController (rescued above), while a NameError raised from
|
|
93
|
+
# inside the controller's own broken body re-raises as-is and must
|
|
94
|
+
# PROPAGATE out of ungated? (a blanket rescue NameError would silently
|
|
95
|
+
# report a broken controller as gated). Hand-rolling camelize+constantize
|
|
96
|
+
# would have to reimplement that triage and would drift. (KTD-2)
|
|
97
|
+
#
|
|
98
|
+
# controller_class_for lives on Request, so a throwaway request object is
|
|
99
|
+
# the price of asking. Built lazily and memoized here, NEVER in initialize:
|
|
100
|
+
# the role-save path constructs a GatingReflection it may never ask — any
|
|
101
|
+
# failure in construction would 500 role saves. (KTD-8)
|
|
102
|
+
# Memoize path→class for the life of this reflection instance. The role
|
|
103
|
+
# editor asks ungated? and missing_controller? per row; without a cache
|
|
104
|
+
# that is two constantizes per controller (cubic P2 on #43).
|
|
105
|
+
def controller_class_for(controller_path)
|
|
106
|
+
@class_for ||= {}
|
|
107
|
+
return @class_for[controller_path] if @class_for.key?(controller_path)
|
|
108
|
+
|
|
109
|
+
@request ||= ActionDispatch::Request.new({})
|
|
110
|
+
@class_for[controller_path] = @request.controller_class_for(controller_path)
|
|
111
|
+
end
|
|
112
|
+
end
|
|
113
|
+
end
|
|
@@ -0,0 +1,84 @@
|
|
|
1
|
+
module CurrentScope
|
|
2
|
+
# A4: an opt-in dev/test tripwire that catches an action which completed
|
|
3
|
+
# WITHOUT being gated by Guard's current_scope_check!. Include it on a base
|
|
4
|
+
# controller you want verified — INCLUDING one that never includes Guard (an
|
|
5
|
+
# API base, a hand-rolled ActionController::Base), which is exactly the
|
|
6
|
+
# ungated case Guard's own after_action could never see.
|
|
7
|
+
#
|
|
8
|
+
# class ApiController < ActionController::Base
|
|
9
|
+
# include CurrentScope::GatingTripwire
|
|
10
|
+
# current_scope_skip_tripwire! only: :health # mark genuinely-public actions
|
|
11
|
+
# end
|
|
12
|
+
#
|
|
13
|
+
# It owns its skip API on purpose: it can NOT reuse
|
|
14
|
+
# `skip_before_action :current_scope_check!`, because on a controller that
|
|
15
|
+
# never included Guard that callback is undefined and skip_before_action
|
|
16
|
+
# raises ArgumentError at class load — self-defeating on the very controllers
|
|
17
|
+
# the tripwire targets. `current_scope_skip_tripwire!` skips the tripwire's own
|
|
18
|
+
# after_action instead.
|
|
19
|
+
#
|
|
20
|
+
# Known blind spot: an after_action does not run when a before_action halts the
|
|
21
|
+
# chain (render/redirect) — so an action that renders straight from a
|
|
22
|
+
# before_action escapes the tripwire. It is a strong dev/test aid, not total
|
|
23
|
+
# coverage (Grape/Rack endpoints are out of reach for the same reason).
|
|
24
|
+
# What a catch DOES is config.gating_tripwire (:raise in dev/test, :warn
|
|
25
|
+
# elsewhere) — :warn logs each ungated controller#action once instead of
|
|
26
|
+
# 500ing, so a real app can inventory its ungated surface.
|
|
27
|
+
module GatingTripwire
|
|
28
|
+
extend ActiveSupport::Concern
|
|
29
|
+
|
|
30
|
+
class << self
|
|
31
|
+
# The :warn latch. ponytail: a plain Set, not a Mutex — worst case under
|
|
32
|
+
# a race is one extra line, and a flood is the thing being prevented.
|
|
33
|
+
# Per-SITE, not per-process (unlike Guard.ledger_warning_emitted?): the
|
|
34
|
+
# point of :warn is an inventory, so every distinct controller#action
|
|
35
|
+
# must get its own line.
|
|
36
|
+
def warning_unseen?(site)
|
|
37
|
+
@warned ||= Set.new
|
|
38
|
+
@warned.add?(site) ? true : false
|
|
39
|
+
end
|
|
40
|
+
|
|
41
|
+
# Cleared on engine to_prepare — a reload can change whether a site is
|
|
42
|
+
# gated, so a stale latch is a false all-clear — and by tests.
|
|
43
|
+
def reset_warnings!
|
|
44
|
+
@warned = nil
|
|
45
|
+
end
|
|
46
|
+
end
|
|
47
|
+
|
|
48
|
+
included do
|
|
49
|
+
after_action :current_scope_verify_gated!
|
|
50
|
+
end
|
|
51
|
+
|
|
52
|
+
class_methods do
|
|
53
|
+
# The tripwire's OWN exempt marker. Accepts the usual callback filters
|
|
54
|
+
# (only:/except:) and simply skips the tripwire after_action for them.
|
|
55
|
+
def current_scope_skip_tripwire!(**options)
|
|
56
|
+
skip_after_action :current_scope_verify_gated!, **options
|
|
57
|
+
end
|
|
58
|
+
end
|
|
59
|
+
|
|
60
|
+
private
|
|
61
|
+
|
|
62
|
+
def current_scope_verify_gated!
|
|
63
|
+
# Guard sets @current_scope_checked when current_scope_check! runs. A
|
|
64
|
+
# controller carrying only this mixin (no Guard) never sets it.
|
|
65
|
+
return if instance_variable_defined?(:@current_scope_checked) && @current_scope_checked
|
|
66
|
+
|
|
67
|
+
raise CurrentScope::ConfigurationError, current_scope_tripwire_message if CurrentScope.config.gating_tripwire == :raise
|
|
68
|
+
|
|
69
|
+
# :warn — same remediation text, once per controller#action. The latch
|
|
70
|
+
# check comes first so an already-warned site (every request after the
|
|
71
|
+
# first, on a fail-open that keeps serving traffic) pays no string build.
|
|
72
|
+
return unless GatingTripwire.warning_unseen?("#{controller_path}##{action_name}")
|
|
73
|
+
|
|
74
|
+
Rails.logger&.warn("[CurrentScope] #{current_scope_tripwire_message}")
|
|
75
|
+
end
|
|
76
|
+
|
|
77
|
+
def current_scope_tripwire_message
|
|
78
|
+
"\"#{controller_path}##{action_name}\" completed without running current_scope_check! — " \
|
|
79
|
+
"this controller is not gated by CurrentScope::Guard. Include CurrentScope::Guard on its " \
|
|
80
|
+
"base controller, or mark this action public with " \
|
|
81
|
+
"`current_scope_skip_tripwire! only: :#{action_name}`."
|
|
82
|
+
end
|
|
83
|
+
end
|
|
84
|
+
end
|
|
@@ -0,0 +1,216 @@
|
|
|
1
|
+
module CurrentScope
|
|
2
|
+
# Judges a scoped grant. Two methods, two certainties:
|
|
3
|
+
#
|
|
4
|
+
# verdict_for PROVEN — the grant cannot match anything, for any type.
|
|
5
|
+
# type_untargeted? ADVISORY — suggestive only; the host's runtime hooks
|
|
6
|
+
# decide what a controller resolves to, so this can be
|
|
7
|
+
# wrong and says so where the operator reads it.
|
|
8
|
+
#
|
|
9
|
+
# Neither is #90's "inert" (the RECORD is gone; different fix).
|
|
10
|
+
# Design rationale: docs/plans/2026-07-28-032-feat-unresolvable-grant-guardrail-plan.md
|
|
11
|
+
module GrantDiagnosis
|
|
12
|
+
class << self
|
|
13
|
+
# Proven, or nil. Never guesses.
|
|
14
|
+
def verdict_for(grant)
|
|
15
|
+
return nil if orphaned?(grant)
|
|
16
|
+
|
|
17
|
+
role = grant.role
|
|
18
|
+
return nil if role.nil? || role.full_access?
|
|
19
|
+
|
|
20
|
+
keys = persisted_keys(role)
|
|
21
|
+
# Checked BEFORE the catalog guard: a role with nothing ticked can never
|
|
22
|
+
# match whatever the catalog says, so deferring to "no verdict" here
|
|
23
|
+
# would downgrade a proven finding to an advisory.
|
|
24
|
+
return :no_permissions if keys.empty?
|
|
25
|
+
|
|
26
|
+
# An empty catalog means routes are not derived yet, not that every key
|
|
27
|
+
# is dead. Only the unrouted claim depends on it.
|
|
28
|
+
return nil if CurrentScope.catalog.keys.empty?
|
|
29
|
+
return :unrouted_permissions if keys.none? { |key| live?(key, resource_class(grant)) }
|
|
30
|
+
|
|
31
|
+
nil
|
|
32
|
+
rescue NameError, ActiveRecord::ActiveRecordError => e
|
|
33
|
+
raise if e.instance_of?(NoMethodError)
|
|
34
|
+
|
|
35
|
+
log_degrade(e)
|
|
36
|
+
nil
|
|
37
|
+
end
|
|
38
|
+
|
|
39
|
+
# `verdict:` lets a caller that already has it skip the recompute (three
|
|
40
|
+
# role_permissions plucks per grant otherwise).
|
|
41
|
+
def type_untargeted?(grant, verdict: :__unset)
|
|
42
|
+
verdict = verdict_for(grant) if verdict == :__unset
|
|
43
|
+
return false unless verdict.nil?
|
|
44
|
+
return false if orphaned?(grant)
|
|
45
|
+
|
|
46
|
+
role = grant.role
|
|
47
|
+
return false if role.nil? || role.full_access?
|
|
48
|
+
|
|
49
|
+
return false unless ensure_models_loaded!
|
|
50
|
+
|
|
51
|
+
klass = resource_class(grant)
|
|
52
|
+
return false if klass.nil? # unresolvable class is #90's inert, not ours
|
|
53
|
+
|
|
54
|
+
keys = persisted_keys(role)
|
|
55
|
+
return false if keys.any? { |key| routed?(key) && targets_any_route_key?(key, klass) }
|
|
56
|
+
|
|
57
|
+
# #108: a grant on a PARENT legitimately reaches its children, so stay
|
|
58
|
+
# silent when any declared chain reaches this class.
|
|
59
|
+
!reachable_through_declared_chain?(klass, keys)
|
|
60
|
+
rescue NameError, ActiveRecord::ActiveRecordError => e
|
|
61
|
+
raise if e.instance_of?(NoMethodError)
|
|
62
|
+
|
|
63
|
+
log_degrade(e)
|
|
64
|
+
false
|
|
65
|
+
end
|
|
66
|
+
|
|
67
|
+
# The advisory's caveat, centralised for the same reason the proven wording
|
|
68
|
+
# is: the console and the CLI had drifted into two versions of it.
|
|
69
|
+
def untargeted_caveat
|
|
70
|
+
"This is NOT a verdict. Only your current_scope_record hooks decide which " \
|
|
71
|
+
"records a controller resolves to, and that is not knowable statically. A " \
|
|
72
|
+
"controller serving this type under a different name is a false alarm here. " \
|
|
73
|
+
"Check the hook before removing anything."
|
|
74
|
+
end
|
|
75
|
+
|
|
76
|
+
# One wording, so the task and the view cannot drift.
|
|
77
|
+
def verdict_label(verdict)
|
|
78
|
+
case verdict
|
|
79
|
+
when :no_permissions then "role ticks no permissions"
|
|
80
|
+
when :unrouted_permissions then "role ticks only unrouted keys"
|
|
81
|
+
end
|
|
82
|
+
end
|
|
83
|
+
|
|
84
|
+
def verdict_fix(verdict)
|
|
85
|
+
case verdict
|
|
86
|
+
when :no_permissions
|
|
87
|
+
"Tick at least one permission on this role, or remove the grant."
|
|
88
|
+
when :unrouted_permissions
|
|
89
|
+
"Every key on this role is absent from the route-derived catalog, so " \
|
|
90
|
+
"nothing can ever gate them. Re-tick the role against current routes."
|
|
91
|
+
end
|
|
92
|
+
end
|
|
93
|
+
|
|
94
|
+
private
|
|
95
|
+
|
|
96
|
+
# #90's state, not ours — two badges naming different remedies on one
|
|
97
|
+
# grant is the confusion this feature exists to end.
|
|
98
|
+
# Never the STAGED keys Role#permission_keys returns after a failed save.
|
|
99
|
+
def persisted_keys(role)
|
|
100
|
+
role.role_permissions.pluck(:permission_key)
|
|
101
|
+
end
|
|
102
|
+
|
|
103
|
+
def log_degrade(error)
|
|
104
|
+
Rails.logger&.warn(
|
|
105
|
+
"[CurrentScope] GrantDiagnosis could not judge a scoped grant " \
|
|
106
|
+
"(#{error.class}: #{error.message}); reporting no finding for it."
|
|
107
|
+
)
|
|
108
|
+
end
|
|
109
|
+
|
|
110
|
+
def orphaned?(grant)
|
|
111
|
+
grant.respond_to?(:orphaned_resource?) && grant.orphaned_resource?
|
|
112
|
+
end
|
|
113
|
+
|
|
114
|
+
# A polymorphic grant stores the STI BASE class, but the routed controller
|
|
115
|
+
# is named after the SUBCLASS — so a grant on an Invoice (stored
|
|
116
|
+
# "Document") whose role ticks "invoices#show" WORKS and must not be
|
|
117
|
+
# flagged. Check every route key in the hierarchy.
|
|
118
|
+
def targets_any_route_key?(key, klass)
|
|
119
|
+
([ klass ] + klass.descendants).any? do |candidate|
|
|
120
|
+
targets_route_key?(key, candidate.model_name.route_key)
|
|
121
|
+
end
|
|
122
|
+
rescue StandardError
|
|
123
|
+
true # unknown hierarchy: stay silent, never flag on a guess
|
|
124
|
+
end
|
|
125
|
+
|
|
126
|
+
# declared_names fills as model classes LOAD. With eager loading off (the
|
|
127
|
+
# default in development, where this task is run) the chain lookup is
|
|
128
|
+
# blind and would flag the very #108 grants it exists to protect — and
|
|
129
|
+
# order-dependently, since an earlier row could load the model. Force the
|
|
130
|
+
# load once.
|
|
131
|
+
# Returns false when the registry may be incomplete, so the caller stays
|
|
132
|
+
# silent rather than negating a partial answer into an advisory. Latches
|
|
133
|
+
# only on success, so a transient failure can retry.
|
|
134
|
+
def ensure_models_loaded!
|
|
135
|
+
return true if @models_loaded
|
|
136
|
+
|
|
137
|
+
Rails.application.eager_load! unless Rails.application.config.eager_load
|
|
138
|
+
@models_loaded = true
|
|
139
|
+
rescue StandardError => e
|
|
140
|
+
log_degrade(e)
|
|
141
|
+
false
|
|
142
|
+
end
|
|
143
|
+
|
|
144
|
+
# Walks the same declared reflections the resolver walks, so the two
|
|
145
|
+
# cannot disagree about what a chain reaches.
|
|
146
|
+
def reachable_through_declared_chain?(klass, keys)
|
|
147
|
+
CurrentScope::ParentChain.declared_names.any? do |name|
|
|
148
|
+
child = name.safe_constantize
|
|
149
|
+
next false if child.nil?
|
|
150
|
+
next false unless keys.any? { |key| routed?(key) && targets_any_route_key?(key, child) }
|
|
151
|
+
|
|
152
|
+
chain_reaches?(child, klass)
|
|
153
|
+
end
|
|
154
|
+
end
|
|
155
|
+
|
|
156
|
+
def chain_reaches?(child, klass)
|
|
157
|
+
current = child
|
|
158
|
+
CurrentScope::ParentChain::MAX_PARENT_DEPTH.times do
|
|
159
|
+
reflection = CurrentScope::ParentChain.reflection_for(current)
|
|
160
|
+
return false if reflection.nil?
|
|
161
|
+
return true if reflection.klass.base_class == klass.base_class
|
|
162
|
+
|
|
163
|
+
current = reflection.klass
|
|
164
|
+
end
|
|
165
|
+
false
|
|
166
|
+
end
|
|
167
|
+
|
|
168
|
+
def resource_class(grant)
|
|
169
|
+
klass = grant.resource_type&.safe_constantize
|
|
170
|
+
klass.respond_to?(:model_name) ? klass : nil
|
|
171
|
+
end
|
|
172
|
+
|
|
173
|
+
# Can this key ever open anything for THIS grant? Routed keys can. So can
|
|
174
|
+
# the catalog's injected break-glass key — but only on the rows it was
|
|
175
|
+
# injected onto, so the exemption is row-local: a bypass key for another
|
|
176
|
+
# type cannot lift anything here.
|
|
177
|
+
def live?(key, klass)
|
|
178
|
+
return true if CurrentScope.catalog.routed?(key)
|
|
179
|
+
|
|
180
|
+
return false unless injected?(key)
|
|
181
|
+
|
|
182
|
+
# A FULL sod_bypass_permission ("claims#bypass_sod") is the key the
|
|
183
|
+
# resolver checks for every record, so it is live on any grant and the
|
|
184
|
+
# row-local heuristic must not apply. Only a bare action is per-row.
|
|
185
|
+
configured = CurrentScope.config.sod_bypass_permission.to_s
|
|
186
|
+
return key == configured if configured.include?("#")
|
|
187
|
+
|
|
188
|
+
!klass.nil? && targets_any_route_key?(key, klass)
|
|
189
|
+
end
|
|
190
|
+
|
|
191
|
+
def injected?(key)
|
|
192
|
+
CurrentScope.catalog.include?(key) && !CurrentScope.catalog.routed?(key)
|
|
193
|
+
end
|
|
194
|
+
|
|
195
|
+
def routed?(key)
|
|
196
|
+
CurrentScope.catalog.routed?(key)
|
|
197
|
+
end
|
|
198
|
+
|
|
199
|
+
# Wider than CurrentScope.permission_key's comparison: a controller segment
|
|
200
|
+
# CONTAINING the route key counts, and both the namespaced and collapsed
|
|
201
|
+
# forms are tried. Biased toward false negatives on purpose. Pins:
|
|
202
|
+
# test/grant_diagnosis_test.rb (nested_reports, namespaced).
|
|
203
|
+
def targets_route_key?(key, route_key)
|
|
204
|
+
controller = key.split("#").first.to_s
|
|
205
|
+
# BOTH forms: a namespaced model keeps its namespace in the route key
|
|
206
|
+
# (Billing::Invoice -> "billing_invoices") while the controller path
|
|
207
|
+
# splits it ("billing/invoices"), so comparing only the last segment
|
|
208
|
+
# falsely flags the conventional Rails layout.
|
|
209
|
+
[ controller.tr("/", "_"), controller.split("/").last.to_s ].any? do |segment|
|
|
210
|
+
segment == route_key ||
|
|
211
|
+
segment.end_with?("_#{route_key}") || segment.start_with?("#{route_key}_")
|
|
212
|
+
end
|
|
213
|
+
end
|
|
214
|
+
end
|
|
215
|
+
end
|
|
216
|
+
end
|