current_scope 0.5.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (58) hide show
  1. checksums.yaml +7 -0
  2. data/MIT-LICENSE +20 -0
  3. data/README.md +377 -0
  4. data/Rakefile +6 -0
  5. data/app/assets/javascripts/current_scope/application.js +229 -0
  6. data/app/assets/stylesheets/current_scope/application.css +917 -0
  7. data/app/controllers/current_scope/application_controller.rb +96 -0
  8. data/app/controllers/current_scope/events_controller.rb +14 -0
  9. data/app/controllers/current_scope/role_assignments_controller.rb +190 -0
  10. data/app/controllers/current_scope/roles_controller.rb +256 -0
  11. data/app/controllers/current_scope/scoped_role_assignments_controller.rb +165 -0
  12. data/app/controllers/current_scope/subjects_controller.rb +75 -0
  13. data/app/helpers/current_scope/application_helper.rb +261 -0
  14. data/app/models/concerns/current_scope/storable_keys.rb +101 -0
  15. data/app/models/current_scope/application_record.rb +5 -0
  16. data/app/models/current_scope/current.rb +74 -0
  17. data/app/models/current_scope/event.rb +136 -0
  18. data/app/models/current_scope/role.rb +106 -0
  19. data/app/models/current_scope/role_assignment.rb +42 -0
  20. data/app/models/current_scope/role_permission.rb +9 -0
  21. data/app/models/current_scope/scoped_role_assignment.rb +98 -0
  22. data/app/views/current_scope/events/index.html.erb +41 -0
  23. data/app/views/current_scope/roles/edit.html.erb +229 -0
  24. data/app/views/current_scope/roles/index.html.erb +55 -0
  25. data/app/views/current_scope/roles/members.html.erb +114 -0
  26. data/app/views/current_scope/roles/new.html.erb +19 -0
  27. data/app/views/current_scope/scoped_role_assignments/new.html.erb +144 -0
  28. data/app/views/current_scope/shared/access_denied.html.erb +30 -0
  29. data/app/views/current_scope/subjects/index.html.erb +124 -0
  30. data/app/views/layouts/current_scope/application.html.erb +56 -0
  31. data/config/routes.rb +13 -0
  32. data/db/migrate/20260710000001_create_current_scope_tables.rb +31 -0
  33. data/db/migrate/20260710000002_create_current_scope_events.rb +32 -0
  34. data/db/migrate/20260714000001_add_description_to_current_scope_roles.rb +5 -0
  35. data/db/migrate/20260805000001_widen_current_scope_polymorphic_ids.rb +165 -0
  36. data/lib/current_scope/configuration.rb +613 -0
  37. data/lib/current_scope/context.rb +41 -0
  38. data/lib/current_scope/engine.rb +202 -0
  39. data/lib/current_scope/gating_reflection.rb +113 -0
  40. data/lib/current_scope/gating_tripwire.rb +84 -0
  41. data/lib/current_scope/grant_diagnosis.rb +216 -0
  42. data/lib/current_scope/guard.rb +750 -0
  43. data/lib/current_scope/mutation_guard.rb +90 -0
  44. data/lib/current_scope/parent_chain.rb +397 -0
  45. data/lib/current_scope/permission_catalog.rb +146 -0
  46. data/lib/current_scope/permission_grid.rb +132 -0
  47. data/lib/current_scope/permissions.rb +94 -0
  48. data/lib/current_scope/resolver.rb +669 -0
  49. data/lib/current_scope/schema_guard.rb +223 -0
  50. data/lib/current_scope/scopeable.rb +38 -0
  51. data/lib/current_scope/sod_preflight.rb +380 -0
  52. data/lib/current_scope/test_helpers.rb +53 -0
  53. data/lib/current_scope/version.rb +3 -0
  54. data/lib/current_scope.rb +432 -0
  55. data/lib/generators/current_scope/install/install_generator.rb +114 -0
  56. data/lib/generators/current_scope/install/templates/initializer.rb +175 -0
  57. data/lib/tasks/current_scope_tasks.rake +454 -0
  58. metadata +123 -0
@@ -0,0 +1,175 @@
1
+ CurrentScope.configure do |config|
2
+ # --- Retrofitting an existing app? Start here ---------------------------
3
+ #
4
+ # This engine is fail-closed: once the gate is mounted, anything not granted
5
+ # is denied. In an app that already has users and traffic, that means your
6
+ # controller suite goes RED and your users get 403s the moment you mount it —
7
+ # not because anything is misconfigured, but because no grants exist yet.
8
+ #
9
+ # So don't cut over blind. Run in report mode first:
10
+ #
11
+ # config.enforcement = :report
12
+ #
13
+ # The gate then LOGS what it would have denied and lets the request through.
14
+ # Exercise the app (or run your suite), then read the gaps back out — each row
15
+ # names a subject and the permission they were missing:
16
+ #
17
+ # bin/rails current_scope:report
18
+ #
19
+ # That list IS your grant-seeding work. Seed the roles it names, watch the
20
+ # would_deny rows stop appearing, then flip to :enforce. Reversible at every
21
+ # step — it's one line back.
22
+ #
23
+ # Report mode is an ADOPTION ramp, not a way to run in production. It relaxes
24
+ # exactly one thing: "nobody has granted this yet". A separation-of-duties
25
+ # veto still refuses, and the management console is never opened by it.
26
+ #
27
+ # config.enforcement = :enforce # :enforce (default) | :report
28
+ # ------------------------------------------------------------------------
29
+
30
+ # What the opt-in GatingTripwire mixin does when it catches an action that
31
+ # completed WITHOUT running the gate. :raise fails loudly (CI goes red);
32
+ # :warn logs once per controller#action and lets the response through, so a
33
+ # real app can inventory its ungated surface without 500ing. There is no
34
+ # :off — not including CurrentScope::GatingTripwire is off.
35
+ # config.gating_tripwire = Rails.env.local? ? :raise : :warn
36
+
37
+ # Controller method that returns the authenticated subject.
38
+ # config.user_method = :current_user
39
+
40
+ # Class whose instances hold roles, used by the management UI.
41
+ # config.subject_class = "User"
42
+
43
+ # How a subject is identified in the management UI (an id is meaningless with
44
+ # UUID keys). A Symbol names a method; a Proc takes the subject. Defaults to a
45
+ # best-effort label (current_scope_label, else name/email/title, else id).
46
+ # config.subject_label = :email
47
+ # config.subject_label = ->(u) { "#{u.first_name} #{u.last_name}" }
48
+
49
+ # Fold the role-editor grid's action columns. Default = CRUD (new/edit hide
50
+ # into create/update; index+show read as one). Set to nil to show every raw
51
+ # action as its own column.
52
+ # config.permission_grid_groups = { "read" => %w[index show], "create" => %w[new create],
53
+ # "update" => %w[edit update], "destroy" => %w[destroy] }
54
+
55
+ # Action names whose record-less gate derives its answer from the scoped
56
+ # list: for these, "may they open this list?" is answered by the same
57
+ # id-narrowed query scope_for renders from, so a scoped full_access role
58
+ # ("Owner of Report #7") opens exactly the collections that would show it
59
+ # records — gate and list agree by construction. Matched on the action
60
+ # segment of the key, like sod_actions. Default ["index"]; set [] to
61
+ # restore the pre-#65 behavior (explicit ticks still open type-bound
62
+ # record-less gates; scoped full_access opens none). A full key
63
+ # ("reports#index") raises — the list is action-segment matched, app-wide —
64
+ # and a canonical mutating name (create/update/destroy) warns at assignment.
65
+ #
66
+ # LIST-NARROWING READS ONLY: the safety of honoring full_access here comes
67
+ # from the answer being derived from record ids, so it is only sound for
68
+ # actions with a list side. Never name a mutating action ("create",
69
+ # "destroy_all") — that would hand a scoped full_access holder the action
70
+ # on every record of the type off a grant on one record. Custom read
71
+ # actions (export, search) are the intended additions. The declared
72
+ # current_scope_model is trusted like current_scope_record: review both.
73
+ # config.collection_read_actions = %w[index]
74
+
75
+ # --- Impersonation (act-as) ---------------------------------------------
76
+ # These three knobs layer, in this order:
77
+ #
78
+ # 1. actor_method — turns impersonation ON.
79
+ # 2. allow_mutations_while_impersonating — the read-only gate runs FIRST.
80
+ # 3. sod_identity — only observable once a mutation
81
+ # is allowed through (or on a
82
+ # GET-listed sod_action).
83
+ #
84
+ # Controller method returning the REAL actor while impersonating. Leave unset
85
+ # when no one impersonates — actor then equals the subject, so attribution
86
+ # reads current_scope_actor with no nil branch, and sod_identity below has no
87
+ # effect. See the "Impersonation (act-as)" section of the README.
88
+ # config.actor_method = :true_user
89
+
90
+ # false (default): an impersonated session is read-only — every non-GET/HEAD
91
+ # request is denied, INCLUDING the engine's management UI. Your
92
+ # stop-impersonation, sign-out, and sign-in endpoints must opt out with
93
+ # skip_before_action :current_scope_mutation_guard!, or impersonation (and
94
+ # sign-in) can never clear it.
95
+ # config.allow_mutations_while_impersonating = false
96
+
97
+ # Which identities the separation-of-duties veto weighs. :either (default)
98
+ # also vetoes a record the REAL actor initiated while impersonating, so
99
+ # impersonation can never approve your own record. :subject weighs only the
100
+ # effective subject. Identical when not impersonating.
101
+ # config.sod_identity = :either
102
+ # ------------------------------------------------------------------------
103
+
104
+ # Separation of duties is OPT-IN — empty by default. List the actions a
105
+ # record's initiator can never perform on their own record (four-eyes).
106
+ # Deliberately NOT editable in the UI. Records reached by these actions must
107
+ # define current_scope_initiator (return nil to exempt a record type) — the
108
+ # resolver raises if the hook is missing, in :report mode as well as :enforce.
109
+ # The engine logs a preflight warning naming the actions it can see this
110
+ # coming for (at boot in production; on the first request in development,
111
+ # whose route set is lazy); `rails current_scope:report` lists them too. Leave
112
+ # commented for RBAC-only apps.
113
+ # config.sod_actions = %w[approve]
114
+
115
+ # Audit ledger — tri-state: false | true (default) | :strict.
116
+ # false — no audit rows recorded.
117
+ # true — record management-UI mutations, impersonation boundary events,
118
+ # and grant!/rake/seeds bootstrap grants; if the events table
119
+ # hasn't been migrated yet, degrade gracefully (skip + warn once).
120
+ # Direct model writes and test helpers are not recorded.
121
+ # :strict — a missing events table RAISES (rolling the mutation back), so an
122
+ # audit-mandatory app never commits an unaudited change.
123
+ # config.audit = true
124
+
125
+ # --- Dev diagnostics -----------------------------------------------------
126
+ # Four failure modes this engine has that are SILENT, and silent in the bad
127
+ # direction — the thing going wrong looks exactly like the thing going right.
128
+ # All four are LOG-ONLY (no decision, exception, header, or audit row changes)
129
+ # and all four default ON in development and test, OFF in production.
130
+ #
131
+ # They are listed here rather than left to the docs on purpose: a named flag in
132
+ # your initializer is how you learn the failure mode exists at all.
133
+
134
+ # The SoD veto was SKIPPED because the gate had no record — an SoD member
135
+ # action whose current_scope_record returned nil (or was never declared). The
136
+ # request was ALLOWED, and a skipped veto looks identical to a veto that
137
+ # passed. The gem's #1 foot-gun.
138
+ # config.warn_on_nil_sod_record = Rails.env.local?
139
+
140
+ # Denied "no_grant", but the subject holds a scoped grant that WOULD have
141
+ # applied — and the controller declares no current_scope_record, so the gate
142
+ # had no record to apply it to. A member action that forgot its hook: it fails
143
+ # closed (correctly), but the 403 is indistinguishable from "never granted", so
144
+ # you go and stare at the grants, which are fine.
145
+ # config.warn_on_inert_scoped_grant = Rails.env.local?
146
+
147
+ # Short-form allowed_to?(:show, record) derived a DIFFERENT key than the gate
148
+ # on the current controller enforces (the namespaced/custom-named controller
149
+ # foot-gun): a link that 403s, or a hidden one that would have worked. A hint,
150
+ # not an accusation — asking about another resource derives a different key too,
151
+ # and that's correct — so it warns once per site and names both readings.
152
+ # config.warn_on_cross_controller_derivation = Rails.env.local?
153
+
154
+ # Denied "model_undeclared": a collection action declared with
155
+ # `current_scope_record = nil` on a controller that names no
156
+ # current_scope_model, while the subject holds a scoped grant ticking the
157
+ # key. The gate had no type to bind that grant to, so it failed closed —
158
+ # correctly, but the fix is one line: `def current_scope_model = TheType`.
159
+ # The same flag covers "model_invalid" — a declared hook returning
160
+ # something other than a concrete AR class ("Report" for Report); that
161
+ # nudge names the value the hook returned.
162
+ # config.warn_on_undeclared_collection_model = Rails.env.local?
163
+ # ------------------------------------------------------------------------
164
+
165
+ # Controller paths (regexps) excluded from the permission grid. Excluded
166
+ # controllers can't be granted, so they must also skip the gate with
167
+ # skip_before_action :current_scope_check! — Guard raises otherwise.
168
+ # Skipping the gate leaves the controller ungated by CurrentScope — protect
169
+ # it with your own authorization (e.g. require_admin!). See
170
+ # https://github.com/davidteren/current_scope/blob/main/docs/SECURITY-CHECKLIST.md
171
+ # config.excluded_controllers += [%r{\Awebhooks/}]
172
+
173
+ # Controller the management UI inherits from (for host auth + before_actions).
174
+ # config.parent_controller = "::ApplicationController"
175
+ end
@@ -0,0 +1,454 @@
1
+ namespace :current_scope do
2
+ desc "Apply the #151 grant-column shape to an existing database, idempotently. " \
3
+ "Usage: bin/rails current_scope:repair_schema"
4
+ task repair_schema: :environment do
5
+ # WHY THIS EXISTS SEPARATELY FROM db:migrate.
6
+ #
7
+ # A database built by `db:schema:load` / `db:setup` / `db:test:prepare` —
8
+ # every new app, every CI run, every fresh checkout — comes out with the
9
+ # right column TYPE and the server's default collation, because schema.rb
10
+ # cannot express a MySQL collation. Loading a schema also stamps every
11
+ # migration version as applied, so `db:migrate` has nothing pending and
12
+ # prints nothing. On MySQL that left a host unable to boot, with the boot
13
+ # error prescribing a command that could not possibly fix it.
14
+ #
15
+ # This task re-applies the migration's own logic directly. It is idempotent:
16
+ # where the columns are already correct it changes nothing.
17
+ path = Dir[CurrentScope::Engine.root.join("db/migrate/*_widen_current_scope_polymorphic_ids.rb")].first
18
+ abort "Could not find the widening migration inside the gem." if path.nil?
19
+
20
+ load path
21
+ migration = WidenCurrentScopePolymorphicIds.new
22
+ migration.verbose = false
23
+ migration.migrate(:up)
24
+
25
+ # Say what this adapter actually did. The binary collation is a MySQL-only
26
+ # step — PostgreSQL and SQLite already compare these columns byte for byte —
27
+ # so claiming it everywhere would tell a PostgreSQL operator their columns
28
+ # were re-collated when nothing of the sort happened.
29
+ shape = "#{CurrentScope::KEY_LIMIT}-character"
30
+ shape += ", binary-collated" if CurrentScope.mysql?(CurrentScope::RoleAssignment.connection)
31
+ puts "CurrentScope grant columns are in the #{shape} shape #151 requires."
32
+ end
33
+
34
+ desc "Grant the full-access Owner role to a subject (bootstrap the first admin). " \
35
+ "Usage: bin/rails current_scope:grant SUBJECT_ID=1"
36
+ task grant: :environment do
37
+ id = ENV["SUBJECT_ID"]
38
+ abort "SUBJECT_ID is required, e.g. bin/rails current_scope:grant SUBJECT_ID=1" if id.blank?
39
+
40
+ klass = CurrentScope.config.subject_class.constantize
41
+ subject = klass.find_by(id: id)
42
+ abort "No #{klass} with id=#{id}" if subject.nil?
43
+
44
+ # grant! seeds Owner on the default path — warn on replacement even when
45
+ # the Owner row does not exist yet (first-time Owner creation).
46
+ prior = CurrentScope::RoleAssignment.find_by(subject: subject)&.role
47
+ if prior && prior.name != "Owner"
48
+ warn "WARNING: #{klass}##{subject.id} already held the #{prior.name.inspect} role — " \
49
+ "replacing it with full-access Owner."
50
+ end
51
+
52
+ CurrentScope.grant!(subject)
53
+ puts "Granted the full-access Owner role to #{klass}##{subject.id}."
54
+ end
55
+
56
+ desc "Summarize would-be denials recorded in report mode into a starter role grid. " \
57
+ "Usage: bin/rails current_scope:report"
58
+ task report: :environment do
59
+ # The subject's current org-wide role, when resolvable — the grid reads
60
+ # differently if someone already holds a role that just doesn't tick these
61
+ # keys. Best-effort: a rollout aid must not abort everyone else's summary
62
+ # because one subject's record was deleted or its class no longer loads.
63
+ # A lambda, not a def — a rake file's `def` lands on Object.
64
+ org_role_suffix = lambda do |subject_gid|
65
+ subject = GlobalID::Locator.locate(subject_gid)
66
+ role = subject && CurrentScope::RoleAssignment.find_by(subject: subject)&.role
67
+ role ? " — currently #{role.name}" : ""
68
+ rescue StandardError
69
+ ""
70
+ end
71
+
72
+ begin
73
+ rows = CurrentScope::Event.where(event: "access.would_deny")
74
+ .pluck(:subject, :target_label, :details)
75
+ # #73: SoD blind-spot 403s are NOT would_deny (granting won't fix them).
76
+ # Surface them as a separate section so the survey is complete.
77
+ blind_rows = CurrentScope::Event.where(event: "access.sod_blind_spot")
78
+ .pluck(:subject, :target_label, :details)
79
+ # #133: an SoD action whose model defines no current_scope_initiator did
80
+ # not 403 — it RAISED. Its own section, because it is the one report-mode
81
+ # outcome that is a 500, and neither granting nor the record hook fixes it.
82
+ initiator_rows = CurrentScope::Event.where(event: "access.sod_initiator_missing")
83
+ .pluck(:subject, :target_label, :details)
84
+ rescue ActiveRecord::StatementInvalid => e
85
+ # Report mode without the migration records nothing (the ledger degrades and
86
+ # warns once). Reaching for this summary is exactly how a host discovers
87
+ # that, so it must name the fix rather than raise a stack trace at them.
88
+ raise unless e.message.match?(/current_scope_events/i)
89
+
90
+ abort "The current_scope_events table doesn't exist, so nothing was recorded.\n" \
91
+ "Run: bin/rails current_scope:install:migrations && bin/rails db:migrate"
92
+ end
93
+
94
+ # #134: these two are STATIC — derived from the grants table, not the
95
+ # ledger — so they are present with zero traffic. That is the whole point:
96
+ # a grant that can never match is most likely to exist BEFORE report mode
97
+ # was ever exercised, which is exactly when the ledger is empty.
98
+ # find_each, not a full load: a host's grants table can be large and this is
99
+ # a scan. Verdict computed once and handed to the advisory, which would
100
+ # otherwise recompute it (and re-query role_permissions) per grant.
101
+ # #133: static too, and for the same reason the grant scans are — an SoD
102
+ # action with no initiator behind it exists before report mode is ever
103
+ # exercised, which is exactly when the ledger is empty. Never raises; it
104
+ # degrades to no findings and logs.
105
+ # One scan, carried as a value. Nothing below has to reason about whether
106
+ # some other call has since reset a flag on the module.
107
+ preflight = CurrentScope::SodPreflight.scan
108
+ preflight_rows = preflight.rows
109
+
110
+ dead_grants = []
111
+ untargeted_grants = []
112
+ begin
113
+ CurrentScope::ScopedRoleAssignment.includes(role: :role_permissions)
114
+ .in_batches(of: 500) do |relation|
115
+ batch = relation.to_a
116
+ # orphaned? reads the polymorphic resource, which includes() cannot
117
+ # cover — without this the scan costs one extra query per grant.
118
+ CurrentScope::ScopedRoleAssignment.preload_resolvable_resources!(batch)
119
+ batch.each do |grant|
120
+ verdict = CurrentScope::GrantDiagnosis.verdict_for(grant)
121
+ if verdict
122
+ dead_grants << [ grant, verdict ]
123
+ elsif CurrentScope::GrantDiagnosis.type_untargeted?(grant, verdict: verdict)
124
+ untargeted_grants << grant
125
+ end
126
+ end
127
+ end
128
+ rescue ActiveRecord::ActiveRecordError => e
129
+ # This scan is an ADDITION to the ledger survey, so a database problem
130
+ # here must not take the whole task down — the would-deny summary is the
131
+ # part a host is mid-rollout depending on. Degrade to no static findings
132
+ # and say so. (cubic)
133
+ warn "[CurrentScope] could not scan scoped grants (#{e.class}: #{e.message}); " \
134
+ "skipping the static grant sections."
135
+ dead_grants = []
136
+ untargeted_grants = []
137
+ end
138
+
139
+ # Same rescue the org_role_suffix lambda above needs, for the same reason: a
140
+ # rollout aid must not abort the whole survey because one subject's class was
141
+ # removed. Without it this task now raises NameError where it never did.
142
+ grant_line = lambda do |grant|
143
+ # Through the canonical guard: a non-canonical stored subject_id must not
144
+ # be labeled as the unrelated live record it would cast into (#151).
145
+ subject = grant.current_scope_resolved_record("subject")
146
+ who =
147
+ begin
148
+ subject ? CurrentScope.label_for(subject) : "#{grant.subject_type} ##{grant.subject_id}"
149
+ rescue StandardError
150
+ "#{grant.subject_type} ##{grant.subject_id}"
151
+ end
152
+ " #{who} — role \"#{grant.role&.name}\" on #{grant.resource_type}##{grant.resource_id}"
153
+ end
154
+
155
+ # One running flag, not a per-section list of every section before it. That
156
+ # chain grew a term each time a section was added (#134 added two, #133 two
157
+ # more), and it made every new section an edit to every LATER section's
158
+ # guard — miss one and the task prints a wrong blank line that no test
159
+ # catches. `separate` emits the blank only when something already printed.
160
+ printed_section = false
161
+ separate = lambda do
162
+ puts if printed_section
163
+ printed_section = true
164
+ end
165
+
166
+ # THE QUESTION THIS TASK IS RUN TO ANSWER is "can I flip to :enforce yet?",
167
+ # and six independently-gated sections made the reader OR them together by
168
+ # hand. One count line first, so the answer is on screen before the detail.
169
+ #
170
+ # Deliberately NOT a verdict. "Safe to enforce" is not a claim this task can
171
+ # make: the preflight is partial by construction, the ledger is historical
172
+ # and only as complete as the traffic that has run, and neither can see a
173
+ # controller nobody exercised. It reports what it FOUND and names what it
174
+ # cannot see — the same rule the preflight caveat and the ungated task
175
+ # follow. (#133 review)
176
+ signals = {
177
+ "would-be denials (grant these)" => rows.count,
178
+ "scoped grants that can never match" => dead_grants.count,
179
+ "scoped grants worth checking" => untargeted_grants.count,
180
+ "SoD actions that will RAISE (500, not grantable)" => preflight_rows.count,
181
+ "SoD blind-spot denials (not grantable)" => blind_rows.count,
182
+ "SoD actions that already RAISED (500, not grantable)" => initiator_rows.count
183
+ }.reject { |_label, count| count.zero? }
184
+
185
+ separate.call
186
+ if signals.empty?
187
+ puts "CurrentScope report: nothing found in any category."
188
+ else
189
+ puts "CurrentScope report — #{signals.count} category(ies) with something in them:"
190
+ signals.each { |label, count| puts " #{count.to_s.rjust(6)} #{label}" }
191
+ end
192
+ puts
193
+ # The SoD clause only when the host opted into SoD. A project that never set
194
+ # config.sod_actions has no preflight to qualify, and naming one is noise
195
+ # about a feature they do not use — pinned by "no SoD config means no
196
+ # preflight section at all".
197
+ caveat_line = +" This is a survey, not a clearance: the ledger only knows traffic that " \
198
+ "has already run."
199
+ if CurrentScope.config.sod_actions.any?
200
+ caveat_line << " The SoD preflight is also partial by construction (its own note says how)."
201
+ caveat_line << " It could not complete this run." if preflight.degraded?
202
+ end
203
+ puts caveat_line
204
+
205
+ # Still the ledger guard, but it no longer RETURNS: the sections below are
206
+ # derived from the grants table, not the ledger. (#134)
207
+ if rows.empty? && blind_rows.empty? && initiator_rows.empty?
208
+ separate.call
209
+ # "No output" is indistinguishable from "the task is broken", and the two
210
+ # likeliest causes are both SILENT: report mode never on, or audit off.
211
+ # Name them — this is the first thing a host runs, and an unexplained blank
212
+ # is how they conclude the feature doesn't work.
213
+ puts "No would-be denials recorded."
214
+ puts
215
+ puts " config.enforcement is #{CurrentScope.config.enforcement.inspect} " \
216
+ "(needs :report to record any)"
217
+ puts " config.audit is #{CurrentScope.config.audit.inspect} " \
218
+ "(needs true or :strict — the ledger is where these rows live)"
219
+ puts
220
+ puts "With both on, exercise the app or run your suite, then re-run this."
221
+ # NOT `next`. The sections below are derived from the grants table and the
222
+ # routes, not the ledger, so they are present with zero traffic — which is
223
+ # exactly when a grant that can never match is most likely to exist.
224
+ # Returning here would hide them in that case. The config explanation
225
+ # above still prints, because "nothing was recorded" stays true and
226
+ # unexplained silence is how a host concludes the feature does not
227
+ # work. (#134) The blank line before whatever follows is `separate`'s job
228
+ # now, so this branch no longer has to look ahead at the other sections.
229
+ end
230
+
231
+ # ponytail: group in Ruby, not SQL. `details` is a JSON column and querying
232
+ # into it is adapter-specific; this is a rollout aid run by hand over a
233
+ # transitional table, so portability beats a smarter query.
234
+ #
235
+ # Shared tally so would_deny and sod_blind_spot sections cannot drift on
236
+ # ordering / unknown-permission handling (PR #103 review).
237
+ print_permission_counts = lambda do |event_rows|
238
+ event_rows
239
+ .group_by { |_s, _l, details| details.is_a?(Hash) ? details["permission"] : nil }
240
+ .transform_values(&:count)
241
+ .sort_by { |permission, count| [ -count, permission.to_s ] }
242
+ .each { |permission, count| puts " #{count.to_s.rjust(5)}x #{permission || '(unknown)'}" }
243
+ end
244
+
245
+ unless rows.empty?
246
+ separate.call
247
+ grouped = rows.group_by { |subject, _label, _details| subject }
248
+
249
+ puts "Would-be denials — grant these to stop them (most-denied first):"
250
+ puts
251
+
252
+ grouped.each do |subject_gid, subject_rows|
253
+ label = subject_rows.first[1].presence || subject_gid
254
+ puts " #{label}#{org_role_suffix.call(subject_gid)}"
255
+ print_permission_counts.call(subject_rows)
256
+ puts
257
+ end
258
+
259
+ puts "Total: #{rows.count} would-be denials across #{grouped.size} subject(s)."
260
+ end
261
+
262
+ unless dead_grants.empty?
263
+ separate.call
264
+ puts "Scoped grants that can never match — granting more will not help:"
265
+ puts
266
+ dead_grants.group_by { |_g, verdict| verdict }.each do |verdict, pairs|
267
+ puts " #{CurrentScope::GrantDiagnosis.verdict_label(verdict)}"
268
+ pairs.each { |grant, _v| puts grant_line.call(grant) }
269
+ puts " → #{CurrentScope::GrantDiagnosis.verdict_fix(verdict)}"
270
+ puts
271
+ end
272
+ puts "Total: #{dead_grants.count} grant(s) that cannot match any gated action."
273
+ end
274
+
275
+ unless untargeted_grants.empty?
276
+ separate.call
277
+ puts "Worth checking — no ticked key targets this grant's type:"
278
+ puts
279
+ untargeted_grants.each { |grant| puts grant_line.call(grant) }
280
+ puts
281
+ puts " #{CurrentScope::GrantDiagnosis.untargeted_caveat}"
282
+ end
283
+
284
+ # An EMPTY preflight still speaks when SoD is on. Suppressing the section
285
+ # entirely made a check that blew up (a host hook that raises, no database
286
+ # connection yet, a controller that will not load) look identical on stdout
287
+ # to a check that ran clean — and the PARTIAL caveat, the thing that stops
288
+ # this being read as a verdict, lived inside the suppressed branch. The
289
+ # degrade warning goes to the log, not to the terminal the operator is
290
+ # reading right before an enforce flip. Same rule as the ungated task: a
291
+ # vacuous all-clear is worse than a blank. (#133 review)
292
+ if preflight_rows.empty? && CurrentScope.config.sod_actions.any?
293
+ separate.call
294
+ if preflight.degraded?
295
+ puts "Separation-of-duties preflight: COULD NOT COMPLETE — this section is incomplete."
296
+ puts
297
+ # The reason printed HERE rather than "see the log": an operator reading
298
+ # a terminal must not be sent off to find a log file.
299
+ puts " #{CurrentScope::SodPreflight.skip_summary(preflight)}"
300
+ puts " Do NOT read the absence of findings below as an all-clear."
301
+ else
302
+ puts "Separation-of-duties preflight: inspected #{preflight.inspected} of " \
303
+ "#{preflight.in_scope} routed SoD action(s); none named a model missing " \
304
+ "#{CurrentScope::Resolver::INITIATOR_METHOD}."
305
+ if preflight.blind?
306
+ puts
307
+ puts " NOTHING was inspected — none of those controllers declares " \
308
+ "current_scope_model, so there was nothing for this check to read. This is " \
309
+ "not an all-clear."
310
+ end
311
+ end
312
+ puts
313
+ puts " #{CurrentScope::SodPreflight.caveat}"
314
+ end
315
+
316
+ unless preflight_rows.empty?
317
+ separate.call
318
+ puts "Separation-of-duties actions that will RAISE (500) — not a denial, a misconfiguration:"
319
+ puts
320
+ preflight_rows.each do |permission, model|
321
+ puts " #{permission} — #{model.name} defines no " \
322
+ "#{CurrentScope::Resolver::INITIATOR_METHOD}"
323
+ end
324
+ puts
325
+ # SHARED with the boot warning, not re-spelled. The remedies are not
326
+ # coequal on a list that can be wrong — defining the hook wires a control,
327
+ # removing the action deletes one — and a private copy here is how that
328
+ # correction reached one surface and not the other for a commit. (#133)
329
+ puts " #{CurrentScope::SodPreflight.fix_line}"
330
+ puts " Report mode does NOT downgrade these — the request 500s exactly as it would " \
331
+ "under :enforce."
332
+ puts
333
+ puts " #{CurrentScope::SodPreflight.caveat}"
334
+ end
335
+
336
+ unless blind_rows.empty?
337
+ separate.call
338
+ puts "SoD blind-spot denials — NOT fixed by granting (declare current_scope_record):"
339
+ puts
340
+ print_permission_counts.call(blind_rows)
341
+ puts
342
+ puts "Total: #{blind_rows.count} blind-spot 403(s). Granting these permissions will not " \
343
+ "clear them — fix the record hook (or remove the action from config.sod_actions)."
344
+ end
345
+
346
+ # #133: the traffic-found half. The static section above catches these only
347
+ # where a controller declares current_scope_model; these rows are the ones
348
+ # that reached a real request first, so they name the model the gate ACTUALLY
349
+ # held — a proof where the static list is a lead.
350
+ unless initiator_rows.empty?
351
+ separate.call
352
+ puts "SoD actions that RAISED (500s) — NOT fixed by granting, a missing " \
353
+ "current_scope_initiator:"
354
+ puts
355
+ initiator_rows
356
+ .group_by { |_s, _l, details| details.is_a?(Hash) ? [ details["permission"], details["model"] ] : nil }
357
+ .transform_values(&:count)
358
+ .sort_by { |pair, count| [ -count, pair.to_a.map(&:to_s) ] }
359
+ .each do |pair, count|
360
+ permission, model = pair
361
+ puts " #{count.to_s.rjust(5)}x #{permission || '(unknown)'} — " \
362
+ "#{model || '(unknown model)'}"
363
+ end
364
+ puts
365
+ puts "Total: #{initiator_rows.count} raised request(s). These are NOT denials and granting " \
366
+ "changes nothing — define #{CurrentScope::Resolver::INITIATOR_METHOD} on each model " \
367
+ "listed (return nil to exempt a record), or remove the action from config.sod_actions."
368
+ end
369
+ end
370
+
371
+ desc "Inventory the routed controllers that provably never run the gate — the static " \
372
+ "half of the ungated-surface audit (config.gating_tripwire = :warn is the runtime half). " \
373
+ "Usage: bin/rails current_scope:ungated"
374
+ task ungated: :environment do
375
+ # One reflection for the whole walk — its request object memoizes (KTD-8).
376
+ # A broken controller body's NameError propagates on purpose (KTD-2): a
377
+ # rescue here would report a broken controller as gated.
378
+ gating = CurrentScope::GatingReflection.new
379
+ catalog = CurrentScope.catalog
380
+ grouped = catalog.grouped
381
+
382
+ # The catalog injects the break-glass key onto any row routing an SoD
383
+ # action, and that grant is LIVE even on an ungated controller — honored by
384
+ # whatever gated controller decides SoD on the record (the grid's own
385
+ # KTD-9 exemption). Printing it under "grants nothing" would tell an
386
+ # operator the most sensitive grant in the grid is inert. Strip it from
387
+ # the listing and say so once. Only the INJECTED key is stripped —
388
+ # catalog.routed? keeps a real routed action that merely shares the bypass
389
+ # name in the audit, because omitting it would hide a real fail-open route.
390
+ # The catalog also owns the permission parse (split("#", -1) + shape
391
+ # checks) — a loose split here would accept a malformed value. (#79 review)
392
+ bypass_action = CurrentScope.config.allow_sod_bypass ? catalog.bypass_action : nil
393
+ stripped_bypass = false
394
+
395
+ # Build the printable rows BEFORE deciding emptiness: a synthetic
396
+ # bypass-only row (a namespace-only SoD resource) reflects as "ungated"
397
+ # while routing nothing, and a header over an empty body reads as a broken
398
+ # task. Rows first, then branch on what there is to say.
399
+ rows = grouped.keys.sort.filter_map { |controller|
400
+ next unless gating.ungated?(controller)
401
+
402
+ actions = grouped[controller].sort
403
+ if bypass_action && actions.include?(bypass_action) && !catalog.routed?("#{controller}##{bypass_action}")
404
+ actions -= [ bypass_action ]
405
+ stripped_bypass = true
406
+ end
407
+ next if actions.empty? # nothing routed here — nothing to audit
408
+
409
+ [ controller, actions ]
410
+ }
411
+
412
+ if grouped.empty?
413
+ # A vacuous all-clear is worse than a blank: with nothing routed there
414
+ # was nothing to inspect, and "every routed controller has the callback"
415
+ # is technically true of an empty set and completely misleading.
416
+ puts "No routed controllers found in the permission catalog — nothing was " \
417
+ "inspected. Check your routes and config.excluded_controllers."
418
+ elsif rows.empty?
419
+ # An unexplained blank reads as "the task is broken" — and a bare blank
420
+ # would also overclaim. Claim only what the reflection proved: nothing
421
+ # was PROVEN ungated. A route whose controller doesn't resolve is
422
+ # unclassified, not vouched for (#43 owns that badge) — "every controller
423
+ # has the callback" would vouch for rows nobody inspected.
424
+ puts "No controller was proven ungated. (A routed path whose controller " \
425
+ "does not resolve is unclassified, not verified — see issue #43.)"
426
+ else
427
+ puts "Provably ungated — current_scope_check! is absent from these controllers' " \
428
+ "callback chains, so the gate never runs there:"
429
+ puts
430
+ rows.each { |controller, actions| puts " #{controller} (#{actions.join(', ')})" }
431
+ puts
432
+ puts "Ticking these in the role grid grants nothing until the gate runs. " \
433
+ "If a controller inherited a skip, re-assert before_action " \
434
+ ":current_scope_check! on it; if it never had the gate, include " \
435
+ "CurrentScope::Guard."
436
+ if stripped_bypass
437
+ puts
438
+ puts "(#{bypass_action} omitted from the listing — break-glass stays LIVE " \
439
+ "even on an ungated controller; see the role grid's exempt note.)"
440
+ end
441
+ end
442
+
443
+ # The limit of the proof, stated even when nothing is listed (KTD-3): a
444
+ # conditional skip (skip_before_action only:/except:) leaves the callback
445
+ # PRESENT wearing a condition — unprovable by reflection, so never shown
446
+ # here even though some of its actions really run open. The runtime half
447
+ # catches those.
448
+ puts
449
+ puts "Limit: this lists only what the callback chain PROVES. A conditional skip " \
450
+ "(skip_before_action only:/except:) does not appear here — set " \
451
+ "config.gating_tripwire = :warn and include CurrentScope::GatingTripwire " \
452
+ "to inventory those at runtime."
453
+ end
454
+ end