current_scope 0.5.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (58) hide show
  1. checksums.yaml +7 -0
  2. data/MIT-LICENSE +20 -0
  3. data/README.md +377 -0
  4. data/Rakefile +6 -0
  5. data/app/assets/javascripts/current_scope/application.js +229 -0
  6. data/app/assets/stylesheets/current_scope/application.css +917 -0
  7. data/app/controllers/current_scope/application_controller.rb +96 -0
  8. data/app/controllers/current_scope/events_controller.rb +14 -0
  9. data/app/controllers/current_scope/role_assignments_controller.rb +190 -0
  10. data/app/controllers/current_scope/roles_controller.rb +256 -0
  11. data/app/controllers/current_scope/scoped_role_assignments_controller.rb +165 -0
  12. data/app/controllers/current_scope/subjects_controller.rb +75 -0
  13. data/app/helpers/current_scope/application_helper.rb +261 -0
  14. data/app/models/concerns/current_scope/storable_keys.rb +101 -0
  15. data/app/models/current_scope/application_record.rb +5 -0
  16. data/app/models/current_scope/current.rb +74 -0
  17. data/app/models/current_scope/event.rb +136 -0
  18. data/app/models/current_scope/role.rb +106 -0
  19. data/app/models/current_scope/role_assignment.rb +42 -0
  20. data/app/models/current_scope/role_permission.rb +9 -0
  21. data/app/models/current_scope/scoped_role_assignment.rb +98 -0
  22. data/app/views/current_scope/events/index.html.erb +41 -0
  23. data/app/views/current_scope/roles/edit.html.erb +229 -0
  24. data/app/views/current_scope/roles/index.html.erb +55 -0
  25. data/app/views/current_scope/roles/members.html.erb +114 -0
  26. data/app/views/current_scope/roles/new.html.erb +19 -0
  27. data/app/views/current_scope/scoped_role_assignments/new.html.erb +144 -0
  28. data/app/views/current_scope/shared/access_denied.html.erb +30 -0
  29. data/app/views/current_scope/subjects/index.html.erb +124 -0
  30. data/app/views/layouts/current_scope/application.html.erb +56 -0
  31. data/config/routes.rb +13 -0
  32. data/db/migrate/20260710000001_create_current_scope_tables.rb +31 -0
  33. data/db/migrate/20260710000002_create_current_scope_events.rb +32 -0
  34. data/db/migrate/20260714000001_add_description_to_current_scope_roles.rb +5 -0
  35. data/db/migrate/20260805000001_widen_current_scope_polymorphic_ids.rb +165 -0
  36. data/lib/current_scope/configuration.rb +613 -0
  37. data/lib/current_scope/context.rb +41 -0
  38. data/lib/current_scope/engine.rb +202 -0
  39. data/lib/current_scope/gating_reflection.rb +113 -0
  40. data/lib/current_scope/gating_tripwire.rb +84 -0
  41. data/lib/current_scope/grant_diagnosis.rb +216 -0
  42. data/lib/current_scope/guard.rb +750 -0
  43. data/lib/current_scope/mutation_guard.rb +90 -0
  44. data/lib/current_scope/parent_chain.rb +397 -0
  45. data/lib/current_scope/permission_catalog.rb +146 -0
  46. data/lib/current_scope/permission_grid.rb +132 -0
  47. data/lib/current_scope/permissions.rb +94 -0
  48. data/lib/current_scope/resolver.rb +669 -0
  49. data/lib/current_scope/schema_guard.rb +223 -0
  50. data/lib/current_scope/scopeable.rb +38 -0
  51. data/lib/current_scope/sod_preflight.rb +380 -0
  52. data/lib/current_scope/test_helpers.rb +53 -0
  53. data/lib/current_scope/version.rb +3 -0
  54. data/lib/current_scope.rb +432 -0
  55. data/lib/generators/current_scope/install/install_generator.rb +114 -0
  56. data/lib/generators/current_scope/install/templates/initializer.rb +175 -0
  57. data/lib/tasks/current_scope_tasks.rake +454 -0
  58. metadata +123 -0
@@ -0,0 +1,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