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,750 @@
|
|
|
1
|
+
module CurrentScope
|
|
2
|
+
# The enforcement point. Include after Context to gate every action behind
|
|
3
|
+
# its own permission: the current controller#action IS the permission key,
|
|
4
|
+
# so new controllers are gated (fail-closed) the moment they exist.
|
|
5
|
+
#
|
|
6
|
+
# Any controller whose actions take part in record-level decisions (scoped
|
|
7
|
+
# roles, SoD) declares a private current_scope_record method returning the
|
|
8
|
+
# record. Three rules for the hook:
|
|
9
|
+
# - it runs for EVERY gated action, collection actions included — return
|
|
10
|
+
# nil when there is no record
|
|
11
|
+
# - it runs BEFORE the controller's own before_actions, so it must load
|
|
12
|
+
# the record itself (memoize so set_* callbacks reuse it)
|
|
13
|
+
# - key off request.path_parameters, NEVER params: a query-string ?id=
|
|
14
|
+
# must not let a scoped role on one record unlock a collection action
|
|
15
|
+
#
|
|
16
|
+
# def current_scope_record
|
|
17
|
+
# set_report if request.path_parameters[:id]
|
|
18
|
+
# end
|
|
19
|
+
#
|
|
20
|
+
# The hook is a DECLARATION, and the gate reads it as one. Returning nil says
|
|
21
|
+
# "this action has no record" — that is what lets a subject holding only
|
|
22
|
+
# scoped grants through a collection gate, with scope_for narrowing the list
|
|
23
|
+
# (#19). Declaring no hook at all says nothing, so the gate assumes nothing
|
|
24
|
+
# and scoped grants cannot open it (NO_RECORD below) — otherwise a controller
|
|
25
|
+
# that simply forgot the hook would hand a scoped subject every record of its
|
|
26
|
+
# type. Nothing is lost by silence: without a hook, scoped grants could never
|
|
27
|
+
# open a collection gate anyway. A collection-only controller that wants them
|
|
28
|
+
# to says so in one line:
|
|
29
|
+
#
|
|
30
|
+
# def current_scope_record = nil
|
|
31
|
+
#
|
|
32
|
+
# A controller may ALSO declare a private current_scope_model naming the type
|
|
33
|
+
# its collection actions deal in:
|
|
34
|
+
#
|
|
35
|
+
# def current_scope_model = Report
|
|
36
|
+
#
|
|
37
|
+
# Same discovery rules as current_scope_record (private, fixed name,
|
|
38
|
+
# optional). The Guard threads it to the resolver so the record-less scoped
|
|
39
|
+
# branch can bind to that type instead of matching a scoped grant on ANY
|
|
40
|
+
# type (#50); absent means the type is unknown. A plain method, so a host
|
|
41
|
+
# may branch on action_name for a per-action answer.
|
|
42
|
+
#
|
|
43
|
+
# The declaration is TRUSTED, like current_scope_record: since #65 a listed
|
|
44
|
+
# collection-read gate derives its answer from the declared type's scoped
|
|
45
|
+
# list — full_access included — so declaring the WRONG type opens this
|
|
46
|
+
# controller's reads to subjects holding scoped full_access grants of that
|
|
47
|
+
# type. Review the declaration the way you review the record hook.
|
|
48
|
+
#
|
|
49
|
+
# The two hooks PAIR, they don't substitute: current_scope_model WITHOUT
|
|
50
|
+
# current_scope_record is inert, because declaring no record hook passes
|
|
51
|
+
# NO_RECORD (below) and the record-less branch never runs — the declared
|
|
52
|
+
# type is never consulted. A collection controller opting scoped grants in
|
|
53
|
+
# declares BOTH: `def current_scope_record = nil` plus the model.
|
|
54
|
+
#
|
|
55
|
+
# Skip the gate for public endpoints with skip_before_action :current_scope_check!
|
|
56
|
+
# or, preferably, current_scope_skip_gate!(reason: "…") so the role grid can
|
|
57
|
+
# show declared intent instead of an unexplained "gate not run" badge (#76).
|
|
58
|
+
# MutationGuard (included here) adds the read-only-while-impersonating gate as
|
|
59
|
+
# its OWN before_action, so it runs first and survives that skip.
|
|
60
|
+
module Guard
|
|
61
|
+
extend ActiveSupport::Concern
|
|
62
|
+
include MutationGuard
|
|
63
|
+
|
|
64
|
+
# Inheritable whole-controller skip reason from current_scope_skip_gate!.
|
|
65
|
+
# nil means no declared reason (bare skip_before_action or never included).
|
|
66
|
+
class_methods do
|
|
67
|
+
# Prefer the macro over bare skip_before_action so the grid can show
|
|
68
|
+
# "skipped: …" instead of the alarming unexplained badge (#76 / plan 030).
|
|
69
|
+
# only:/except: still perform the skip; the stored reason is the
|
|
70
|
+
# whole-controller annotation when those are absent (row-level grid today).
|
|
71
|
+
def current_scope_skip_gate!(reason:, **options)
|
|
72
|
+
text = reason.to_s.strip
|
|
73
|
+
raise ArgumentError, "current_scope_skip_gate! requires a non-blank reason:" if text.empty?
|
|
74
|
+
|
|
75
|
+
# Whole-controller only: only:/except: stays unprovable at row level
|
|
76
|
+
# (KTD-3), so we do not claim the entire controller was deliberately
|
|
77
|
+
# opened. The skip still runs.
|
|
78
|
+
if options[:only].nil? && options[:except].nil?
|
|
79
|
+
self.current_scope_gate_skip_reason = text
|
|
80
|
+
end
|
|
81
|
+
|
|
82
|
+
skip_before_action :current_scope_check!, raise: false, **options
|
|
83
|
+
end
|
|
84
|
+
end
|
|
85
|
+
|
|
86
|
+
# "This controller never said whether there is a record here." Passed to the
|
|
87
|
+
# resolver instead of nil when the controller declares no
|
|
88
|
+
# current_scope_record hook at all.
|
|
89
|
+
#
|
|
90
|
+
# The distinction matters because the resolver honors a scoped grant on a
|
|
91
|
+
# record-less target — that is how a scoped-only subject reaches their index
|
|
92
|
+
# (#19). A declared hook returning nil is the host stating "there is no
|
|
93
|
+
# record here", which is exactly what the contract above asks for, and the
|
|
94
|
+
# resolver can trust it. No hook is not that statement: it is silence, and
|
|
95
|
+
# reading silence as "collection action" lets a controller with member
|
|
96
|
+
# actions hand a scoped subject every record of its type — strictly worse
|
|
97
|
+
# than the 403 it gave before this path existed.
|
|
98
|
+
#
|
|
99
|
+
# Neither nil nor a Class, so the resolver's record-less branch skips it and
|
|
100
|
+
# the decision falls to deny. Org-wide and full_access are unaffected — they
|
|
101
|
+
# never read the record — so silence costs a host nothing it had before:
|
|
102
|
+
# scoped grants could never open a collection gate anyway. Declaring the
|
|
103
|
+
# hook is how you opt in.
|
|
104
|
+
NO_RECORD = Object.new.freeze
|
|
105
|
+
|
|
106
|
+
class << self
|
|
107
|
+
# Warn-once latch for a failed would-be-denial recording, mirroring
|
|
108
|
+
# Event.warn_missing_events_table_once. Lives on the module, not the
|
|
109
|
+
# controller: the failure is per-process (a missing table, a dead
|
|
110
|
+
# connection), so per-instance state would warn once per request and
|
|
111
|
+
# defeat the point.
|
|
112
|
+
#
|
|
113
|
+
# ponytail: a plain ivar, not a Mutex. Worst case under a race is a second
|
|
114
|
+
# warning line — the thing being prevented is a flood, not a duplicate.
|
|
115
|
+
def ledger_warning_emitted? = @ledger_warning_emitted
|
|
116
|
+
def ledger_warning_emitted! = @ledger_warning_emitted = true
|
|
117
|
+
|
|
118
|
+
# Test seam: the latch would otherwise leak across examples, silently
|
|
119
|
+
# disarming the warning for every test after the first and making the
|
|
120
|
+
# suite order-dependent.
|
|
121
|
+
def reset_ledger_warning! = @ledger_warning_emitted = false
|
|
122
|
+
end
|
|
123
|
+
|
|
124
|
+
included do
|
|
125
|
+
# class_attribute so a child inherits a parent's declared reason (#62 shape).
|
|
126
|
+
class_attribute :current_scope_gate_skip_reason, instance_accessor: false, default: nil
|
|
127
|
+
before_action :current_scope_check!
|
|
128
|
+
end
|
|
129
|
+
|
|
130
|
+
private
|
|
131
|
+
|
|
132
|
+
def current_scope_check!
|
|
133
|
+
# Record that the gate ran, so an optional GatingTripwire (A4) can tell a
|
|
134
|
+
# gated action from one on a controller that never included Guard.
|
|
135
|
+
@current_scope_checked = true
|
|
136
|
+
permission = "#{controller_path}##{action_name}"
|
|
137
|
+
|
|
138
|
+
# An excluded controller can never be granted in the grid, so gating it
|
|
139
|
+
# would lock it to full_access forever — a misconfiguration, not a deny.
|
|
140
|
+
# Name excluded-vs-unrouted and the matching regex so the fix is obvious
|
|
141
|
+
# (#44); "not in the catalog" alone hid which of the two was true.
|
|
142
|
+
unless CurrentScope.catalog.include?(permission)
|
|
143
|
+
raise CurrentScope::ConfigurationError, catalog_miss_message(permission)
|
|
144
|
+
end
|
|
145
|
+
|
|
146
|
+
record = resolve_current_scope_record
|
|
147
|
+
model = resolve_current_scope_model
|
|
148
|
+
|
|
149
|
+
# Stash the declared type for the advisory path (allowed_to? in a view),
|
|
150
|
+
# keyed to THIS controller so a cross-controller question can't borrow it
|
|
151
|
+
# (#50, KTD-6). Additive — the gate decision below reads `model` directly,
|
|
152
|
+
# not the ambient copy.
|
|
153
|
+
#
|
|
154
|
+
# NOT when the record hook is absent (NO_RECORD): the gate skips the
|
|
155
|
+
# record-less branch for NO_RECORD (the R9 inert case), so it DENIES a
|
|
156
|
+
# scoped subject — and the advisory path must agree, not show a link the
|
|
157
|
+
# gate 403s. Stashing the model here without a declared record is the one
|
|
158
|
+
# place the view could diverge from the gate. (#50 review, cubic)
|
|
159
|
+
CurrentScope::Current.collection_model = record.equal?(NO_RECORD) ? nil : model
|
|
160
|
+
CurrentScope::Current.collection_model_path = controller_path
|
|
161
|
+
|
|
162
|
+
# Decision *inputs* (subject, permission, record, model, actor) are
|
|
163
|
+
# explicit — the resolver does not read ambient identity for the allow/
|
|
164
|
+
# deny answer. Org-role *lookup* may use Current.memoized_org_role (a
|
|
165
|
+
# per-request cache, not a decision input). Actor only widens SoD under
|
|
166
|
+
# :either while impersonating; otherwise actor == subject.
|
|
167
|
+
allowed, reason = decide_with_report_diagnosis(permission, record, model)
|
|
168
|
+
unless allowed
|
|
169
|
+
# The nudge runs BEFORE the report-mode branch, and that ordering is the
|
|
170
|
+
# whole point of it in a retrofit. Report mode downgrades a :no_grant to
|
|
171
|
+
# an observation and lets the request through — so a nudge placed after
|
|
172
|
+
# the early return would go silent for exactly the host report mode
|
|
173
|
+
# exists for. And a missing record hook is the one gap report mode CANNOT
|
|
174
|
+
# explain on its own: the would_deny row for that action never clears, no
|
|
175
|
+
# matter what you grant, because the gate has no record to match a scoped
|
|
176
|
+
# grant against. This is the line that says why. Log-only either way, so
|
|
177
|
+
# it cannot affect the branch below. (#37/#41 interaction)
|
|
178
|
+
nudge_on_inert_scoped_grant(permission, record, reason)
|
|
179
|
+
nudge_on_undeclared_collection_model(permission, record, reason)
|
|
180
|
+
|
|
181
|
+
return report_would_deny(permission, record) if report_only_denial?(reason, permission, record)
|
|
182
|
+
|
|
183
|
+
# Report mode still 403s the SoD blind spot (correct, fail-closed) but
|
|
184
|
+
# used to do so silently — no log, no ledger, invisible to
|
|
185
|
+
# current_scope:report. Diagnose before the raise so a retrofit host
|
|
186
|
+
# sees the mis-declared record hook they must fix (#73).
|
|
187
|
+
diagnose_report_sod_blind_spot(permission, record, reason)
|
|
188
|
+
|
|
189
|
+
raise CurrentScope::AccessDenied.new(
|
|
190
|
+
permission,
|
|
191
|
+
reason: reason,
|
|
192
|
+
permission: permission,
|
|
193
|
+
record: (record.equal?(NO_RECORD) ? nil : record),
|
|
194
|
+
subject: CurrentScope::Current.user
|
|
195
|
+
)
|
|
196
|
+
end
|
|
197
|
+
|
|
198
|
+
record_sod_bypass(permission, record) if reason == :sod_bypassed
|
|
199
|
+
nudge_on_nil_sod_record(permission, record)
|
|
200
|
+
end
|
|
201
|
+
|
|
202
|
+
# The gate decision, plus report mode's account of the one failure it cannot
|
|
203
|
+
# downgrade and cannot pass through: a model with no current_scope_initiator
|
|
204
|
+
# on an SoD action, which raises and 500s the request (#133).
|
|
205
|
+
#
|
|
206
|
+
# The raise STAYS. Report mode's promise is "nothing changes for users", and
|
|
207
|
+
# this breaks it — but the two candidate repairs are worse. Passing the
|
|
208
|
+
# request through executes an SoD action with the four-eyes veto never
|
|
209
|
+
# consulted, which is #73's escalation with the safety catch removed;
|
|
210
|
+
# downgrading to a 403 dresses a misconfiguration up as an ordinary denial,
|
|
211
|
+
# the silent-weakening pattern this engine keeps getting burned by. So the
|
|
212
|
+
# 500 stands, and what changes is that it stops being the loudest failure
|
|
213
|
+
# with the least reporting: it now names itself in the log and in the ledger,
|
|
214
|
+
# so `rails current_scope:report` accounts for it instead of leaving a host
|
|
215
|
+
# to correlate 500s by hand. SodPreflight is the other half — it finds most
|
|
216
|
+
# of these at boot, before any traffic arrives here at all.
|
|
217
|
+
#
|
|
218
|
+
# Rescues the CLASS and asks the resolver WHY, rather than matching the
|
|
219
|
+
# message: ConfigurationError is raised for more than one cause, and the
|
|
220
|
+
# cause decides which fix the host is sent after.
|
|
221
|
+
def decide_with_report_diagnosis(permission, record, model)
|
|
222
|
+
CurrentScope.resolver.decide(
|
|
223
|
+
subject: CurrentScope::Current.user, permission: permission,
|
|
224
|
+
record: record, model: model, actor: CurrentScope::Current.actor
|
|
225
|
+
)
|
|
226
|
+
rescue CurrentScope::ConfigurationError
|
|
227
|
+
diagnose_report_sod_initiator(permission, record)
|
|
228
|
+
raise
|
|
229
|
+
end
|
|
230
|
+
|
|
231
|
+
# #133: report mode only. In :enforce a host has committed to the engine and
|
|
232
|
+
# meets this in their error tracker; report mode is the survey, so the survey
|
|
233
|
+
# is where it has to appear. Mirrors diagnose_report_sod_blind_spot — same
|
|
234
|
+
# shape, same report-only scope, a distinct event because the fix is
|
|
235
|
+
# different (the MODEL's hook, not the controller's).
|
|
236
|
+
def diagnose_report_sod_initiator(permission, record)
|
|
237
|
+
return unless CurrentScope.config.report_only?
|
|
238
|
+
|
|
239
|
+
gate_record = record.equal?(NO_RECORD) ? nil : record
|
|
240
|
+
# Ask the resolver (#74) — no second copy of the condition, and no reading
|
|
241
|
+
# of the exception's message.
|
|
242
|
+
return unless CurrentScope.resolver.sod_initiator_missing?(
|
|
243
|
+
permission: permission, record: gate_record
|
|
244
|
+
)
|
|
245
|
+
|
|
246
|
+
Rails.logger&.warn(
|
|
247
|
+
"[CurrentScope] report-only: \"#{permission}\" RAISED rather than being reported — " \
|
|
248
|
+
"#{gate_record.class.name} defines no #{CurrentScope::Resolver::INITIATOR_METHOD}, and " \
|
|
249
|
+
"\"#{permission}\" is a separation-of-duties action (config.sod_actions), so the veto " \
|
|
250
|
+
"cannot run and the engine refuses to guess. Report mode does NOT downgrade this: the " \
|
|
251
|
+
"request 500s here exactly as it would under :enforce. Define " \
|
|
252
|
+
"#{CurrentScope::Resolver::INITIATOR_METHOD} on #{gate_record.class.name} (return nil to " \
|
|
253
|
+
"exempt a record), or remove \"#{permission.split('#').last}\" from config.sod_actions. " \
|
|
254
|
+
"See also rails current_scope:report (access.sod_initiator_missing events)."
|
|
255
|
+
)
|
|
256
|
+
record_sod_initiator_missing_event(permission, gate_record)
|
|
257
|
+
end
|
|
258
|
+
|
|
259
|
+
def record_sod_initiator_missing_event(permission, record)
|
|
260
|
+
subject = CurrentScope::Current.user
|
|
261
|
+
return if subject.nil?
|
|
262
|
+
|
|
263
|
+
# Building the row is rescued SEPARATELY from writing it, and the reason is
|
|
264
|
+
# the latch rather than the rescue. warn_ledger_failure_once is one
|
|
265
|
+
# per-PROCESS one-shot shared by all three report-mode recorders
|
|
266
|
+
# (would_deny, sod_blind_spot, and this one), so a failure that never
|
|
267
|
+
# reached the ledger would consume the single warning the OTHER two still
|
|
268
|
+
# need — and label itself "could not record", sending an operator after a
|
|
269
|
+
# ledger problem that does not exist. Only Event.record! may trip that
|
|
270
|
+
# latch. (#133 — qodo, PR #141)
|
|
271
|
+
begin
|
|
272
|
+
# An unsaved record has no GlobalID, so attribute the row to the subject
|
|
273
|
+
# instead — the model NAME is the fix-carrying detail here, and it rides
|
|
274
|
+
# in details either way.
|
|
275
|
+
target = record if record.respond_to?(:to_gid) && record.try(:persisted?)
|
|
276
|
+
details = {
|
|
277
|
+
permission: permission,
|
|
278
|
+
model: record.class.name,
|
|
279
|
+
fix: "define #{CurrentScope::Resolver::INITIATOR_METHOD} on #{record.class.name}"
|
|
280
|
+
}
|
|
281
|
+
rescue StandardError => e
|
|
282
|
+
Rails.logger&.warn(
|
|
283
|
+
"[CurrentScope] report-only: could not BUILD the access.sod_initiator_missing row " \
|
|
284
|
+
"(#{safe_error_description(e)}) — this is not a ledger failure, so the " \
|
|
285
|
+
"ledger warning is left armed. The request RAISED " \
|
|
286
|
+
"CurrentScope::ConfigurationError (500) either way."
|
|
287
|
+
)
|
|
288
|
+
return nil
|
|
289
|
+
end
|
|
290
|
+
|
|
291
|
+
CurrentScope::Event.record!(
|
|
292
|
+
event: "access.sod_initiator_missing", target: target || subject, details: details
|
|
293
|
+
)
|
|
294
|
+
rescue StandardError => e
|
|
295
|
+
# The request is about to 500 on the ConfigurationError being re-raised —
|
|
296
|
+
# say that, rather than claiming an outcome this path does not produce.
|
|
297
|
+
warn_ledger_failure_once(
|
|
298
|
+
e,
|
|
299
|
+
event: "access.sod_initiator_missing",
|
|
300
|
+
request_outcome: "The request RAISED CurrentScope::ConfigurationError (500) — only the " \
|
|
301
|
+
"access.sod_initiator_missing row is missing."
|
|
302
|
+
)
|
|
303
|
+
nil
|
|
304
|
+
end
|
|
305
|
+
|
|
306
|
+
# Report mode lifts EXACTLY ONE wall: :no_grant — "nobody has granted this
|
|
307
|
+
# subject this permission yet", which is the entire state of a host that has
|
|
308
|
+
# mounted the gate and not yet seeded its grants. That is the thing report
|
|
309
|
+
# mode exists to survey.
|
|
310
|
+
#
|
|
311
|
+
# Matched POSITIVELY, on one reason, and that is the whole design. Every
|
|
312
|
+
# other denial is a real refusal about a real rule and must still 403:
|
|
313
|
+
# :sod_veto (relaxing it lets an initiator actually self-approve — a fraud
|
|
314
|
+
# action executed, not a role gap surfaced), :impersonation_gate, and
|
|
315
|
+
# :not_full_access (the management console — report mode must never hand out
|
|
316
|
+
# the UI where grants are made).
|
|
317
|
+
#
|
|
318
|
+
# An "everything except the vetoes I know about" rule would have been correct
|
|
319
|
+
# the day it was written and wrong by the next release: :not_full_access did
|
|
320
|
+
# not exist when this was designed, and it is excluded here by construction
|
|
321
|
+
# rather than by anyone remembering to add it. New reasons are refusals until
|
|
322
|
+
# someone deliberately says otherwise — fail-closed, applied to the mode
|
|
323
|
+
# itself.
|
|
324
|
+
#
|
|
325
|
+
# ...but :no_grant is not always the innocent reason it looks like. See below.
|
|
326
|
+
def report_only_denial?(reason, permission, record)
|
|
327
|
+
CurrentScope.config.report_only? &&
|
|
328
|
+
reason == :no_grant &&
|
|
329
|
+
!sod_veto_blind_spot?(permission, record)
|
|
330
|
+
end
|
|
331
|
+
|
|
332
|
+
# The SoD blind spot: a :no_grant that is NOT evidence the veto approved.
|
|
333
|
+
#
|
|
334
|
+
# The veto has nothing to measure without a record, so the resolver skips it
|
|
335
|
+
# and the decision falls through to the ordinary grant check. What comes back
|
|
336
|
+
# is :no_grant — indistinguishable from an ordinary missing grant, but meaning
|
|
337
|
+
# "nobody asked the veto", not "the veto passed".
|
|
338
|
+
#
|
|
339
|
+
# In :enforce that costs nothing; :no_grant is a 403 either way, so the
|
|
340
|
+
# skipped veto never decides anything (config.warn_on_nil_sod_record exists to
|
|
341
|
+
# surface it on the ALLOW path). Report mode is what turns it into a hole:
|
|
342
|
+
# :no_grant is exactly what it downgrades, so a host that mis-declares
|
|
343
|
+
# current_scope_record on an SoD action gets the action EXECUTED with the
|
|
344
|
+
# four-eyes rule never consulted. The subject could be the initiator. Nobody
|
|
345
|
+
# checked.
|
|
346
|
+
#
|
|
347
|
+
# So report mode declines to speak where the veto couldn't, and downgrades
|
|
348
|
+
# only a denial the veto actually saw and passed. This costs a retrofitting
|
|
349
|
+
# host nothing real: an SoD action reached without a record is a
|
|
350
|
+
# misconfiguration they must fix regardless, and it still 403s as it does
|
|
351
|
+
# today.
|
|
352
|
+
#
|
|
353
|
+
# ASKS the resolver rather than re-deriving "did the veto run" — the resolver
|
|
354
|
+
# owns that condition and a second copy would drift, with the drifting copy
|
|
355
|
+
# being the one guarding the fraud control. An earlier draft of this did
|
|
356
|
+
# enumerate its own "record-less" set (nil, NO_RECORD, Class) and missed the
|
|
357
|
+
# commonest mistake of all: a hook returning `params[:id]`, a String, which
|
|
358
|
+
# the resolver skips the veto for but that guess would have waved through.
|
|
359
|
+
def sod_veto_blind_spot?(permission, record)
|
|
360
|
+
CurrentScope.resolver.sod_veto_skipped?(permission: permission, record: record)
|
|
361
|
+
end
|
|
362
|
+
|
|
363
|
+
# Observe and proceed.
|
|
364
|
+
def report_would_deny(permission, record)
|
|
365
|
+
Rails.logger&.warn(
|
|
366
|
+
"[CurrentScope] report-only: would DENY #{permission.inspect} " \
|
|
367
|
+
"(reason: no_grant) — grant it before setting config.enforcement = :enforce"
|
|
368
|
+
)
|
|
369
|
+
response.set_header("X-Current-Scope-Reason", "would_deny")
|
|
370
|
+
record_would_deny_event(permission, record)
|
|
371
|
+
end
|
|
372
|
+
|
|
373
|
+
# #73: report mode refuses to *downgrade* an SoD blind-spot denial (the
|
|
374
|
+
# veto never ran), but must not 403 *silently*. Log the cause/fix and
|
|
375
|
+
# record a distinct ledger row — never access.would_deny, because granting
|
|
376
|
+
# the permission will not clear this 403 (the hook is the fix).
|
|
377
|
+
def diagnose_report_sod_blind_spot(permission, record, reason)
|
|
378
|
+
return unless CurrentScope.config.report_only?
|
|
379
|
+
return unless reason == :no_grant
|
|
380
|
+
# Ask the resolver (#74) — no second copy of the skip condition.
|
|
381
|
+
return unless sod_veto_blind_spot?(permission, record)
|
|
382
|
+
|
|
383
|
+
Rails.logger&.warn(
|
|
384
|
+
"[CurrentScope] report-only: DENIED #{permission.inspect} because the " \
|
|
385
|
+
"separation-of-duties veto could not run (no usable record for this " \
|
|
386
|
+
"action). This is NOT a missing grant — granting will not clear the 403. " \
|
|
387
|
+
"Declare current_scope_record to return the AR record on this member " \
|
|
388
|
+
"action (or remove the action from config.sod_actions if it is not " \
|
|
389
|
+
"meant to be four-eyes gated). See also rails current_scope:report " \
|
|
390
|
+
"(access.sod_blind_spot events)."
|
|
391
|
+
)
|
|
392
|
+
record_sod_blind_spot_event(permission, record)
|
|
393
|
+
end
|
|
394
|
+
|
|
395
|
+
def record_sod_blind_spot_event(permission, record)
|
|
396
|
+
subject = CurrentScope::Current.user
|
|
397
|
+
return if subject.nil?
|
|
398
|
+
|
|
399
|
+
target = record.equal?(NO_RECORD) ? nil : record
|
|
400
|
+
# Non-records (String params[:id], etc.) are not GlobalID targets.
|
|
401
|
+
target = nil unless target.respond_to?(:to_gid)
|
|
402
|
+
|
|
403
|
+
CurrentScope::Event.record!(
|
|
404
|
+
event: "access.sod_blind_spot",
|
|
405
|
+
target: target || subject,
|
|
406
|
+
details: {
|
|
407
|
+
permission: permission,
|
|
408
|
+
reason: "no_grant",
|
|
409
|
+
blind_spot: true,
|
|
410
|
+
fix: "declare current_scope_record for this SoD member action"
|
|
411
|
+
}
|
|
412
|
+
)
|
|
413
|
+
rescue StandardError => e
|
|
414
|
+
# Blind-spot path returns 403 next — do not claim the request was allowed
|
|
415
|
+
# or that a would_deny row was lost (PR #103 review).
|
|
416
|
+
warn_ledger_failure_once(
|
|
417
|
+
e,
|
|
418
|
+
event: "access.sod_blind_spot",
|
|
419
|
+
request_outcome: "The request was DENIED (403) — only the access.sod_blind_spot row is missing."
|
|
420
|
+
)
|
|
421
|
+
nil
|
|
422
|
+
end
|
|
423
|
+
|
|
424
|
+
# R3: report mode NEVER raises — that is its whole promise, and it has to hold
|
|
425
|
+
# regardless of audit posture or the state of the ledger.
|
|
426
|
+
#
|
|
427
|
+
# Every other caller of Event.record! is a mutation being performed, where
|
|
428
|
+
# :strict re-raising to roll back an unaudited change is exactly right. This
|
|
429
|
+
# is not a mutation: it observes a request that is being let through anyway.
|
|
430
|
+
# Inheriting that raise would mean a host running audit = :strict who hasn't
|
|
431
|
+
# run the events migration 500s on every ungranted request — the opposite of
|
|
432
|
+
# what report mode promises, landing on the exact host it exists for.
|
|
433
|
+
#
|
|
434
|
+
# The rescue wraps ONLY this call. Event.record! is the one thing here with a
|
|
435
|
+
# documented raise contract, so it is the one thing worth catching; a broad
|
|
436
|
+
# rescue over the whole observation would also swallow a broken logger or
|
|
437
|
+
# response, which are app-fatal anyway and shouldn't be hidden. (#59 review)
|
|
438
|
+
def record_would_deny_event(permission, record)
|
|
439
|
+
subject = CurrentScope::Current.user
|
|
440
|
+
# No ambient subject ⇒ nothing to attribute the row to, and Event.record!
|
|
441
|
+
# raises on a nil actor. Guard on the SUBJECT, not on `target` — a record
|
|
442
|
+
# can be non-nil while the subject is nil.
|
|
443
|
+
return if subject.nil?
|
|
444
|
+
|
|
445
|
+
# NO_RECORD (the controller declared no hook) and nil (it declared "no
|
|
446
|
+
# record here") both mean there is nothing to attribute the row to but the
|
|
447
|
+
# subject. Compared by identity — NO_RECORD is an Object instance, so
|
|
448
|
+
# `is_a?` would match every record there is.
|
|
449
|
+
target = record.equal?(NO_RECORD) ? nil : record
|
|
450
|
+
|
|
451
|
+
CurrentScope::Event.record!(
|
|
452
|
+
event: "access.would_deny", target: target || subject,
|
|
453
|
+
details: { permission: permission, reason: "no_grant" }
|
|
454
|
+
)
|
|
455
|
+
rescue StandardError => e
|
|
456
|
+
# ponytail: swallow and warn ONCE. An unrecordable observation is a lost
|
|
457
|
+
# log line; a raise here is a 500 on a request report mode promised to pass.
|
|
458
|
+
warn_ledger_failure_once(
|
|
459
|
+
e,
|
|
460
|
+
event: "access.would_deny",
|
|
461
|
+
request_outcome: "The request WAS allowed through — only the access.would_deny row is missing."
|
|
462
|
+
)
|
|
463
|
+
nil
|
|
464
|
+
end
|
|
465
|
+
|
|
466
|
+
# The failure this catches is PERSISTENT, not incidental: :report + audit
|
|
467
|
+
# :strict + an un-migrated events table fails identically on every request.
|
|
468
|
+
# Warning per-request floods the log with one repeated line and buries the
|
|
469
|
+
# thing the operator actually needs — that the ledger is empty because the
|
|
470
|
+
# table is missing, and what to do about it. And it is the exact situation
|
|
471
|
+
# report mode exists for, so it is the one a host is most likely to be in.
|
|
472
|
+
#
|
|
473
|
+
# Warn-once per process, mirroring Event.warn_missing_events_table_once —
|
|
474
|
+
# the same failure, the same treatment. (#59 review) The message names the
|
|
475
|
+
# fix for a missing table and otherwise reports the real error, because
|
|
476
|
+
# telling someone with a dead connection to run migrations sends them after
|
|
477
|
+
# the wrong problem.
|
|
478
|
+
#
|
|
479
|
+
# `event` / `request_outcome` are path-specific: would_deny allows the
|
|
480
|
+
# request; sod_blind_spot still 403s — the operator-facing text must not
|
|
481
|
+
# claim the wrong outcome (PR #103).
|
|
482
|
+
def warn_ledger_failure_once(error, event:, request_outcome:)
|
|
483
|
+
return if CurrentScope::Guard.ledger_warning_emitted?
|
|
484
|
+
|
|
485
|
+
CurrentScope::Guard.ledger_warning_emitted!
|
|
486
|
+
Rails.logger&.warn(
|
|
487
|
+
"[CurrentScope] report-only: #{ledger_failure_hint(error, event: event)} " \
|
|
488
|
+
"#{request_outcome} This warns once per process."
|
|
489
|
+
)
|
|
490
|
+
end
|
|
491
|
+
|
|
492
|
+
# ASKS Event whether this is the un-migrated-table case rather than pattern-
|
|
493
|
+
# matching the message here. Event's signature already excludes missing-COLUMN
|
|
494
|
+
# errors — a partial migration is not an absent table, and its own comment
|
|
495
|
+
# says why: it "would point operators at the wrong fix". A looser test here
|
|
496
|
+
# reintroduced exactly that, telling someone their table was missing while
|
|
497
|
+
# they were looking right at it. (#59 review)
|
|
498
|
+
def ledger_failure_hint(error, event:)
|
|
499
|
+
if CurrentScope::Event.missing_events_table?(error)
|
|
500
|
+
"the current_scope_events table is missing, so #{event} rows are not being " \
|
|
501
|
+
"recorded and `rails current_scope:report` will be incomplete. Run " \
|
|
502
|
+
"`rails current_scope:install:migrations && rails db:migrate`, or set " \
|
|
503
|
+
"config.audit = false if you don't want the ledger."
|
|
504
|
+
else
|
|
505
|
+
"could not record #{event} (#{safe_error_description(error)})."
|
|
506
|
+
end
|
|
507
|
+
end
|
|
508
|
+
|
|
509
|
+
# "Class: message", with a fallback when the exception itself is hostile.
|
|
510
|
+
#
|
|
511
|
+
# `e.message` is host-overridable and can raise. Every use of it here is
|
|
512
|
+
# inside a rescue on a path that is ABOUT to re-raise something more
|
|
513
|
+
# important — the ConfigurationError that names the model and both fixes, or
|
|
514
|
+
# a 403 — so a second exception raised while formatting the first would
|
|
515
|
+
# replace it, and the host would lose the message that tells them what to do.
|
|
516
|
+
# This codebase already accepted that argument once for `model.inspect`
|
|
517
|
+
# (PR #93); the same reasoning covers `message`. (#133 — cubic, PR #141)
|
|
518
|
+
def safe_error_description(error)
|
|
519
|
+
"#{error.class}: #{error.message.to_s.truncate(120)}"
|
|
520
|
+
rescue StandardError
|
|
521
|
+
"#{error.class}: (message unavailable)"
|
|
522
|
+
end
|
|
523
|
+
|
|
524
|
+
# Why a gated permission is missing from the catalog: excluded by config
|
|
525
|
+
# (name the matching regex) vs never routed. Two different host fixes (#44).
|
|
526
|
+
def catalog_miss_message(permission)
|
|
527
|
+
matched = CurrentScope.config.excluded_controllers.select { |re| controller_path.match?(re) }
|
|
528
|
+
skip_clause =
|
|
529
|
+
"Either stop excluding it, or skip the gate here with " \
|
|
530
|
+
"skip_before_action :current_scope_check!. Skipping the gate leaves this " \
|
|
531
|
+
"controller ungated by CurrentScope — protect it with your own " \
|
|
532
|
+
"authorization (e.g. require_admin!)."
|
|
533
|
+
|
|
534
|
+
if matched.any?
|
|
535
|
+
patterns = matched.map(&:inspect).join(", ")
|
|
536
|
+
%("#{permission}" is excluded by config.excluded_controllers (matched #{patterns}). #{skip_clause})
|
|
537
|
+
else
|
|
538
|
+
%("#{permission}" is not in the permission catalog because it is not routed. ) +
|
|
539
|
+
"Add a route for this controller#action, or skip the gate with " \
|
|
540
|
+
"skip_before_action :current_scope_check! if the action is intentional " \
|
|
541
|
+
"(then protect it with your own authorization)."
|
|
542
|
+
end
|
|
543
|
+
end
|
|
544
|
+
|
|
545
|
+
# The record this gate decides against, or NO_RECORD when the controller
|
|
546
|
+
# never declared the hook (see NO_RECORD). A declared hook's answer — record
|
|
547
|
+
# or nil — is passed through exactly as given.
|
|
548
|
+
#
|
|
549
|
+
# Deliberately reads the DECLARATION, not the route. Guessing member-vs-
|
|
550
|
+
# collection from path parameters cannot be made correct: `:id` misses
|
|
551
|
+
# `param: :slug`; "any key not suffixed _id" misses `param: :external_id`
|
|
552
|
+
# and falsely accuses a nested parent with a custom param. Each rule fails
|
|
553
|
+
# on the next routing DSL option, because the route simply does not encode
|
|
554
|
+
# what the host means. The hook does, and the contract above already asks
|
|
555
|
+
# every gated controller to declare it.
|
|
556
|
+
def resolve_current_scope_record
|
|
557
|
+
return NO_RECORD unless respond_to?(:current_scope_record, true)
|
|
558
|
+
|
|
559
|
+
send(:current_scope_record)
|
|
560
|
+
end
|
|
561
|
+
|
|
562
|
+
# The type this controller's collection actions deal in, or nil when the
|
|
563
|
+
# host never declared current_scope_model. Mirrors
|
|
564
|
+
# resolve_current_scope_record, minus the sentinel: for the RECORD,
|
|
565
|
+
# "declared nil" and "declared nothing" are different statements and
|
|
566
|
+
# NO_RECORD keeps them apart; for the TYPE both collapse to the same fact —
|
|
567
|
+
# unknown — so a plain nil carries it.
|
|
568
|
+
def resolve_current_scope_model
|
|
569
|
+
return nil unless respond_to?(:current_scope_model, true)
|
|
570
|
+
|
|
571
|
+
send(:current_scope_model)
|
|
572
|
+
end
|
|
573
|
+
|
|
574
|
+
# Break-glass audit (KTD-1): the resolver stays pure and only reports
|
|
575
|
+
# :sod_bypassed; the Guard — which runs once per REAL gated action, never on
|
|
576
|
+
# advisory allowed_to?/scope_for — records the override exactly once and
|
|
577
|
+
# surfaces it on the response. Recorded for ANY verb: the guarantee is
|
|
578
|
+
# "every bypass is audited", so if a host ever routes an SoD action to GET,
|
|
579
|
+
# the bypass still leaves its trail rather than slipping through unlogged.
|
|
580
|
+
# Event.record! is a no-op when config.audit is false, so an audit-off host
|
|
581
|
+
# still permits, records nothing — consistent with the rest of the ledger.
|
|
582
|
+
def record_sod_bypass(permission, record)
|
|
583
|
+
initiator = record.send(CurrentScope::Resolver::INITIATOR_METHOD)
|
|
584
|
+
CurrentScope::Event.record!(
|
|
585
|
+
event: "sod.bypassed", target: record,
|
|
586
|
+
details: { permission: permission, initiator: initiator&.to_gid&.to_s }
|
|
587
|
+
)
|
|
588
|
+
response.set_header("X-Current-Scope-Reason", "sod_bypassed")
|
|
589
|
+
end
|
|
590
|
+
|
|
591
|
+
# A5 dev/test aid (on by default in dev/test, #41): the request was ALLOWED, but if it's an SoD
|
|
592
|
+
# action gated with a nil record, the SoD veto was silently skipped — a sign
|
|
593
|
+
# current_scope_record returned nil on a member action. Lives here (the gate
|
|
594
|
+
# seam), not in the shared resolver, so it never fires on advisory
|
|
595
|
+
# allowed_to?/scope_for calls. Prod behavior is unchanged either way.
|
|
596
|
+
# The denial-side mirror of nudge_on_nil_sod_record (#41): this controller
|
|
597
|
+
# declared NO current_scope_record hook, and the subject holds a scoped grant
|
|
598
|
+
# that would have applied if it had.
|
|
599
|
+
#
|
|
600
|
+
# That is a controller with member actions that forgot the hook. It fails
|
|
601
|
+
# closed — correctly — but the resulting 403 is byte-identical to "you were
|
|
602
|
+
# never granted this", so whoever debugs it goes and stares at the grants,
|
|
603
|
+
# which are fine, instead of the controller, which isn't.
|
|
604
|
+
#
|
|
605
|
+
# Keyed on NO_RECORD, NOT on nil, and the difference is the whole nudge:
|
|
606
|
+
#
|
|
607
|
+
# - NO_RECORD = "this controller never said whether there's a record here."
|
|
608
|
+
# Silence. Scoped grants can't open the gate, so a genuinely-granted
|
|
609
|
+
# subject is refused and nothing says why. THIS is the bug.
|
|
610
|
+
# - nil = "there is no record here", stated deliberately by the host.
|
|
611
|
+
# Since #49 a scoped role ticking the key OPENS that gate, so a subject
|
|
612
|
+
# with a matching grant isn't denied at all and there is nothing to nudge
|
|
613
|
+
# about. Nudging here would fire on every legitimate collection request.
|
|
614
|
+
#
|
|
615
|
+
# (Plan 023 predates #49 and guards on `record.nil?` — which can no longer
|
|
616
|
+
# fire for the case it was written for, and excludes the case that can. Pinned
|
|
617
|
+
# by tests below rather than left to the next reader to rediscover.)
|
|
618
|
+
def nudge_on_inert_scoped_grant(permission, record, reason)
|
|
619
|
+
return unless CurrentScope.config.warn_on_inert_scoped_grant
|
|
620
|
+
return unless reason == :no_grant
|
|
621
|
+
return unless record.equal?(NO_RECORD)
|
|
622
|
+
|
|
623
|
+
# Diagnostics must not 500 the request (#74): a transient DB error inside
|
|
624
|
+
# scoped_grant_exists? is log-only, matching record_would_deny_event.
|
|
625
|
+
has_grant =
|
|
626
|
+
begin
|
|
627
|
+
CurrentScope.resolver.scoped_grant_exists?(
|
|
628
|
+
subject: CurrentScope::Current.user, permission: permission
|
|
629
|
+
)
|
|
630
|
+
rescue StandardError => e
|
|
631
|
+
Rails.logger&.warn(
|
|
632
|
+
"[CurrentScope] warn_on_inert_scoped_grant could not check scoped grants " \
|
|
633
|
+
"(#{safe_error_description(e)}); skipping diagnostic for \"#{permission}\"."
|
|
634
|
+
)
|
|
635
|
+
false
|
|
636
|
+
end
|
|
637
|
+
return unless has_grant
|
|
638
|
+
|
|
639
|
+
# Says only what the predicate proves. scoped_grant_exists? has no resource
|
|
640
|
+
# filter — it can't have one, the missing record IS the bug — so it
|
|
641
|
+
# establishes that a matching scoped grant exists on SOME record, not that
|
|
642
|
+
# it would have applied to whichever record this action meant. Claiming
|
|
643
|
+
# "would satisfy it" overstates that, and a diagnostic that overstates is
|
|
644
|
+
# how a diagnostic starts being ignored. (#61 review, qodo)
|
|
645
|
+
message =
|
|
646
|
+
"[CurrentScope] denied \"#{permission}\" (no_grant) — this subject holds a scoped grant " \
|
|
647
|
+
"for it on some record, and #{controller_path} declares no current_scope_record, so the " \
|
|
648
|
+
"gate had no record to match it against. If this is a member action, declare the hook " \
|
|
649
|
+
"(`def current_scope_record = set_thing`) — if the subject holds the grant on the record " \
|
|
650
|
+
"in question, that fixes this. If the controller is collection-only, " \
|
|
651
|
+
"`def current_scope_record = nil` says so and lets scoped grants through."
|
|
652
|
+
|
|
653
|
+
# R9 (#50): this controller declared current_scope_model WITHOUT
|
|
654
|
+
# current_scope_record — the model hook is INERT, because no record hook
|
|
655
|
+
# means NO_RECORD and the record-less branch (the only reader of the
|
|
656
|
+
# declared type) never runs. That trigger is byte-for-byte this nudge's
|
|
657
|
+
# own, so it is one clause on this line, not a second nudge: two log
|
|
658
|
+
# lines saying the same thing on the same request is the noise the
|
|
659
|
+
# diagnostics contract exists to avoid.
|
|
660
|
+
if respond_to?(:current_scope_model, true)
|
|
661
|
+
message += " NOTE: this controller declares current_scope_model, but that hook is inert " \
|
|
662
|
+
"without current_scope_record — the record hook is what's missing here."
|
|
663
|
+
end
|
|
664
|
+
|
|
665
|
+
Rails.logger&.warn(message)
|
|
666
|
+
end
|
|
667
|
+
|
|
668
|
+
# #50's adoption gap, named at the moment it bites: the resolver denied
|
|
669
|
+
# :model_undeclared — a declared collection action (record nil) with no
|
|
670
|
+
# current_scope_model, while a scoped grant explicitly ticks the key. The
|
|
671
|
+
# reason ALREADY proves that whole condition (the resolver derived it), so
|
|
672
|
+
# the predicate here is the reason and nothing else — re-deriving it from
|
|
673
|
+
# record/grants would be the drifting second copy KTD-5 warns about.
|
|
674
|
+
# `record` is taken to mirror its sibling's call shape, not consulted.
|
|
675
|
+
# Log-only; the reason rides X-Current-Scope-Reason with or without this.
|
|
676
|
+
def nudge_on_undeclared_collection_model(permission, _record, reason)
|
|
677
|
+
return unless CurrentScope.config.warn_on_undeclared_collection_model
|
|
678
|
+
|
|
679
|
+
# Both labels are the same cell — a scoped grant satisfies the key but
|
|
680
|
+
# the gate had no usable type to bind it to — with different fixes, so
|
|
681
|
+
# each gets its own sentence. :model_invalid names the actual value the
|
|
682
|
+
# hook returned, because "declares no current_scope_model" would be a
|
|
683
|
+
# lie to the host that DID declare one and typo'd it (the release-gate
|
|
684
|
+
# finding this branch exists for).
|
|
685
|
+
case reason
|
|
686
|
+
when :model_undeclared
|
|
687
|
+
# The grant is known to satisfy the key on SOME record of SOME type — a
|
|
688
|
+
# tick, or (on a listed read, #65) a full_access role, which ticks
|
|
689
|
+
# nothing. The missing declaration is exactly why the gate couldn't
|
|
690
|
+
# check which, and it also can't check record liveness — so the fix is
|
|
691
|
+
# named as "may fix", never promised. (#61 wording precedent; the
|
|
692
|
+
# resolver's label-predicate comment relies on this hedge.)
|
|
693
|
+
Rails.logger&.warn(
|
|
694
|
+
"[CurrentScope] denied \"#{permission}\" (model_undeclared) — this is a declared " \
|
|
695
|
+
"collection action (current_scope_record returned nil) and the subject holds a scoped " \
|
|
696
|
+
"grant that satisfies the key, but #{controller_path} declares no current_scope_model, " \
|
|
697
|
+
"so the gate had no type to bind it to and failed closed. Declaring the type this " \
|
|
698
|
+
"collection deals in (`def current_scope_model = TheType`) may fix this — the grant " \
|
|
699
|
+
"must be of that type, and for a listed read its record must still be in the model's " \
|
|
700
|
+
"default scope."
|
|
701
|
+
)
|
|
702
|
+
when :model_invalid
|
|
703
|
+
# Re-read the hook rather than thread the value through the call —
|
|
704
|
+
# keeping the call site at its siblings' 3-arg shape, so a host that
|
|
705
|
+
# collides with this (private, but un-namespaced) method name at the
|
|
706
|
+
# old arity gets its own method called, not an ArgumentError-500 on a
|
|
707
|
+
# denied request (PR #93 review, qodo + cubic). Display-only re-read:
|
|
708
|
+
# the hook is a plain method and the decision is already made.
|
|
709
|
+
model = resolve_current_scope_model
|
|
710
|
+
# A diagnostic must never alter the denial path — an object whose
|
|
711
|
+
# inspect raises would turn this 403 into a 500 (PR #93 review, cubic).
|
|
712
|
+
described = begin
|
|
713
|
+
model.inspect
|
|
714
|
+
rescue StandardError
|
|
715
|
+
"(uninspectable object)"
|
|
716
|
+
end
|
|
717
|
+
Rails.logger&.warn(
|
|
718
|
+
"[CurrentScope] denied \"#{permission}\" (model_invalid) — #{controller_path}'s " \
|
|
719
|
+
"current_scope_model returned #{described}, which is not a concrete " \
|
|
720
|
+
"ActiveRecord model class, so the gate could not bind the record-less check to it " \
|
|
721
|
+
"and failed closed. Return the AR class this collection lists " \
|
|
722
|
+
"(`def current_scope_model = TheType`) — a String, instance, or abstract class " \
|
|
723
|
+
"cannot be matched against scoped grants."
|
|
724
|
+
)
|
|
725
|
+
end
|
|
726
|
+
end
|
|
727
|
+
|
|
728
|
+
def nudge_on_nil_sod_record(permission, record)
|
|
729
|
+
return unless CurrentScope.config.warn_on_nil_sod_record
|
|
730
|
+
# Ask the resolver (#74) — do not re-derive "did the veto skip?". A private
|
|
731
|
+
# nil/NO_RECORD copy missed the commonest mistake: a hook returning
|
|
732
|
+
# params[:id] (a String). sod_veto_skipped? is the single definition.
|
|
733
|
+
gate_record = record.equal?(NO_RECORD) ? nil : record
|
|
734
|
+
return unless CurrentScope.resolver.sod_veto_skipped?(permission: permission, record: gate_record)
|
|
735
|
+
|
|
736
|
+
shape_hint =
|
|
737
|
+
if gate_record.nil?
|
|
738
|
+
"a nil record"
|
|
739
|
+
else
|
|
740
|
+
"a non-record (#{gate_record.class.name}) — often a hook returning params[:id]"
|
|
741
|
+
end
|
|
742
|
+
|
|
743
|
+
Rails.logger&.warn(
|
|
744
|
+
"[CurrentScope] \"#{permission}\" is a separation-of-duties action but was gated with " \
|
|
745
|
+
"#{shape_hint}, so the SoD veto was skipped. If this is a member action, " \
|
|
746
|
+
"current_scope_record must return the AR record; if it's a collection action, this is expected."
|
|
747
|
+
)
|
|
748
|
+
end
|
|
749
|
+
end
|
|
750
|
+
end
|