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,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
|