current_scope 0.2.0 → 0.3.1

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.

Potentially problematic release.


This version of current_scope might be problematic. Click here for more details.

Files changed (36) hide show
  1. checksums.yaml +4 -4
  2. data/README.md +317 -18
  3. data/app/assets/javascripts/current_scope/application.js +4 -0
  4. data/app/assets/stylesheets/current_scope/application.css +101 -0
  5. data/app/controllers/current_scope/application_controller.rb +39 -1
  6. data/app/controllers/current_scope/role_assignments_controller.rb +110 -25
  7. data/app/controllers/current_scope/roles_controller.rb +130 -32
  8. data/app/helpers/current_scope/application_helper.rb +168 -12
  9. data/app/models/current_scope/current.rb +24 -0
  10. data/app/models/current_scope/event.rb +10 -6
  11. data/app/models/current_scope/role.rb +61 -10
  12. data/app/views/current_scope/roles/edit.html.erb +92 -4
  13. data/app/views/current_scope/roles/index.html.erb +16 -2
  14. data/app/views/current_scope/roles/members.html.erb +3 -3
  15. data/app/views/current_scope/roles/new.html.erb +1 -1
  16. data/app/views/current_scope/scoped_role_assignments/new.html.erb +19 -10
  17. data/app/views/current_scope/shared/access_denied.html.erb +30 -0
  18. data/app/views/current_scope/subjects/index.html.erb +21 -7
  19. data/app/views/layouts/current_scope/application.html.erb +4 -1
  20. data/config/routes.rb +3 -4
  21. data/lib/current_scope/configuration.rb +411 -18
  22. data/lib/current_scope/engine.rb +7 -0
  23. data/lib/current_scope/gating_reflection.rb +62 -0
  24. data/lib/current_scope/gating_tripwire.rb +36 -5
  25. data/lib/current_scope/guard.rb +410 -10
  26. data/lib/current_scope/mutation_guard.rb +30 -5
  27. data/lib/current_scope/permission_catalog.rb +116 -3
  28. data/lib/current_scope/permission_grid.rb +34 -4
  29. data/lib/current_scope/permissions.rb +45 -8
  30. data/lib/current_scope/resolver.rb +317 -13
  31. data/lib/current_scope/version.rb +1 -1
  32. data/lib/current_scope.rb +113 -5
  33. data/lib/generators/current_scope/install/install_generator.rb +64 -0
  34. data/lib/generators/current_scope/install/templates/initializer.rb +94 -5
  35. data/lib/tasks/current_scope_tasks.rake +153 -0
  36. metadata +6 -2
data/lib/current_scope.rb CHANGED
@@ -9,12 +9,30 @@ require "current_scope/scopeable"
9
9
  require "current_scope/mutation_guard"
10
10
  require "current_scope/guard"
11
11
  require "current_scope/gating_tripwire"
12
+ require "current_scope/gating_reflection"
12
13
  require "current_scope/engine"
13
14
 
14
15
  module CurrentScope
15
16
  # Raised when the resolver denies an action gated by Guard (or when the
16
17
  # management UI is accessed without a full-access role). Carries an optional
17
- # machine-readable reason (:sod_veto, :no_grant, :impersonation_gate).
18
+ # machine-readable reason, surfaced on the response as X-Current-Scope-Reason
19
+ # by current_scope_denied:
20
+ #
21
+ # :sod_veto — the record's initiator can't perform an SoD action on it
22
+ # :no_grant — nothing granted the permission (the default deny)
23
+ # :model_undeclared — a record-less deny that a scoped grant would have
24
+ # opened, had the controller declared current_scope_model
25
+ # to bind it to a type (#50). Fail-closed, with the fix named.
26
+ # :model_invalid — its sibling: current_scope_model WAS declared but
27
+ # returned something the shape guard refuses (a String,
28
+ # an instance, an abstract class — not a concrete AR
29
+ # class). Same cell, different fix, so a different label.
30
+ # :impersonation_gate — a mutation while impersonating, which is read-only
31
+ # :not_full_access — the engine's management UI, which only full_access enters
32
+ #
33
+ # Every denial in the gem raises this and lands in current_scope_denied, so a
34
+ # denial cannot exist that forgets its reason. (:sod_bypassed is the one
35
+ # audited ALLOW, so it is set by the Guard rather than raised here.)
18
36
  class AccessDenied < StandardError
19
37
  attr_reader :reason
20
38
 
@@ -50,6 +68,14 @@ module CurrentScope
50
68
  @catalog = nil
51
69
  end
52
70
 
71
+ # The cross-controller nudge warns once per site (see below). That latch is
72
+ # per-process, so it must be clearable: a leaked one silently disarms the
73
+ # warning for every later test and makes the suite order-dependent. Also
74
+ # cleared on engine to_prepare, since a reload can change what's routed.
75
+ def reset_cross_controller_warnings!
76
+ @cross_controller_warned = nil
77
+ end
78
+
53
79
  # Models that opted into the scoped-role picker via CurrentScope::Scopeable.
54
80
  # Stored as class-name strings and resolved lazily so dev-mode reloading
55
81
  # never pins a stale constant. Rebuilt from scratch on every engine
@@ -74,12 +100,13 @@ module CurrentScope
74
100
  # `action` is either a full permission key ("admin/reports#approve") or a
75
101
  # bare action name resolved against `record`'s route key, falling back to
76
102
  # `controller_path`.
77
- def allowed?(action, subject:, record: nil, controller_path: nil, actor: nil)
103
+ def allowed?(action, subject:, record: nil, controller_path: nil, actor: nil, model: nil)
78
104
  resolver.allow?(
79
105
  subject: subject,
80
106
  permission: permission_key(action, record: record, controller_path: controller_path),
81
107
  record: record,
82
- actor: actor
108
+ actor: actor,
109
+ model: model
83
110
  )
84
111
  end
85
112
 
@@ -91,6 +118,24 @@ module CurrentScope
91
118
  resolver.scope_for(subject: subject, model: model, permission: permission)
92
119
  end
93
120
 
121
+ # THE human-label fallback chain, shared by the UI helpers
122
+ # (ApplicationHelper#current_scope_label) and the audit ledger
123
+ # (Event.label_for) — one definition, so a record can never render as
124
+ # "Apollo" on screen while being frozen into the ledger as "Project #7".
125
+ # Chain: the record's own current_scope_label (Scopeable provides one) →
126
+ # human identifiers (name/email/title) → "Model #id" → to_s. Returns nil
127
+ # for nil; callers choose their own nil presentation ("(none)" in views).
128
+ def label_for(record)
129
+ return if record.nil?
130
+ return record.current_scope_label if record.respond_to?(:current_scope_label)
131
+
132
+ name = record.try(:name).presence || record.try(:email).presence || record.try(:title).presence
133
+ return name if name
134
+ return "#{record.model_name.human} ##{record.id}" if record.respond_to?(:model_name)
135
+
136
+ record.to_s
137
+ end
138
+
94
139
  def permission_key(action, record: nil, controller_path: nil)
95
140
  action = action.to_s
96
141
  return action if action.include?("#")
@@ -102,6 +147,7 @@ module CurrentScope
102
147
  # Guard enforces, so prefer it: the view must agree with the gate.
103
148
  return "#{controller_path}##{action}" if controller_path&.split("/")&.last == route_key
104
149
 
150
+ warn_on_cross_controller_derivation(action, route_key, controller_path)
105
151
  return "#{route_key}##{action}"
106
152
  end
107
153
  return "#{controller_path}##{action}" if controller_path
@@ -111,6 +157,7 @@ module CurrentScope
111
157
  "a full \"controller#action\" string, or call from a controller/view"
112
158
  end
113
159
 
160
+
114
161
  # Impersonation boundary events. The impersonated identity is an EXPLICIT
115
162
  # argument (not read from the ambient pair): at act-as START the ambient
116
163
  # actor still equals the effective user — Current re-resolves next request —
@@ -139,14 +186,75 @@ module CurrentScope
139
186
  # same subject's org role to `role` rather than creating a duplicate (which
140
187
  # the one-role-per-subject uniqueness would reject anyway). Backs the
141
188
  # `current_scope:grant` rake task, so a fresh install doesn't need a console.
189
+ #
190
+ # Seeds the default Owner/Member roles ONLY on the default path — the name
191
+ # promises "assign a role", so a caller granting an explicit role must not
192
+ # get a full-access Owner row created in their roles table as a side effect.
142
193
  def grant!(subject, role: nil)
143
- seed_defaults!
144
- role ||= Role.find_by!(name: "Owner")
194
+ role ||= begin
195
+ seed_defaults!
196
+ Role.find_by!(name: "Owner")
197
+ end
145
198
  RoleAssignment.find_or_initialize_by(subject: subject).tap { |a| a.update!(role: role) }
146
199
  end
147
200
 
148
201
  private
149
202
 
203
+ # The documented namespaced/custom-named controller foot-gun (#41): the short
204
+ # form derived a DIFFERENT key than the gate on this controller enforces, so a
205
+ # view can show a link that 403s (or hide one that works). Silent, and the
206
+ # symptom appears nowhere near the cause.
207
+ #
208
+ # THIS SIGNAL IS AMBIGUOUS AND CANNOT BE MADE PRECISE. Two callers produce
209
+ # byte-identical inputs here:
210
+ #
211
+ # DashboardController renders Reports; allowed_to?(:show, report) is meant
212
+ # to mirror THIS controller's gate (dashboard#show) -> foot-gun.
213
+ # DocumentsController lists documents with links to reports;
214
+ # allowed_to?(:show, report) genuinely means reports#show -> correct.
215
+ #
216
+ # Both have a controller path that doesn't end in the record's route_key, and
217
+ # both route "{controller_path}##{action}". Nothing at the call site
218
+ # distinguishes intent. An earlier draft treated the catalog hit as proof of
219
+ # the foot-gun and warned "they disagree" — which is a false positive on every
220
+ # row of the second case. (#59/#61 review, cubic)
221
+ #
222
+ # So: warn ONCE per (controller_path, action, route_key), and say plainly that
223
+ # either reading may be right. One line per distinct site is a hint; one line
224
+ # per row is noise people learn to filter — and a diagnostic that cries wolf is
225
+ # worse than none, which is the whole thesis of this PR.
226
+ #
227
+ # ponytail: derivation is a hot path (every view helper call), so the flag is
228
+ # checked FIRST — off costs one boolean and never touches the catalog.
229
+ def warn_on_cross_controller_derivation(action, route_key, controller_path)
230
+ return unless config.warn_on_cross_controller_derivation
231
+ return if controller_path.nil? || controller_path.empty?
232
+ # A "log-only" diagnostic that raises isn't log-only. catalog reads
233
+ # Rails.application.routes, so a host that forces the flag on outside a
234
+ # booted Rails must get silence, not a NameError out of key derivation.
235
+ # (#61 review, qodo)
236
+ return unless defined?(Rails) && Rails.respond_to?(:application) && Rails.application
237
+
238
+ gate_key = "#{controller_path}##{action}"
239
+ return unless catalog.include?(gate_key)
240
+ return unless cross_controller_warning_unseen?(gate_key, route_key)
241
+
242
+ Rails.logger&.warn(
243
+ "[CurrentScope] allowed_to?(#{action.to_sym.inspect}, <#{route_key.singularize.camelize}>) on " \
244
+ "#{controller_path} derived \"#{route_key}##{action}\", but the gate here enforces " \
245
+ "\"#{gate_key}\". If you meant this controller's own gate, they disagree — pass the explicit " \
246
+ "key: allowed_to?(\"#{gate_key}\"). If you're asking about a different resource than this " \
247
+ "controller handles, the derived key is correct and this is expected. Warned once per site."
248
+ )
249
+ end
250
+
251
+ # ponytail: a plain Set, not a Mutex — worst case under a race is one extra
252
+ # line, and a flood is the thing being prevented. Dev/test only by default.
253
+ def cross_controller_warning_unseen?(gate_key, route_key)
254
+ @cross_controller_warned ||= Set.new
255
+ @cross_controller_warned.add?("#{gate_key}|#{route_key}") ? true : false
256
+ end
257
+
150
258
  # A2: the boundary events are the one place a host declares it is actually
151
259
  # impersonating. If actor_method is unset there, the entire act-as security
152
260
  # model is silently inert — so fail LOUD instead of recording an
@@ -27,7 +27,71 @@ module CurrentScope
27
27
  4. Manage roles at /current_scope (full-access subjects only).
28
28
 
29
29
  NEXT
30
+
31
+ say_retrofit_warning if existing_app?
30
32
  end
33
+
34
+ # An ABSOLUTE URL, not a repo-relative path. This prints in the HOST's
35
+ # terminal, in the HOST's app directory: "docs/guides/..." would resolve
36
+ # against their app, where it does not exist — and the gemspec ships only
37
+ # {app,config,db,lib} + README, so it is not in the installed gem either.
38
+ # Shipping docs/ wouldn't fix it (nobody reads docs out of a gem's install
39
+ # dir); a URL is the only form that resolves from where the reader is
40
+ # standing, and terminals make it clickable. (#64 review, qodo)
41
+ #
42
+ # Points at blob/main deliberately: the guide does not exist at the last
43
+ # release tag (it landed after v0.2.0), so a version-pinned URL would 404
44
+ # today. Revisit pinning to "blob/v#{VERSION}" at the next release, when a
45
+ # tag containing the guide exists. (#71 review, qodo)
46
+ GUIDE_PATH = "docs/guides/adopting-in-an-existing-app.md".freeze
47
+ GUIDE_URL = "https://github.com/davidteren/current_scope/blob/main/#{GUIDE_PATH}".freeze
48
+
49
+ private
50
+
51
+ # Adding a fail-closed gate to an app that already has controllers means
52
+ # every one of them starts denying: nothing is granted yet. That is the
53
+ # engine working, but it reads as "the gem broke my app" — and it lands
54
+ # AFTER step 2 above, when the suite is already red and the person is
55
+ # already reaching for git revert. Say it before that happens.
56
+ def say_retrofit_warning
57
+ say <<~RETROFIT, :yellow
58
+ Heads up — this app already has controllers.
59
+
60
+ Step 2 mounts a FAIL-CLOSED gate: anything not granted is denied. No
61
+ grants exist yet, so your controller specs will go red and your users
62
+ will get 403s. Nothing is misconfigured when that happens; it is the
63
+ gate doing its job on an app that hasn't been granted anything.
64
+
65
+ To retrofit incrementally instead, set this before step 2:
66
+
67
+ config.enforcement = :report # config/initializers/current_scope.rb
68
+
69
+ The gate then logs what it WOULD deny and lets it through. Run your
70
+ suite, then read the gaps back as a starter role grid:
71
+
72
+ bin/rails current_scope:report
73
+
74
+ Seed the roles it names, watch it empty out, then flip to :enforce.
75
+ One line back at any point.
76
+
77
+ The full retrofit guide — callback ordering vs. your authentication,
78
+ the Devise recipe, the skip_before_action fail-open trap, and a
79
+ rollout ladder:
80
+
81
+ #{GUIDE_URL}
82
+
83
+ RETROFIT
84
+ end
85
+
86
+ # ponytail: "does this app have controllers of its own" — the closed set
87
+ # is app/controllers/*.rb minus the one Rails generates for every app.
88
+ # A fresh `rails new` has only application_controller.rb, so it gets the
89
+ # clean install message; anything more means a real app is being
90
+ # retrofitted. Wrong guess costs a paragraph of advice, not correctness.
91
+ def existing_app?
92
+ controllers = Dir.glob(File.join(destination_root, "app/controllers/**/*_controller.rb"))
93
+ controllers.reject { |f| File.basename(f) == "application_controller.rb" }.any?
94
+ end
31
95
  end
32
96
  end
33
97
  end
@@ -1,4 +1,39 @@
1
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
+
2
37
  # Controller method that returns the authenticated subject.
3
38
  # config.user_method = :current_user
4
39
 
@@ -17,6 +52,26 @@ CurrentScope.configure do |config|
17
52
  # config.permission_grid_groups = { "read" => %w[index show], "create" => %w[new create],
18
53
  # "update" => %w[edit update], "destroy" => %w[destroy] }
19
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
+
20
75
  # --- Impersonation (act-as) ---------------------------------------------
21
76
  # These three knobs layer, in this order:
22
77
  #
@@ -61,11 +116,45 @@ CurrentScope.configure do |config|
61
116
  # audit-mandatory app never commits an unaudited change.
62
117
  # config.audit = true
63
118
 
64
- # Dev/test aid: log a nudge when an SoD action is ALLOWED but was gated with a
65
- # nil record i.e. the SoD veto was silently skipped because
66
- # current_scope_record returned nil on a member action. Off by default; never
67
- # changes behavior.
68
- # config.warn_on_nil_sod_record = false
119
+ # --- Dev diagnostics -----------------------------------------------------
120
+ # Four failure modes this engine has that are SILENT, and silent in the bad
121
+ # direction the thing going wrong looks exactly like the thing going right.
122
+ # All four are LOG-ONLY (no decision, exception, header, or audit row changes)
123
+ # and all four default ON in development and test, OFF in production.
124
+ #
125
+ # They are listed here rather than left to the docs on purpose: a named flag in
126
+ # your initializer is how you learn the failure mode exists at all.
127
+
128
+ # The SoD veto was SKIPPED because the gate had no record — an SoD member
129
+ # action whose current_scope_record returned nil (or was never declared). The
130
+ # request was ALLOWED, and a skipped veto looks identical to a veto that
131
+ # passed. The gem's #1 foot-gun.
132
+ # config.warn_on_nil_sod_record = Rails.env.local?
133
+
134
+ # Denied "no_grant", but the subject holds a scoped grant that WOULD have
135
+ # applied — and the controller declares no current_scope_record, so the gate
136
+ # had no record to apply it to. A member action that forgot its hook: it fails
137
+ # closed (correctly), but the 403 is indistinguishable from "never granted", so
138
+ # you go and stare at the grants, which are fine.
139
+ # config.warn_on_inert_scoped_grant = Rails.env.local?
140
+
141
+ # Short-form allowed_to?(:show, record) derived a DIFFERENT key than the gate
142
+ # on the current controller enforces (the namespaced/custom-named controller
143
+ # foot-gun): a link that 403s, or a hidden one that would have worked. A hint,
144
+ # not an accusation — asking about another resource derives a different key too,
145
+ # and that's correct — so it warns once per site and names both readings.
146
+ # config.warn_on_cross_controller_derivation = Rails.env.local?
147
+
148
+ # Denied "model_undeclared": a collection action declared with
149
+ # `current_scope_record = nil` on a controller that names no
150
+ # current_scope_model, while the subject holds a scoped grant ticking the
151
+ # key. The gate had no type to bind that grant to, so it failed closed —
152
+ # correctly, but the fix is one line: `def current_scope_model = TheType`.
153
+ # The same flag covers "model_invalid" — a declared hook returning
154
+ # something other than a concrete AR class ("Report" for Report); that
155
+ # nudge names the value the hook returned.
156
+ # config.warn_on_undeclared_collection_model = Rails.env.local?
157
+ # ------------------------------------------------------------------------
69
158
 
70
159
  # Controller paths (regexps) excluded from the permission grid. Excluded
71
160
  # controllers can't be granted, so they must also skip the gate with
@@ -12,4 +12,157 @@ namespace :current_scope do
12
12
  CurrentScope.grant!(subject)
13
13
  puts "Granted the full-access Owner role to #{klass}##{subject.id}."
14
14
  end
15
+
16
+ desc "Summarize would-be denials recorded in report mode into a starter role grid. " \
17
+ "Usage: bin/rails current_scope:report"
18
+ task report: :environment do
19
+ # The subject's current org-wide role, when resolvable — the grid reads
20
+ # differently if someone already holds a role that just doesn't tick these
21
+ # keys. Best-effort: a rollout aid must not abort everyone else's summary
22
+ # because one subject's record was deleted or its class no longer loads.
23
+ # A lambda, not a def — a rake file's `def` lands on Object.
24
+ org_role_suffix = lambda do |subject_gid|
25
+ subject = GlobalID::Locator.locate(subject_gid)
26
+ role = subject && CurrentScope::RoleAssignment.find_by(subject: subject)&.role
27
+ role ? " — currently #{role.name}" : ""
28
+ rescue StandardError
29
+ ""
30
+ end
31
+
32
+ begin
33
+ rows = CurrentScope::Event.where(event: "access.would_deny")
34
+ .pluck(:subject, :target_label, :details)
35
+ rescue ActiveRecord::StatementInvalid => e
36
+ # Report mode without the migration records nothing (the ledger degrades and
37
+ # warns once). Reaching for this summary is exactly how a host discovers
38
+ # that, so it must name the fix rather than raise a stack trace at them.
39
+ raise unless e.message.match?(/current_scope_events/i)
40
+
41
+ abort "The current_scope_events table doesn't exist, so nothing was recorded.\n" \
42
+ "Run: bin/rails current_scope:install:migrations && bin/rails db:migrate"
43
+ end
44
+
45
+ if rows.empty?
46
+ # "No output" is indistinguishable from "the task is broken", and the two
47
+ # likeliest causes are both SILENT: report mode never on, or audit off.
48
+ # Name them — this is the first thing a host runs, and an unexplained blank
49
+ # is how they conclude the feature doesn't work.
50
+ puts "No would-be denials recorded."
51
+ puts
52
+ puts " config.enforcement is #{CurrentScope.config.enforcement.inspect} " \
53
+ "(needs :report to record any)"
54
+ puts " config.audit is #{CurrentScope.config.audit.inspect} " \
55
+ "(needs true or :strict — the ledger is where these rows live)"
56
+ puts
57
+ puts "With both on, exercise the app or run your suite, then re-run this."
58
+ next
59
+ end
60
+
61
+ # ponytail: group in Ruby, not SQL. `details` is a JSON column and querying
62
+ # into it is adapter-specific; this is a rollout aid run by hand over a
63
+ # transitional table, so portability beats a smarter query.
64
+ grouped = rows.group_by { |subject, _label, _details| subject }
65
+
66
+ puts "Would-be denials — grant these to stop them (most-denied first):"
67
+ puts
68
+
69
+ grouped.each do |subject_gid, subject_rows|
70
+ label = subject_rows.first[1].presence || subject_gid
71
+ puts " #{label}#{org_role_suffix.call(subject_gid)}"
72
+
73
+ subject_rows
74
+ .group_by { |_s, _l, details| details.is_a?(Hash) ? details["permission"] : nil }
75
+ .transform_values(&:count)
76
+ .sort_by { |permission, count| [ -count, permission.to_s ] }
77
+ .each { |permission, count| puts " #{count.to_s.rjust(5)}x #{permission || '(unknown)'}" }
78
+
79
+ puts
80
+ end
81
+
82
+ puts "Total: #{rows.count} would-be denials across #{grouped.size} subject(s)."
83
+ end
84
+
85
+ desc "Inventory the routed controllers that provably never run the gate — the static " \
86
+ "half of the ungated-surface audit (config.gating_tripwire = :warn is the runtime half). " \
87
+ "Usage: bin/rails current_scope:ungated"
88
+ task ungated: :environment do
89
+ # One reflection for the whole walk — its request object memoizes (KTD-8).
90
+ # A broken controller body's NameError propagates on purpose (KTD-2): a
91
+ # rescue here would report a broken controller as gated.
92
+ gating = CurrentScope::GatingReflection.new
93
+ catalog = CurrentScope.catalog
94
+ grouped = catalog.grouped
95
+
96
+ # The catalog injects the break-glass key onto any row routing an SoD
97
+ # action, and that grant is LIVE even on an ungated controller — honored by
98
+ # whatever gated controller decides SoD on the record (the grid's own
99
+ # KTD-9 exemption). Printing it under "grants nothing" would tell an
100
+ # operator the most sensitive grant in the grid is inert. Strip it from
101
+ # the listing and say so once. Only the INJECTED key is stripped —
102
+ # catalog.routed? keeps a real routed action that merely shares the bypass
103
+ # name in the audit, because omitting it would hide a real fail-open route.
104
+ # The catalog also owns the permission parse (split("#", -1) + shape
105
+ # checks) — a loose split here would accept a malformed value. (#79 review)
106
+ bypass_action = CurrentScope.config.allow_sod_bypass ? catalog.bypass_action : nil
107
+ stripped_bypass = false
108
+
109
+ # Build the printable rows BEFORE deciding emptiness: a synthetic
110
+ # bypass-only row (a namespace-only SoD resource) reflects as "ungated"
111
+ # while routing nothing, and a header over an empty body reads as a broken
112
+ # task. Rows first, then branch on what there is to say.
113
+ rows = grouped.keys.sort.filter_map { |controller|
114
+ next unless gating.ungated?(controller)
115
+
116
+ actions = grouped[controller].sort
117
+ if bypass_action && actions.include?(bypass_action) && !catalog.routed?("#{controller}##{bypass_action}")
118
+ actions -= [ bypass_action ]
119
+ stripped_bypass = true
120
+ end
121
+ next if actions.empty? # nothing routed here — nothing to audit
122
+
123
+ [ controller, actions ]
124
+ }
125
+
126
+ if grouped.empty?
127
+ # A vacuous all-clear is worse than a blank: with nothing routed there
128
+ # was nothing to inspect, and "every routed controller has the callback"
129
+ # is technically true of an empty set and completely misleading.
130
+ puts "No routed controllers found in the permission catalog — nothing was " \
131
+ "inspected. Check your routes and config.excluded_controllers."
132
+ elsif rows.empty?
133
+ # An unexplained blank reads as "the task is broken" — and a bare blank
134
+ # would also overclaim. Claim only what the reflection proved: nothing
135
+ # was PROVEN ungated. A route whose controller doesn't resolve is
136
+ # unclassified, not vouched for (#43 owns that badge) — "every controller
137
+ # has the callback" would vouch for rows nobody inspected.
138
+ puts "No controller was proven ungated. (A routed path whose controller " \
139
+ "does not resolve is unclassified, not verified — see issue #43.)"
140
+ else
141
+ puts "Provably ungated — current_scope_check! is absent from these controllers' " \
142
+ "callback chains, so the gate never runs there:"
143
+ puts
144
+ rows.each { |controller, actions| puts " #{controller} (#{actions.join(', ')})" }
145
+ puts
146
+ puts "Ticking these in the role grid grants nothing until the gate runs. " \
147
+ "If a controller inherited a skip, re-assert before_action " \
148
+ ":current_scope_check! on it; if it never had the gate, include " \
149
+ "CurrentScope::Guard."
150
+ if stripped_bypass
151
+ puts
152
+ puts "(#{bypass_action} omitted from the listing — break-glass stays LIVE " \
153
+ "even on an ungated controller; see the role grid's exempt note.)"
154
+ end
155
+ end
156
+
157
+ # The limit of the proof, stated even when nothing is listed (KTD-3): a
158
+ # conditional skip (skip_before_action only:/except:) leaves the callback
159
+ # PRESENT wearing a condition — unprovable by reflection, so never shown
160
+ # here even though some of its actions really run open. The runtime half
161
+ # catches those.
162
+ puts
163
+ puts "Limit: this lists only what the callback chain PROVES. A conditional skip " \
164
+ "(skip_before_action only:/except:) does not appear here — set " \
165
+ "config.gating_tripwire = :warn and include CurrentScope::GatingTripwire " \
166
+ "to inventory those at runtime."
167
+ end
15
168
  end
metadata CHANGED
@@ -1,7 +1,7 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: current_scope
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.2.0
4
+ version: 0.3.1
5
5
  platform: ruby
6
6
  authors:
7
7
  - David Teren
@@ -32,7 +32,9 @@ dependencies:
32
32
  description: 'A mountable Rails engine for authorization: permissions auto-derived
33
33
  from controller actions, roles as editable data, per-record scoped roles, a separation-of-duties
34
34
  veto, and an ambient authorization context that makes allowed_to? work identically
35
- in controllers, views, and components.'
35
+ in controllers, views, and components. NOT PRODUCTION-READY: pre-1.0 with known
36
+ issues under active work — good for experimentation and spikes, not yet for real
37
+ users. See the README and the issue tracker.'
36
38
  email:
37
39
  - dteren@gmail.com
38
40
  executables: []
@@ -64,6 +66,7 @@ files:
64
66
  - app/views/current_scope/roles/members.html.erb
65
67
  - app/views/current_scope/roles/new.html.erb
66
68
  - app/views/current_scope/scoped_role_assignments/new.html.erb
69
+ - app/views/current_scope/shared/access_denied.html.erb
67
70
  - app/views/current_scope/subjects/index.html.erb
68
71
  - app/views/layouts/current_scope/application.html.erb
69
72
  - config/routes.rb
@@ -74,6 +77,7 @@ files:
74
77
  - lib/current_scope/configuration.rb
75
78
  - lib/current_scope/context.rb
76
79
  - lib/current_scope/engine.rb
80
+ - lib/current_scope/gating_reflection.rb
77
81
  - lib/current_scope/gating_tripwire.rb
78
82
  - lib/current_scope/guard.rb
79
83
  - lib/current_scope/mutation_guard.rb