Module: CurrentScope::SodPreflight
- Defined in:
- lib/current_scope/sod_preflight.rb
Overview
Finds, before traffic does, the separation-of-duties actions that are going to raise CurrentScope::ConfigurationError: an action listed in config.sod_actions reaching a model that defines no current_scope_initiator.
The raise itself is correct and stays (a silently skipped veto is the hole #73/#74 exist to prevent). What was wrong is WHEN a host learns about it — per request, from real traffic, including in :report mode, which promises that nothing changes for users. This answers the same question as soon as the routes exist, where the fix is cheap. (#133)
"As soon as the routes exist" is BOOT in an eager-loading environment (production and staging — where a bake runs) and the first request in development, whose route set is lazy. The engine initializer says why.
ADVISORY AND PARTIAL BY CONSTRUCTION, for the reason #134 recorded: the record a member action decides about comes from current_scope_record, an instance method whose value exists only mid-request, so "which model does invoices#approve gate?" is not statically knowable. What IS declared is current_scope_model (#50) — the type a controller's collection actions deal in. Under Rails' resource conventions that is the type its member actions load too, which makes it a good signal and not a proof:
false negative — a controller declaring no current_scope_model is never
inspected, so its 500 still arrives with traffic. That
gap is why the report-mode ledger row exists as well
(Guard#diagnose_report_sod_initiator).
false positive — a controller whose member action loads a DIFFERENT type
than its collection lists gets named against the wrong
model. Costs a look; it can never break an app.
Both limits are stated in the output the operator reads, not in a guide they will not open — the rule GrantDiagnosis.untargeted_caveat set.
Defined Under Namespace
Classes: Result
Constant Summary collapse
- SCAN_ITSELF =
The subject recorded when the walk itself failed, rather than one host controller or model. Paired with a NoMethodError it identifies a bug in this gem — every host call-out is wrapped by a helper and carries its own subject, so it can never produce this pair.
"the scan itself".freeze
Class Method Summary collapse
-
.caveat ⇒ Object
The limits of the answer, in the output rather than in a guide.
-
.fix_line ⇒ Object
The two remedies are NOT coequal here, and this message must not present them as if they were.
-
.scan ⇒ Object
Scan the routed SoD actions and return a Result.
-
.skip_summary(result) ⇒ Object
One line per run, naming the count and the distinct causes — never one line per controller.
-
.warn!(result = scan) ⇒ Object
One message listing every action that will raise, plus the coverage behind an empty list.
Class Method Details
.caveat ⇒ Object
The limits of the answer, in the output rather than in a guide. Shared by
the boot warning and rails current_scope:report so the two cannot drift
into different versions of the same hedge (the drift #134 fixed for the
untargeted-grant caveat).
LINE-BROKEN on purpose. All five limits are real and two were added by
review, so the answer to "this is a wall" is never fewer limits — it is
structure. An unread caveat protects nobody, and this one is the only
thing standing between a fallible list and a host deleting a four-eyes
control. Its sibling GrantDiagnosis.untargeted_caveat is short for the
same reason. (#133 review)
163 164 165 166 167 168 169 170 171 172 173 174 175 176 |
# File 'lib/current_scope/sod_preflight.rb', line 163 def caveat [ "This list is PARTIAL — a lead, not a verdict. Confirm against the model before " \ "you change config.sod_actions.", " Silent when: the controller declares no current_scope_model (ABSENT is not " \ "cleared), or the model cannot be instantiated right now (no database connection " \ "yet, a custom initialize).", " Names the wrong thing when: the declared type is what the COLLECTION lists and " \ "a member action loads a different one; or current_scope_model branches on " \ "action_name, which is read here with no action in hand.", " Cannot happen at all: a finding against an action that turns out to be a " \ "COLLECTION action — the veto never reaches those." ].join("\n") end |
.fix_line ⇒ Object
The two remedies are NOT coequal here, and this message must not present them as if they were. Defining the hook restores a control; removing the action from config.sod_actions DELETES a fraud control. On the raise in Resolver#sod_decision the cause is proven, so offering both plainly is right. This list can be wrong — it reads a declaration, not the record — so leading with "or just turn the veto off" invites a host to disable four-eyes on a false accusation. Lead with the hook; qualify the rest.
PUBLIC beside #caveat, and for the identical reason: rails current_scope:report renders the SAME finding, so a private copy here
guaranteed the correction reached one surface and not the other. It did
exactly that for one commit. (#133 review)
190 191 192 193 194 195 |
# File 'lib/current_scope/sod_preflight.rb', line 190 def fix_line "Fix: define #{Resolver::INITIATOR_METHOD} on each model listed (return nil to exempt a " \ "record). Only if the action was never meant to be four-eyes gated should you remove it " \ "from config.sod_actions — that removes the control rather than wiring it, so confirm " \ "the finding first." end |
.scan ⇒ Object
Scan the routed SoD actions and return a Result. PURE: it reads routes,
declarations and models, and returns; rendering and logging belong to the
callers (warn! for the log, rails current_scope:report for stdout), so
scanning twice cannot emit twice.
Free for the default config: sod_actions is [] until a host opts in, and nothing here touches a controller until it is not.
71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 |
# File 'lib/current_scope/sod_preflight.rb', line 71 def scan rows = [] skipped = [] inspected = 0 in_scope = 0 unless CurrentScope.config.sod_actions.empty? begin reflection = CurrentScope::GatingReflection.new models = {} .each do |controller, | in_scope += 1 model = models.fetch(controller) { models[controller] = declared_model_for(controller, reflection, skipped) } next if model.nil? inspected += 1 next if defines_initiator?(model, skipped) rows << [ , model ] end rescue StandardError => e # AN ADVISORY MUST NEVER BE THE THING THAT BREAKS A BOOT, and that # includes when the bug is OURS. # # This used to re-raise NoMethodError, copying GrantDiagnosis on the # theory that host failures are absorbed by the two helpers below, so # one arriving here can only be a gem bug worth surfacing. The # reasoning holds; the CONSEQUENCE did not survive review. That # sibling is only ever called from a rake task and a console view. # This module is called from an engine initializer, so re-raising # took a host's boot down — over a diagnostic — and aborted # `current_scope:report`, the survey they run mid-rollout. The # convention was borrowed from a module that never runs at boot. # # The distinction it was protecting is kept without the crash: a # NoMethodError whose subject is the scan itself is reported as OUR # bug rather than blamed on host config (see skip_summary), because # every call into host code is wrapped by the helpers and carries the # controller or model as its subject. Loud, attributed, and # survivable. (#133 — Devin, PR #141) # # KEEP WHAT WAS FOUND. `rows` is built outside the begin precisely so # a failure partway through cannot discard the findings already # collected — in a large app one bad controller would otherwise hide # every earlier SoD miss from both surfaces, which is the exact case # this check exists to surface. The run is still marked as skipped, # so an incomplete list never reads as a clean one. (#133 — cubic) skipped << [ SCAN_ITSELF, e ] end end Result.new(rows: rows, inspected: inspected, in_scope: in_scope, skipped: skipped) end |
.skip_summary(result) ⇒ Object
One line per run, naming the count and the distinct causes — never one line per controller. The failure this class expects most is a current_scope_model hook that reads request-scoped state, and that one fails for EVERY affected controller on every routes load; a per-row line would flood exactly the host it is trying to help. Every sibling diagnostic in this engine throttles for the same reason (Guard.warn_ledger_failure_once, Event.warn_missing_events_table_once, the cross-controller nudge's once-per-site set).
Broken controllers are called out separately because their fix differs: the controller does not LOAD, which the gate will hit too. Derived from the error class, not carried as a flag — a NameError can only arrive from inside a controller's own body, because a missing controller CONSTANT became MissingController and returned nil earlier. That is the distinction GatingReflection#controller_class exists to keep.
Returns the string (nil when nothing was skipped) rather than logging, so
rails current_scope:report can render the same facts to stdout — the
operator reading a terminal must not be told to go and find a log.
216 217 218 219 220 221 222 223 224 225 226 227 228 229 230 231 232 233 234 235 236 237 238 239 240 241 242 243 244 |
# File 'lib/current_scope/sod_preflight.rb', line 216 def skip_summary(result) return nil if result.skipped.empty? # NameError is a subclass of NoMethodError's parent, so test the gem-bug # pair FIRST: only the scan-itself subject can carry one, and calling # that a broken controller would send a host after their own code for a # bug in ours. ours = result.skipped.select { |subject, error| subject == SCAN_ITSELF && error.instance_of?(NoMethodError) } broken = result.skipped.select { |subject, error| error.is_a?(NameError) && subject != SCAN_ITSELF } detail = result.skipped.map { |subject, error| "#{subject} (#{error.class})" }.uniq.join(", ") = +"[CurrentScope] SodPreflight skipped #{result.skipped.size} check(s), so its " \ "list is INCOMPLETE — absence below is not an all-clear: #{detail}." if ours.any? << " That NoMethodError came from the scan itself, not from your app — it is " \ "a bug in current_scope. Please report it; nothing in your configuration " \ "needs changing for it." end if broken.any? << " #{broken.size} of those did not LOAD (#{broken.map(&:first).uniq.join(', ')}) " \ "— that is a broken controller, not a missing declaration, and the gate will " \ "fail there too." end end |
.warn!(result = scan) ⇒ Object
One message listing every action that will raise, plus the coverage behind an empty list. Log-only.
130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 |
# File 'lib/current_scope/sod_preflight.rb', line 130 def warn!(result = scan) skips = skip_summary(result) Rails.logger&.warn(skips) if skips if result.rows.empty? return unless result.blind? return Rails.logger&.warn((result)) end listed = result.rows.map { |, model| " #{} — #{model.name} defines no #{Resolver::INITIATOR_METHOD}" } Rails.logger&.warn( "[CurrentScope] separation-of-duties preflight: #{result.rows.size} routed action(s) " \ "will raise CurrentScope::ConfigurationError on the first request that reaches them — " \ "in config.enforcement = :report exactly as in :enforce.\n" \ "#{listed.join("\n")}\n" \ "#{fix_line}\n#{caveat}" ) end |