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,223 @@
1
+ module CurrentScope
2
+ # The #151 boot guard: does this database actually have the grant-column shape
3
+ # the fix requires, and may THIS command boot without it?
4
+ #
5
+ # Its own file rather than more of Engine, matching SodPreflight and
6
+ # ParentChain — Engine states when a check runs, the check itself lives here.
7
+ #
8
+ # #151 is fixed by a MIGRATION, and a gem upgrade does not run one. A host that
9
+ # bundles 0.5 and deploys without applying it keeps integer id columns and
10
+ # keeps the full escalation, silently, because every code path behaves
11
+ # correctly against whatever schema it is given. So check the schema itself and
12
+ # refuse to serve. This is the one check that cannot be a validation: the
13
+ # damage is in the column, not in the next write.
14
+ module SchemaGuard
15
+ def self.check!(allow_database_task: true)
16
+ return if allow_database_task && running_a_database_task?
17
+ # Escape hatch for tooling that must BOOT in order to migrate — our own
18
+ # bin/db does exactly that, because schema.rb cannot carry a MySQL
19
+ # collation. Deliberately an explicit opt-out and not a config flag: a host
20
+ # that sets this in production has chosen to run without the check.
21
+ return if ENV["CURRENT_SCOPE_SKIP_SCHEMA_CHECK"] == "1"
22
+
23
+ # Strict "1" on purpose — this switches OFF a security control, so it
24
+ # should be awkward to trip. But silence would be worse than strictness: a
25
+ # host who writes `=true` (which the gem's other env opt-out does accept)
26
+ # would otherwise believe the check was off while it was still armed.
27
+ if ENV.key?("CURRENT_SCOPE_SKIP_SCHEMA_CHECK")
28
+ Rails.logger&.warn(
29
+ "[CurrentScope] CURRENT_SCOPE_SKIP_SCHEMA_CHECK is set to " \
30
+ "#{ENV['CURRENT_SCOPE_SKIP_SCHEMA_CHECK'].inspect} and was IGNORED — the only " \
31
+ "value that disables the #151 schema check is the string \"1\"."
32
+ )
33
+ end
34
+
35
+ # BOTH halves of every grant predicate, not just the ids. The migration
36
+ # widens the id columns and then re-collates the type columns, and MySQL
37
+ # auto-commits each statement — so a migration that dies between the two
38
+ # leaves binary ids beside case-insensitive types, permanently. Checking
39
+ # only the ids blesses exactly that state, and a grant on `Widget#5` then
40
+ # matches a check for `WIDGET#5`. Same escalation, other column.
41
+ {
42
+ CurrentScope::RoleAssignment => { ids: %w[subject_id], types: %w[subject_type] },
43
+ CurrentScope::ScopedRoleAssignment => {
44
+ ids: %w[subject_id resource_id], types: %w[subject_type resource_type]
45
+ }
46
+ }.each do |model, groups|
47
+ next unless model.table_exists?
48
+
49
+ groups[:ids].each { |column| check_id_column!(model, column) }
50
+ # Type columns are varchar already; only their collation can be wrong.
51
+ groups[:types].each { |column| check_collation!(model, column) } if mysql?
52
+ end
53
+ rescue ActiveRecord::NoDatabaseError, ActiveRecord::ConnectionNotEstablished
54
+ # There is genuinely no database yet (a fresh checkout running db:create,
55
+ # a build step with no server). Nothing to judge, so stay quiet.
56
+ #
57
+ # Narrow ON PURPOSE. Rescuing ActiveRecordError broadly here would turn any
58
+ # transient error into a silent all-clear, which is a security guard
59
+ # failing open. Anything else propagates.
60
+ nil
61
+ end
62
+
63
+ def self.check_id_column!(model, column)
64
+ info = model.columns_hash[column]
65
+ return if info.nil?
66
+
67
+ if info.type.in?([ :string, :text ])
68
+ # STRING IS NOT ENOUGH — the width has to be right too. A varchar(32)
69
+ # passes "is it a string?" and then truncates every UUID written to it,
70
+ # which is the original collision with a different cause. A text column
71
+ # is unbounded and therefore wide enough.
72
+ if !info.limit.nil? && info.limit < CurrentScope::KEY_LIMIT
73
+ raise ConfigurationError,
74
+ "#{model.table_name}.#{column} holds #{info.limit} characters; CurrentScope " \
75
+ "needs #{CurrentScope::KEY_LIMIT}. A UUID is 36 and a narrower column would " \
76
+ "truncate it, so two keys sharing a prefix would name one record (#151). Run " \
77
+ "`bin/rails current_scope:repair_schema` to widen it."
78
+ end
79
+
80
+ if info.respond_to?(:null) && info.null
81
+ raise ConfigurationError,
82
+ "#{model.table_name}.#{column} allows NULL, so a grant may fail to name " \
83
+ "one record. Run `bin/rails current_scope:repair_schema` to restore the " \
84
+ "required NOT NULL constraint (#151)."
85
+ end
86
+
87
+ check_collation!(model, column) if mysql?
88
+ return
89
+ end
90
+
91
+ raise ConfigurationError,
92
+ "#{model.table_name}.#{column} is still #{info.type}. CurrentScope stores a " \
93
+ "record's primary key there, and an integer column silently truncates a " \
94
+ "UUID — two subjects collapse into one identity and one inherits the " \
95
+ "other's roles (#151). Run `bin/rails current_scope:install:migrations && " \
96
+ "bin/rails db:migrate` to widen it."
97
+ end
98
+
99
+ # Collation matters as much as type on MySQL: its default is case AND accent
100
+ # insensitive, so "ABC" and "abc" — or "jose" and "josé" — are the same
101
+ # record. A database built from schema.rb has the right column type and the
102
+ # wrong collation, which is the common case for a new app and for CI.
103
+ def self.check_collation!(model, column)
104
+ info = model.columns_hash[column]
105
+ return if info.nil?
106
+
107
+ # A security guard must not fail open. The column exists and we are on
108
+ # MySQL, so its collation should always be readable; if it is not, refuse
109
+ # rather than bless a column we cannot prove compares case-sensitively —
110
+ # the same fatal treatment roles_controller#candidate_key_as_text now gives
111
+ # missing collation.
112
+ if info.collation.nil?
113
+ raise ConfigurationError,
114
+ "#{model.table_name}.#{column}'s MySQL collation could not be read, so " \
115
+ "CurrentScope cannot prove \"ABC\" and \"abc\" are different records (#151). " \
116
+ "Run `bin/rails current_scope:repair_schema`."
117
+ end
118
+
119
+ if info.collation.end_with?("_bin")
120
+ # Binary, but not necessarily NO PAD. utf8mb4_bin is PAD SPACE, so it
121
+ # still compares "abc" equal to "abc " — a narrower relative of the same
122
+ # collision. The migration prefers utf8mb4_0900_bin for that reason and
123
+ # falls back only where the server (MySQL < 8.0.17) offers nothing
124
+ # better. Refusing to boot there would strand a host on a shortcoming
125
+ # they cannot fix, so say it plainly once instead.
126
+ # Every _bin collation EXCEPT the 0900 family is PAD SPACE, not just
127
+ # utf8mb4_bin — latin1_bin and friends fold trailing spaces too.
128
+ unless info.collation.start_with?("utf8mb4_0900")
129
+ Rails.logger&.warn(
130
+ "[CurrentScope] #{model.table_name}.#{column} uses #{info.collation}, which is " \
131
+ "case-sensitive but PAD SPACE — a key with a trailing space would still " \
132
+ "match one without. On MySQL 8.0.17+ run " \
133
+ "`bin/rails current_scope:repair_schema` to move to utf8mb4_0900_bin (#151)."
134
+ )
135
+ end
136
+ return
137
+ end
138
+
139
+ # NOT db:migrate. A database loaded from schema.rb has every migration
140
+ # version already stamped, so db:migrate finds nothing pending and prints
141
+ # nothing while the collation stays wrong — and schema.rb cannot carry a
142
+ # MySQL collation, so that is the normal state of a new app and of CI.
143
+ # Naming db:migrate here sent hosts to a command that could not work.
144
+ raise ConfigurationError,
145
+ "#{model.table_name}.#{column} uses the #{info.collation} collation, which " \
146
+ "is case and accent insensitive — \"ABC\" and \"abc\" would be the same " \
147
+ "record, so a grant on one reaches the other (#151). Run " \
148
+ "`bin/rails current_scope:repair_schema` to apply a binary collation " \
149
+ "(idempotent, and it works on a schema-loaded database where db:migrate " \
150
+ "has nothing pending)."
151
+ end
152
+
153
+ # The GRANT tables' connection, not ActiveRecord::Base's. The columns being
154
+ # judged come from RoleAssignment.columns_hash, so the adapter question has
155
+ # to be asked of the same database those columns live in — a host that puts
156
+ # the engine's tables on a second connection would otherwise be judged by the
157
+ # wrong server's collation rules.
158
+ def self.mysql?
159
+ CurrentScope.mysql?(CurrentScope::RoleAssignment.connection)
160
+ end
161
+
162
+ # Rake tasks that may BOOT even against an unrepaired schema.
163
+ #
164
+ # The check raises from after_initialize, which every Rails command runs —
165
+ # including `db:migrate`, the command its own error message tells the host to
166
+ # run. Without an exemption an upgrading host is stuck: the app refuses to
167
+ # boot and the repair refuses to run, for the same reason. `assets:` is the
168
+ # same trap one step less obvious — a deploy pipeline that precompiles before
169
+ # migrating dies before it ever reaches the fix.
170
+ #
171
+ # EXACT NAMES, not prefixes, and that distinction is load-bearing: matching
172
+ # `db:create` as a prefix also matches a host's `db:create_tenant`, and
173
+ # `db:migrate` matches `db:migrate_legacy_users`. Prefix matching turned an
174
+ # allow list back into the `db:` free-for-all it replaced. Namespaces that
175
+ # legitimately have children are listed separately, ending in a colon.
176
+ #
177
+ # Anything that serves traffic or runs host code — server, console, runner —
178
+ # is deliberately absent. db:setup, db:reset and db:prepare are present
179
+ # despite seeding: they are how a host REBUILDS a database, and refusing them
180
+ # would leave a broken schema with no way to replace it.
181
+ BOOT_EXEMPT_TASKS = %w[
182
+ db:create db:drop db:migrate db:rollback db:version db:prepare db:setup
183
+ db:reset db:abort_if_pending_migrations db:_dump
184
+ current_scope:install current_scope:repair_schema
185
+ ].freeze
186
+
187
+ # Namespaces whose children are all schema tooling (db:migrate:up,
188
+ # db:schema:load, assets:precompile, …).
189
+ BOOT_EXEMPT_NAMESPACES = %w[
190
+ db:migrate: db:schema: db:structure: db:test: db:environment:
191
+ current_scope:install: assets:
192
+ ].freeze
193
+
194
+ # …minus these, which the lists above would otherwise cover. A bare `db:seed`
195
+ # repairs nothing and runs host code, and seeds routinely create grants
196
+ # through this engine — on the pre-migration schema, exactly the writes that
197
+ # collapse two subjects into one. Prefixes here on purpose: db:seed:replant
198
+ # (which Rails ships) and a host's own db:seed_users are both host code, and
199
+ # over-refusing is the safe direction for a deny list.
200
+ BOOT_REFUSED_TASKS = %w[db:seed db:fixtures:].freeze
201
+
202
+ def self.running_a_database_task?
203
+ return false unless defined?(Rake) && Rake.respond_to?(:application)
204
+
205
+ # `app:` is how an engine's host tasks are namespaced from inside the
206
+ # engine (rails/tasks/engine.rake), so strip it once here instead of
207
+ # spelling every entry twice in both lists.
208
+ tasks = Rake.application.top_level_tasks.map { |task| task.delete_prefix("app:") }
209
+ return false if tasks.any? { |task| task.start_with?(*BOOT_REFUSED_TASKS) }
210
+
211
+ tasks.any? do |task|
212
+ BOOT_EXEMPT_TASKS.include?(task) || task.start_with?(*BOOT_EXEMPT_NAMESPACES)
213
+ end
214
+ rescue StandardError
215
+ # No rake application in scope (a server or console): not a database task.
216
+ false
217
+ end
218
+ private_class_method :check_id_column!, :check_collation!, :mysql?
219
+ # Public: Engine#validate_subject_key! asks the same "may this command boot
220
+ # without the schema?" question and reuses this rather than re-deriving it.
221
+ public_class_method :running_a_database_task?
222
+ end
223
+ end
@@ -0,0 +1,38 @@
1
+ module CurrentScope
2
+ # Include CurrentScope::Scopeable to make a model pickable in the scoped-role
3
+ # assignment UI's type dropdown. BROWSE-ONLY — it does NOT gate access: any
4
+ # model is still a valid GlobalID scoped-role target whether or not it opts in.
5
+ #
6
+ # The `included` hook self-registers the class by NAME (stored as a string and
7
+ # resolved lazily) so dev-mode class reloading never pins a stale constant;
8
+ # the engine rebuilds the registry on every to_prepare.
9
+ module Scopeable
10
+ extend ActiveSupport::Concern
11
+
12
+ included do
13
+ CurrentScope.register_scopeable(name) if name
14
+ end
15
+
16
+ # Default label for the picker. Defined as an ordinary instance method so a
17
+ # host that provides its own current_scope_label simply overrides it — the
18
+ # host class sits ahead of this module in the ancestor chain.
19
+ def current_scope_label
20
+ "#{model_name.human} ##{id}"
21
+ end
22
+
23
+ # OPTIONAL indexed search for the scoped-role picker. current_scope_label is
24
+ # a Ruby method with no backing column, so by default the picker scans the
25
+ # first 500 rows and filters the label in Ruby. Define this class method to
26
+ # search via indexed SQL instead — it receives the query term and must
27
+ # return an ActiveRecord::Relation:
28
+ #
29
+ # class Project < ApplicationRecord
30
+ # include CurrentScope::Scopeable
31
+ # def self.current_scope_searchable_scope(term)
32
+ # where("name ILIKE ?", "%#{term}%")
33
+ # end
34
+ # end
35
+ #
36
+ # No default is provided — the host knows which column is indexed.
37
+ end
38
+ end
@@ -0,0 +1,380 @@
1
+ module CurrentScope
2
+ # Finds, before traffic does, the separation-of-duties actions that are going
3
+ # to raise CurrentScope::ConfigurationError: an action listed in
4
+ # config.sod_actions reaching a model that defines no current_scope_initiator.
5
+ #
6
+ # The raise itself is correct and stays (a silently skipped veto is the hole
7
+ # #73/#74 exist to prevent). What was wrong is WHEN a host learns about it —
8
+ # per request, from real traffic, including in :report mode, which promises
9
+ # that nothing changes for users. This answers the same question as soon as
10
+ # the routes exist, where the fix is cheap. (#133)
11
+ #
12
+ # "As soon as the routes exist" is BOOT in an eager-loading environment
13
+ # (production and staging — where a bake runs) and the first request in
14
+ # development, whose route set is lazy. The engine initializer says why.
15
+ #
16
+ # ADVISORY AND PARTIAL BY CONSTRUCTION, for the reason #134 recorded: the
17
+ # record a member action decides about comes from current_scope_record, an
18
+ # instance method whose value exists only mid-request, so "which model does
19
+ # invoices#approve gate?" is not statically knowable. What IS declared is
20
+ # current_scope_model (#50) — the type a controller's collection actions deal
21
+ # in. Under Rails' resource conventions that is the type its member actions
22
+ # load too, which makes it a good signal and not a proof:
23
+ #
24
+ # false negative — a controller declaring no current_scope_model is never
25
+ # inspected, so its 500 still arrives with traffic. That
26
+ # gap is why the report-mode ledger row exists as well
27
+ # (Guard#diagnose_report_sod_initiator).
28
+ # false positive — a controller whose member action loads a DIFFERENT type
29
+ # than its collection lists gets named against the wrong
30
+ # model. Costs a look; it can never break an app.
31
+ #
32
+ # Both limits are stated in the output the operator reads, not in a guide they
33
+ # will not open — the rule GrantDiagnosis.untargeted_caveat set.
34
+ module SodPreflight
35
+ # One scan's answer, carried as a value instead of left on the module.
36
+ #
37
+ # Every honesty rule this feature has needed — "an empty list is not an
38
+ # all-clear", "a run that skipped checks under-reports", "a run that
39
+ # inspected nothing proves nothing" — is a question about ONE scan. Holding
40
+ # that on a singleton made the answers depend on call order, needed a
41
+ # fail-closed special case for "never run", and made the rake task capture a
42
+ # flag next to the call so 140 lines later it still meant the right run.
43
+ # A value has none of those problems: you cannot ask a result you do not
44
+ # have. (#133 review — cubic, ie-predictability, ie-architecture all landed
45
+ # on this seam.)
46
+ Result = Struct.new(:rows, :inspected, :in_scope, :skipped, keyword_init: true) do
47
+ # Some check could not be completed, so the list under-reports.
48
+ def degraded? = skipped.any?
49
+
50
+ # The list cannot be read as an all-clear: either a check failed, or there
51
+ # was nothing to read because no controller in scope declared a model.
52
+ def blind? = degraded? || (inspected.zero? && in_scope.positive?)
53
+
54
+ def any? = rows.any?
55
+ end
56
+
57
+ # The subject recorded when the walk itself failed, rather than one host
58
+ # controller or model. Paired with a NoMethodError it identifies a bug in
59
+ # this gem — every host call-out is wrapped by a helper and carries its own
60
+ # subject, so it can never produce this pair.
61
+ SCAN_ITSELF = "the scan itself".freeze
62
+
63
+ class << self
64
+ # Scan the routed SoD actions and return a Result. PURE: it reads routes,
65
+ # declarations and models, and returns; rendering and logging belong to the
66
+ # callers (warn! for the log, `rails current_scope:report` for stdout), so
67
+ # scanning twice cannot emit twice.
68
+ #
69
+ # Free for the default config: sod_actions is [] until a host opts in, and
70
+ # nothing here touches a controller until it is not.
71
+ def scan
72
+ rows = []
73
+ skipped = []
74
+ inspected = 0
75
+ in_scope = 0
76
+
77
+ unless CurrentScope.config.sod_actions.empty?
78
+ begin
79
+ reflection = CurrentScope::GatingReflection.new
80
+ models = {}
81
+
82
+ sod_permissions.each do |controller, permission|
83
+ in_scope += 1
84
+ model = models.fetch(controller) {
85
+ models[controller] = declared_model_for(controller, reflection, skipped)
86
+ }
87
+ next if model.nil?
88
+
89
+ inspected += 1
90
+ next if defines_initiator?(model, skipped)
91
+
92
+ rows << [ permission, model ]
93
+ end
94
+ rescue StandardError => e
95
+ # AN ADVISORY MUST NEVER BE THE THING THAT BREAKS A BOOT, and that
96
+ # includes when the bug is OURS.
97
+ #
98
+ # This used to re-raise NoMethodError, copying GrantDiagnosis on the
99
+ # theory that host failures are absorbed by the two helpers below, so
100
+ # one arriving here can only be a gem bug worth surfacing. The
101
+ # reasoning holds; the CONSEQUENCE did not survive review. That
102
+ # sibling is only ever called from a rake task and a console view.
103
+ # This module is called from an engine initializer, so re-raising
104
+ # took a host's boot down — over a diagnostic — and aborted
105
+ # `current_scope:report`, the survey they run mid-rollout. The
106
+ # convention was borrowed from a module that never runs at boot.
107
+ #
108
+ # The distinction it was protecting is kept without the crash: a
109
+ # NoMethodError whose subject is the scan itself is reported as OUR
110
+ # bug rather than blamed on host config (see skip_summary), because
111
+ # every call into host code is wrapped by the helpers and carries the
112
+ # controller or model as its subject. Loud, attributed, and
113
+ # survivable. (#133 — Devin, PR #141)
114
+ #
115
+ # KEEP WHAT WAS FOUND. `rows` is built outside the begin precisely so
116
+ # a failure partway through cannot discard the findings already
117
+ # collected — in a large app one bad controller would otherwise hide
118
+ # every earlier SoD miss from both surfaces, which is the exact case
119
+ # this check exists to surface. The run is still marked as skipped,
120
+ # so an incomplete list never reads as a clean one. (#133 — cubic)
121
+ skipped << [ SCAN_ITSELF, e ]
122
+ end
123
+ end
124
+
125
+ Result.new(rows: rows, inspected: inspected, in_scope: in_scope, skipped: skipped)
126
+ end
127
+
128
+ # One message listing every action that will raise, plus the coverage
129
+ # behind an empty list. Log-only.
130
+ def warn!(result = scan)
131
+ skips = skip_summary(result)
132
+ Rails.logger&.warn(skips) if skips
133
+
134
+ if result.rows.empty?
135
+ return unless result.blind?
136
+
137
+ return Rails.logger&.warn(blind_message(result))
138
+ end
139
+
140
+ listed = result.rows.map { |permission, model|
141
+ " #{permission} — #{model.name} defines no #{Resolver::INITIATOR_METHOD}"
142
+ }
143
+
144
+ Rails.logger&.warn(
145
+ "[CurrentScope] separation-of-duties preflight: #{result.rows.size} routed action(s) " \
146
+ "will raise CurrentScope::ConfigurationError on the first request that reaches them — " \
147
+ "in config.enforcement = :report exactly as in :enforce.\n" \
148
+ "#{listed.join("\n")}\n" \
149
+ "#{fix_line}\n#{caveat}"
150
+ )
151
+ end
152
+
153
+ # The limits of the answer, in the output rather than in a guide. Shared by
154
+ # the boot warning and `rails current_scope:report` so the two cannot drift
155
+ # into different versions of the same hedge (the drift #134 fixed for the
156
+ # untargeted-grant caveat).
157
+ # LINE-BROKEN on purpose. All five limits are real and two were added by
158
+ # review, so the answer to "this is a wall" is never fewer limits — it is
159
+ # structure. An unread caveat protects nobody, and this one is the only
160
+ # thing standing between a fallible list and a host deleting a four-eyes
161
+ # control. Its sibling GrantDiagnosis.untargeted_caveat is short for the
162
+ # same reason. (#133 review)
163
+ def caveat
164
+ [
165
+ "This list is PARTIAL — a lead, not a verdict. Confirm against the model before " \
166
+ "you change config.sod_actions.",
167
+ " Silent when: the controller declares no current_scope_model (ABSENT is not " \
168
+ "cleared), or the model cannot be instantiated right now (no database connection " \
169
+ "yet, a custom initialize).",
170
+ " Names the wrong thing when: the declared type is what the COLLECTION lists and " \
171
+ "a member action loads a different one; or current_scope_model branches on " \
172
+ "action_name, which is read here with no action in hand.",
173
+ " Cannot happen at all: a finding against an action that turns out to be a " \
174
+ "COLLECTION action — the veto never reaches those."
175
+ ].join("\n")
176
+ end
177
+
178
+ # The two remedies are NOT coequal here, and this message must not present
179
+ # them as if they were. Defining the hook restores a control; removing the
180
+ # action from config.sod_actions DELETES a fraud control. On the raise in
181
+ # Resolver#sod_decision the cause is proven, so offering both plainly is
182
+ # right. This list can be wrong — it reads a declaration, not the record —
183
+ # so leading with "or just turn the veto off" invites a host to disable
184
+ # four-eyes on a false accusation. Lead with the hook; qualify the rest.
185
+ #
186
+ # PUBLIC beside #caveat, and for the identical reason: `rails
187
+ # current_scope:report` renders the SAME finding, so a private copy here
188
+ # guaranteed the correction reached one surface and not the other. It did
189
+ # exactly that for one commit. (#133 review)
190
+ def fix_line
191
+ "Fix: define #{Resolver::INITIATOR_METHOD} on each model listed (return nil to exempt a " \
192
+ "record). Only if the action was never meant to be four-eyes gated should you remove it " \
193
+ "from config.sod_actions — that removes the control rather than wiring it, so confirm " \
194
+ "the finding first."
195
+ end
196
+
197
+ # One line per run, naming the count and the distinct causes — never one
198
+ # line per controller. The failure this class expects most is a
199
+ # current_scope_model hook that reads request-scoped state, and that one
200
+ # fails for EVERY affected controller on every routes load; a per-row line
201
+ # would flood exactly the host it is trying to help. Every sibling
202
+ # diagnostic in this engine throttles for the same reason
203
+ # (Guard.warn_ledger_failure_once, Event.warn_missing_events_table_once,
204
+ # the cross-controller nudge's once-per-site set).
205
+ #
206
+ # Broken controllers are called out separately because their fix differs:
207
+ # the controller does not LOAD, which the gate will hit too. Derived from
208
+ # the error class, not carried as a flag — a NameError can only arrive from
209
+ # inside a controller's own body, because a missing controller CONSTANT
210
+ # became MissingController and returned nil earlier. That is the
211
+ # distinction GatingReflection#controller_class exists to keep.
212
+ #
213
+ # Returns the string (nil when nothing was skipped) rather than logging, so
214
+ # `rails current_scope:report` can render the same facts to stdout — the
215
+ # operator reading a terminal must not be told to go and find a log.
216
+ def skip_summary(result)
217
+ return nil if result.skipped.empty?
218
+
219
+ # NameError is a subclass of NoMethodError's parent, so test the gem-bug
220
+ # pair FIRST: only the scan-itself subject can carry one, and calling
221
+ # that a broken controller would send a host after their own code for a
222
+ # bug in ours.
223
+ ours = result.skipped.select { |subject, error|
224
+ subject == SCAN_ITSELF && error.instance_of?(NoMethodError)
225
+ }
226
+ broken = result.skipped.select { |subject, error|
227
+ error.is_a?(NameError) && subject != SCAN_ITSELF
228
+ }
229
+ detail = result.skipped.map { |subject, error| "#{subject} (#{error.class})" }.uniq.join(", ")
230
+
231
+ message = +"[CurrentScope] SodPreflight skipped #{result.skipped.size} check(s), so its " \
232
+ "list is INCOMPLETE — absence below is not an all-clear: #{detail}."
233
+ if ours.any?
234
+ message << " That NoMethodError came from the scan itself, not from your app — it is " \
235
+ "a bug in current_scope. Please report it; nothing in your configuration " \
236
+ "needs changing for it."
237
+ end
238
+ if broken.any?
239
+ message << " #{broken.size} of those did not LOAD (#{broken.map(&:first).uniq.join(', ')}) " \
240
+ "— that is a broken controller, not a missing declaration, and the gate will " \
241
+ "fail there too."
242
+ end
243
+ message
244
+ end
245
+
246
+ private
247
+
248
+ def blind_message(result)
249
+ reason =
250
+ # DEGRADED WINS, and the order is the whole finding. Both conditions
251
+ # are true when declared_model_for fails for every controller —
252
+ # `inspected` only counts a successfully READ declaration, so a run
253
+ # whose checks all blew up looks identical to one that found nothing
254
+ # to read. Leading with "none of those controllers declares
255
+ # current_scope_model" then sends an operator off to add declarations
256
+ # when their hook is raising. (#133 — qodo, PR #141)
257
+ if result.degraded?
258
+ "it was not able to look properly (some checks were skipped; see the lines above)"
259
+ elsif result.inspected.zero? && result.in_scope.positive?
260
+ "it inspected NONE of the #{result.in_scope} routed SoD action(s) — none of those " \
261
+ "controllers declares current_scope_model, so there was nothing to read"
262
+ else
263
+ "it was not able to look properly"
264
+ end
265
+
266
+ "[CurrentScope] separation-of-duties preflight found nothing, and #{reason}. Do NOT " \
267
+ "read that as an all-clear. Re-run `rails current_scope:report` once the app is fully " \
268
+ "up.\n#{caveat}"
269
+ end
270
+
271
+ # [[controller_path, permission], ...] for every ROUTED SoD action.
272
+ #
273
+ # routed?, not include?: the catalog also injects the break-glass key, and
274
+ # an injected key gates no request, so it can never raise. (The bypass
275
+ # permission is barred from sod_actions by Configuration#validate! anyway;
276
+ # asking the catalog costs one set lookup and removes the question.)
277
+ def sod_permissions
278
+ actions = CurrentScope.config.sod_actions
279
+ catalog = CurrentScope.catalog
280
+
281
+ catalog.grouped.flat_map do |controller, controller_actions|
282
+ (controller_actions & actions).filter_map do |action|
283
+ key = "#{controller}##{action}"
284
+ [ controller, key ] if catalog.routed?(key)
285
+ end
286
+ end
287
+ end
288
+
289
+ # The type this controller declared, or nil when it declared none, nothing
290
+ # is routed there, or asking failed. Mirrors Guard#resolve_current_scope_model
291
+ # — same private hook, same respond_to?(…, true) discovery — because a
292
+ # second spelling of "what did the host declare?" is a second spelling that
293
+ # drifts.
294
+ def declared_model_for(controller_path, reflection, skipped)
295
+ klass = reflection.controller_class(controller_path)
296
+ return nil if klass.nil?
297
+
298
+ instance = klass.new
299
+ return nil unless instance.respond_to?(:current_scope_model, true)
300
+
301
+ model = instance.send(:current_scope_model)
302
+ # ASK the resolver for the shape rule rather than re-spelling its three
303
+ # terms here: a concrete ActiveRecord class. Anything else cannot be the
304
+ # record an SoD veto measures, so there is nothing to prove and this
305
+ # stays silent — a mis-declared type is :model_invalid's job, not this
306
+ # one's. (An inline copy is the #74 re-derivation defect, and this file
307
+ # already avoids it for the initiator check.)
308
+ return nil unless CurrentScope.resolver.collection_type?(model)
309
+
310
+ model
311
+ rescue StandardError => e
312
+ # HOST CODE RUNS HERE — an instance method, on a controller built outside
313
+ # a request. A hook that reads params is the likeliest failure and must
314
+ # be absorbed, never raised: this is an advisory.
315
+ #
316
+ # A NameError is called out separately because GatingReflection was made
317
+ # public precisely to preserve Rails' triage — a missing controller
318
+ # constant became MissingController (already nil above), so a NameError
319
+ # arriving HERE came from inside the controller's own body. Folding that
320
+ # into the same "could not inspect" line as a params-reading hook would
321
+ # report a broken controller exactly like a fine one, which is the
322
+ # distinction that reflection exists to keep. (#133 review)
323
+ skipped << [ controller_path, e ]
324
+ nil
325
+ end
326
+
327
+ # ASKS an instance the same question the resolver asks the record, rather
328
+ # than re-deriving it as method_defined?: respond_to?(…, true) also honours
329
+ # protected/private definitions and respond_to_missing?, and re-deriving a
330
+ # condition another component owns is the defect this codebase keeps paying
331
+ # for (#74).
332
+ #
333
+ # When an instance IS built (only for a candidate finding, see below),
334
+ # Model.new issues no QUERY but is not connection-free: the first
335
+ # instantiation loads the schema to build the attribute set. On a boot with
336
+ # no reachable database (asset precompile, a container started before its
337
+ # DB) that raises, the model reads as compliant, and the list is quieter
338
+ # for a reason the operator cannot see — which is why the caveat names it
339
+ # and the Result reports it as skipped.
340
+ # CHEAP CHECK FIRST, exact check only before accusing.
341
+ #
342
+ # The resolver asks `record.respond_to?(INITIATOR_METHOD, true)`, and
343
+ # matching it exactly would mean instantiating every declared model at
344
+ # boot — running whatever the host put in `initialize` and
345
+ # `after_initialize`, for models that are almost all fine. So ask the CLASS
346
+ # first: for an ordinary `def`, an `alias`, or an association reader (all
347
+ # defined as real methods when the class body runs) the class-level answer
348
+ # and the instance-level answer are identical, and the overwhelming
349
+ # majority of models stop here having executed no host code at all.
350
+ #
351
+ # They diverge only for a hook reachable through respond_to_missing? /
352
+ # method_missing. That divergence can only produce a FALSE ACCUSATION —
353
+ # the class says "not defined" when an instance would say otherwise — so
354
+ # the instance check is kept exactly there, as the confirmation before this
355
+ # module names a model. A model that is fine is never built; a model about
356
+ # to be flagged is, because an accusation must be right. (#133 review —
357
+ # cubic)
358
+ def defines_initiator?(model, skipped)
359
+ return true if class_level_initiator?(model)
360
+
361
+ model.new.respond_to?(Resolver::INITIATOR_METHOD, true)
362
+ rescue StandardError => e
363
+ # Could not tell (a custom initialize, an after_initialize that needs a
364
+ # request, no database connection yet) ⇒ say nothing. Prove or stay
365
+ # silent, and mark the run so its silence is not read as a clean bill.
366
+ skipped << [ model.name || model.inspect, e ]
367
+ true
368
+ end
369
+
370
+ # All three visibilities, because the resolver's respond_to?(…, true) sees
371
+ # all three and a public-only check would flag a model whose hook is
372
+ # private — which is how the engine's own docs suggest writing host hooks.
373
+ def class_level_initiator?(model)
374
+ m = Resolver::INITIATOR_METHOD
375
+ model.method_defined?(m) || model.private_method_defined?(m) ||
376
+ model.protected_method_defined?(m)
377
+ end
378
+ end
379
+ end
380
+ end