current_scope 0.2.0 → 0.3.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 (35) 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 +99 -0
  5. data/app/controllers/current_scope/application_controller.rb +39 -1
  6. data/app/controllers/current_scope/role_assignments_controller.rb +14 -14
  7. data/app/controllers/current_scope/roles_controller.rb +6 -2
  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/members.html.erb +3 -3
  14. data/app/views/current_scope/roles/new.html.erb +1 -1
  15. data/app/views/current_scope/scoped_role_assignments/new.html.erb +12 -3
  16. data/app/views/current_scope/shared/access_denied.html.erb +30 -0
  17. data/app/views/current_scope/subjects/index.html.erb +17 -5
  18. data/app/views/layouts/current_scope/application.html.erb +4 -1
  19. data/config/routes.rb +3 -4
  20. data/lib/current_scope/configuration.rb +378 -16
  21. data/lib/current_scope/engine.rb +7 -0
  22. data/lib/current_scope/gating_reflection.rb +62 -0
  23. data/lib/current_scope/gating_tripwire.rb +36 -5
  24. data/lib/current_scope/guard.rb +406 -8
  25. data/lib/current_scope/mutation_guard.rb +30 -5
  26. data/lib/current_scope/permission_catalog.rb +116 -3
  27. data/lib/current_scope/permission_grid.rb +34 -4
  28. data/lib/current_scope/permissions.rb +42 -8
  29. data/lib/current_scope/resolver.rb +317 -13
  30. data/lib/current_scope/version.rb +1 -1
  31. data/lib/current_scope.rb +113 -5
  32. data/lib/generators/current_scope/install/install_generator.rb +64 -0
  33. data/lib/generators/current_scope/install/templates/initializer.rb +94 -5
  34. data/lib/tasks/current_scope_tasks.rake +153 -0
  35. metadata +6 -2
@@ -104,7 +104,7 @@ module CurrentScope
104
104
  # name AND the grid diff), else role.updated for a pure grid change. Emits
105
105
  # nothing when neither the name nor the grid moved.
106
106
  def record_role_update(role, previous_name)
107
- diff = role.permission_keys_change || { added: [], removed: [] }
107
+ diff = role.permission_keys_change || { added: [], removed: [], rejected: [] }
108
108
  renamed = previous_name != role.name
109
109
  return unless renamed || diff[:added].any? || diff[:removed].any?
110
110
 
@@ -126,7 +126,11 @@ module CurrentScope
126
126
  # Grid group columns (CRUD checkboxes) submit "controller:group" tokens on
127
127
  # a separate, optional channel — permitted leniently so a raw permission_keys
128
128
  # post (no groups) still works. Expand them into action keys; the model's
129
- # permission_keys= dedups and drops anything not in the catalog.
129
+ # permission_keys= dedups, and REJECTS anything not in the catalog (the
130
+ # save fails and `edit` re-renders with the error). The grid can't submit
131
+ # such a key — cells are built from routed actions only — so a rejection
132
+ # here means a hand-crafted request, which is worth saying out loud rather
133
+ # than dropping silently.
130
134
  groups = params.fetch(:role, {}).permit(permission_groups: [])[:permission_groups]
131
135
  permitted[:permission_keys] = Array(permitted[:permission_keys]) + PermissionGrid.new.expand(groups)
132
136
  permitted
@@ -1,15 +1,13 @@
1
1
  module CurrentScope
2
2
  module ApplicationHelper
3
- # Best-effort human label for any host subject/resource. Prefers a record's
4
- # own current_scope_label (the Scopeable mixin gives every pickable model
5
- # one) so the label a model declares is the label shown everywhere — the
6
- # picker, the chips, and the ledger (see Event.label_for) all agree.
3
+ # Best-effort human label for any host subject/resource. Delegates to the
4
+ # one shared chain (CurrentScope.label_for) the same definition the audit
5
+ # ledger denormalizes (see Event.label_for), so the picker, the chips, and
6
+ # the ledger agree by construction, not by parallel maintenance.
7
7
  def current_scope_label(record)
8
8
  return "(none)" if record.nil?
9
- return record.current_scope_label if record.respond_to?(:current_scope_label)
10
9
 
11
- name = record.try(:name) || record.try(:email) || record.try(:title)
12
- name || "#{record.class.name} ##{record.id}"
10
+ CurrentScope.label_for(record)
13
11
  end
14
12
 
15
13
  # Human label for a subject (user/account), honouring config.subject_label
@@ -36,14 +34,49 @@ module CurrentScope
36
34
  end
37
35
 
38
36
  # The configured subject_label (Symbol or Proc) applied to a subject, or nil
39
- # when unset or when it resolves to a blank value.
37
+ # when unset, when it resolves to a blank value, or when it fails.
38
+ #
39
+ # subject_label is arbitrary host code running once per row on the admin's
40
+ # main tool for granting and reviewing roles. One subject with incomplete
41
+ # data (a Proc doing `u.email.upcase` on a subject whose email is nil) must
42
+ # not take the whole page down — the same intent the holder-label helpers
43
+ # below already encode for stale polymorphic types. A failure here costs one
44
+ # label; a raise costs the page.
40
45
  def configured_subject_label(subject)
41
46
  label = CurrentScope.config.subject_label
42
- if label.respond_to?(:call)
43
- label.call(subject).to_s.presence
44
- elsif label && subject.respond_to?(label)
45
- subject.public_send(label).to_s.presence
47
+ return if label.nil?
48
+ return resolve_subject_label(label) { label.call(subject) } if label.respond_to?(:call)
49
+
50
+ # Only a Symbol or String names a method. Checked BEFORE respond_to?,
51
+ # which raises TypeError on anything else ("false is not a symbol nor a
52
+ # string") — and that raise would land outside the rescue below and 500
53
+ # the page, which is the bug this method exists to prevent, reached
54
+ # through a different door.
55
+ unless label.is_a?(Symbol) || label.is_a?(String)
56
+ CurrentScope::ApplicationHelper.warn_unusable_subject_label_once(label)
57
+ return
58
+ end
59
+
60
+ if subject.respond_to?(label)
61
+ resolve_subject_label(label) { subject.public_send(label) }
62
+ else
63
+ # Not a mistake we can render around: this config does nothing for EVERY
64
+ # subject, silently, which is why it needs saying out loud once.
65
+ CurrentScope::ApplicationHelper.warn_unknown_subject_label_once(label)
66
+ nil
46
67
  end
68
+ rescue StandardError => e
69
+ # Last resort, and the reason it exists: every branch above calls a method
70
+ # ON the host's config value to classify it — `nil?`, `respond_to?`,
71
+ # `is_a?`. A pathological value breaks the classification itself, before
72
+ # any specific guard can classify it, so the guards cannot be where this
73
+ # is caught. A BasicObject has no #nil?; a delegator can raise from
74
+ # respond_to?.
75
+ #
76
+ # Deliberately does NOT touch `label`: it is the thing that just proved it
77
+ # cannot be touched. The warning is keyed off the error alone.
78
+ CurrentScope::ApplicationHelper.warn_subject_label_broken_once(e)
79
+ nil
47
80
  end
48
81
 
49
82
  # Members-list labels that survive a stale/renamed polymorphic type — a
@@ -67,6 +100,129 @@ module CurrentScope
67
100
  def current_scope_gid_label(gid)
68
101
  record = GlobalID::Locator.locate(gid)
69
102
  record ? current_scope_label(record) : gid
103
+ rescue ActiveRecord::RecordNotFound, NameError
104
+ # locate RAISES for a deleted record or renamed class (nil is only for
105
+ # unparseable strings) — same contract the holder helpers rescue above.
106
+ gid
107
+ end
108
+
109
+ private
110
+
111
+ # ponytail: display fallback only — NEVER a decision path. This rescue makes
112
+ # a label degrade instead of erroring; the resolver, Guard and catalog are
113
+ # untouched, so it relaxes no fail-closed guarantee. (The same `rescue
114
+ # StandardError` on a decision path would be a fail-open bug.)
115
+ #
116
+ # StandardError rather than the NameError family the holder helpers catch:
117
+ # those wrap OUR call into a known-shaped record, while this runs a host's
118
+ # arbitrary Proc, which can raise anything. Catching only NameError would
119
+ # fix the reported NoMethodError and leave the same page-down bug for a
120
+ # differently-broken Proc.
121
+ #
122
+ # nil rejoins the existing "resolved blank -> default chain" path in
123
+ # current_scope_subject_label, so no caller learns a new branch.
124
+ def resolve_subject_label(label)
125
+ yield.to_s.presence
126
+ rescue StandardError => e
127
+ CurrentScope::ApplicationHelper.warn_subject_label_raised_once(label, e)
128
+ nil
129
+ end
130
+
131
+ class << self
132
+ # Warn ONCE per (reason, label): a misconfigured subject_label is the same
133
+ # mistake on every row, so one line is a signal and one-per-subject is
134
+ # noise the admin will scroll past. Keyed by the label value, so changing
135
+ # the config in dev warns again for the new value.
136
+ #
137
+ # Always-on rather than behind a warn_on_* flag (cf. warn_on_nil_sod_record,
138
+ # which defaults off because a nil record is often legitimate): a label the
139
+ # subject can't answer, or one that raises, is unambiguously a mistake.
140
+ # Mirrors Event.warn_missing_events_table_once.
141
+ #
142
+ # Says "this subject" rather than "subjects": under STI some subclasses may
143
+ # answer and others not, and warn-once means the first non-responder would
144
+ # otherwise speak for all of them.
145
+ def warn_unknown_subject_label_once(label)
146
+ return unless new_subject_label_warning?(:unknown, label)
147
+
148
+ Rails.logger&.warn(
149
+ "[CurrentScope] config.subject_label is #{label.inspect}, but this subject does not " \
150
+ "respond to it — it is falling back to the default label. If no subject responds to it, " \
151
+ "this config does nothing. Set it to a method the subject responds to (e.g. :email), a " \
152
+ "Proc, or nil."
153
+ )
154
+ end
155
+
156
+ # Not a Symbol, String, Proc, or nil — so it can't name a method and can't
157
+ # be called. Reports the TYPE, not the value: this is the one warning whose
158
+ # subject is an arbitrary host object, and calling `inspect` on it could
159
+ # itself raise — inside the very path that exists to stop a raise.
160
+ def warn_unusable_subject_label_once(label)
161
+ return unless new_subject_label_warning?(:unusable, label.class)
162
+
163
+ Rails.logger&.warn(
164
+ "[CurrentScope] config.subject_label is a #{label.class} — it can't name a method and " \
165
+ "can't be called, so every subject is falling back to the default label. Set it to a " \
166
+ "Symbol (e.g. :email), a Proc taking the subject, or nil."
167
+ )
168
+ end
169
+
170
+ def warn_subject_label_raised_once(label, error)
171
+ return unless new_subject_label_warning?(:raised, label)
172
+
173
+ Rails.logger&.warn(
174
+ "[CurrentScope] config.subject_label raised #{safe_class(error)}: #{safe_message(error)} " \
175
+ "— that subject fell back to the default label rather than erroring the page. A " \
176
+ "subject_label must be total: it runs for every subject, including ones with nil or " \
177
+ "blank attributes."
178
+ )
179
+ end
180
+
181
+ # The configured value could not even be classified — see the rescue in
182
+ # configured_subject_label. Keyed by the error's class, and says nothing
183
+ # about the label itself, because reading the label is what failed.
184
+ def warn_subject_label_broken_once(error)
185
+ return unless new_subject_label_warning?(:broken, safe_class(error))
186
+
187
+ Rails.logger&.warn(
188
+ "[CurrentScope] config.subject_label could not be read — #{safe_class(error)}: " \
189
+ "#{safe_message(error)}. Every subject is falling back to the default label. Set it to " \
190
+ "a Symbol (e.g. :email), a Proc taking the subject, or nil."
191
+ )
192
+ end
193
+
194
+ private
195
+
196
+ # A host exception's message is arbitrary text produced by arbitrary code:
197
+ # it may carry newlines that split one warning across several log records,
198
+ # run to any length, or raise from #message itself. All three would land
199
+ # in the rescue this runs inside — reporting the failure must not become
200
+ # the failure.
201
+ def safe_message(error)
202
+ error.message.to_s.gsub(/\s+/, " ").strip.truncate(200)
203
+ rescue StandardError
204
+ "(message unavailable)"
205
+ end
206
+
207
+ # Same reason as safe_message, one step earlier: `#class` is interpolated
208
+ # before safe_message is even called, and an exception raised by a host
209
+ # Proc can override it. The method-level rescue in configured_subject_label
210
+ # does stop that reaching the page — but at the cost of the accurate
211
+ # warning, replaced by a misattributed one about the secondary failure.
212
+ # The diagnostic is the whole point of warning at all, so protect it.
213
+ def safe_class(error)
214
+ error.class.name.presence || "an exception"
215
+ rescue StandardError
216
+ "an exception"
217
+ end
218
+
219
+ # Set is a core built-in on this gem's Ruby floor (>= 3.2, no require
220
+ # needed — the codebase already uses bare Set elsewhere), so nothing here
221
+ # can raise inside the rescue this runs under.
222
+ def new_subject_label_warning?(reason, label)
223
+ @subject_label_warnings ||= Set.new
224
+ !!@subject_label_warnings.add?([ reason, label ])
225
+ end
70
226
  end
71
227
  end
72
228
  end
@@ -14,6 +14,16 @@ module CurrentScope
14
14
  # each event; nil when the host hasn't set it. Additive — no reader override.
15
15
  attribute :user, :actor, :request_id
16
16
 
17
+ # #50: the collection type the request's controller declared via
18
+ # current_scope_model, and the controller_path it was declared for. The
19
+ # Guard stashes both once per request so the advisory path (allowed_to? in
20
+ # a view) can bind the record-less gate the same way the controller gate
21
+ # did — but the PATH is stored so a cross-controller advisory question
22
+ # (allowed_to?(:index, controller: "reports") from a projects view) does
23
+ # NOT borrow the wrong controller's type (KTD-6). Request-scoped, reset
24
+ # with everything else, so it never leaks between requests.
25
+ attribute :collection_model, :collection_model_path
26
+
17
27
  # Per-request memo for the resolver's org-role lookup. A view with N
18
28
  # permission-gated elements calls the gate N times, each otherwise re-running
19
29
  # the same `RoleAssignment.find_by(subject:)`; caching it here collapses that
@@ -24,10 +34,24 @@ module CurrentScope
24
34
 
25
35
  # Falls back to the effective subject, so actor is never nil when user is
26
36
  # set — callers attribute to `actor` without a nil branch.
37
+ #
38
+ # ROUND-TRIP HAZARD: the reader answers with the fallback, not stored
39
+ # state. Snapshot/restore code (Current.set, Object#with, anything that
40
+ # reads #actor to write it back) would pin the fallback as an explicit
41
+ # actor and restore a stale identity once user changes — read the raw
42
+ # `attributes` hash instead, as TestHelpers#with_current_user does.
27
43
  def actor
28
44
  super || user
29
45
  end
30
46
 
47
+ # True only while a distinct real actor stands behind the effective
48
+ # subject (act-as). THE definition of "impersonating" — the Permissions
49
+ # mixin and the mutation guard both delegate here, so the view-level
50
+ # read-only signal and the write gate can never drift apart.
51
+ def impersonating?
52
+ user.present? && actor != user
53
+ end
54
+
31
55
  # Memoize the org-role lookup for `subject` for the rest of this request/job.
32
56
  # Keyed by subject so a check that spans several subjects stays correct.
33
57
  # Caches nil (no role) too, so a repeated "no grant" check is one query, not N.
@@ -90,13 +90,11 @@ module CurrentScope
90
90
  )
91
91
  end
92
92
 
93
- # Prefer a record's own view-agnostic label; then the AR default; then
94
- # a plain to_s for anything else.
93
+ # The shared chain (CurrentScope.label_for) the same label the UI
94
+ # renders is the one denormalized into target_label, so the ledger and
95
+ # the screen can't disagree about what a record was called.
95
96
  def label_for(record)
96
- return record.current_scope_label if record.respond_to?(:current_scope_label)
97
- return "#{record.model_name.human} ##{record.id}" if record.respond_to?(:model_name)
98
-
99
- record.to_s
97
+ CurrentScope.label_for(record)
100
98
  end
101
99
 
102
100
  # Recognizes "table is missing" across adapters and Rails' own schema
@@ -112,6 +110,12 @@ module CurrentScope
112
110
  !message.match?(/\bcolumn\b/i) &&
113
111
  message.match?(/no such table|could not find table|does(?:n't| not) exist|unknown table|undefined table/i)
114
112
  end
113
+ # PUBLIC: report mode's ledger warning asks this too (#37). It is one
114
+ # question — "is this the un-migrated-table case?" — and one adapter-shaped
115
+ # answer, so it gets one definition. A second opinion elsewhere is a second
116
+ # opinion that drifts, and this one decides which fix an operator is sent
117
+ # after. (#59 review)
118
+ public :missing_events_table?
115
119
  end
116
120
  end
117
121
  end
@@ -8,13 +8,17 @@ module CurrentScope
8
8
  has_many :scoped_role_assignments, dependent: :destroy
9
9
 
10
10
  validates :name, presence: true, uniqueness: true
11
+ validate :permission_keys_in_catalog
11
12
 
12
13
  after_save :persist_permission_keys
13
14
 
14
- # The grid diff computed by the last save: { added: [...], removed: [...] }.
15
- # Empty arrays on a no-op save; nil when no grid was staged (a programmatic
16
- # save that never set permission_keys). Controllers read this to record the
17
- # change the model itself records nothing (seeds must stay silent).
15
+ # The grid diff computed by the last save:
16
+ # { added: [...], removed: [...], rejected: [...] }. Empty arrays on a no-op
17
+ # save; nil when no grid was staged (a programmatic save that never set
18
+ # permission_keys). `added`/`removed` describe what actually persisted;
19
+ # `rejected` names keys the scrub path threw away, so a caller that opted
20
+ # into silence can still log it. Controllers read this to record the change
21
+ # — the model itself records nothing (seeds must stay silent).
18
22
  attr_reader :permission_keys_change
19
23
 
20
24
  def grants?(permission_key)
@@ -25,31 +29,78 @@ module CurrentScope
25
29
  @pending_permission_keys || role_permissions.pluck(:permission_key)
26
30
  end
27
31
 
28
- # Stages a replacement permission set, dropping keys that aren't in the
29
- # catalog (stale keys from removed controllers). Persisted on save, like
30
- # any other attribute never before validations pass.
32
+ # Stages a replacement permission set. STRICT: a key that isn't in the
33
+ # route-derived catalog makes the record invalid rather than disappearing.
34
+ # This is the mass-assignment writer (update!, strong params, the role
35
+ # grid), so it is deliberately the strict one — the scrub escape hatch must
36
+ # never be reachable from form params.
31
37
  def permission_keys=(keys)
32
- @pending_permission_keys = Array(keys).uniq.select { |k| CurrentScope.catalog.include?(k) }
38
+ assign_permission_keys(keys, scrub: false)
39
+ end
40
+
41
+ # ponytail: one implementation, two intents — the setter is the strict
42
+ # front door, this is the same staging with the scrub opt-in named at the
43
+ # call site.
44
+ #
45
+ # `scrub: true` is the ONLY sanctioned silent drop, and it exists for one
46
+ # real case: a controller was removed, so a role still holds keys that no
47
+ # longer route. Cleaning those up is legitimate and shouldn't require the
48
+ # operator to name every dead key. Everything else — typos, unrouted
49
+ # programmatic grants, the never-routed break-glass permission — is a
50
+ # mistake, and a security-grant API must not swallow mistakes.
51
+ def assign_permission_keys(keys, scrub: false)
52
+ # Literal `true` only. Ruby truthiness would let the string "false" — or
53
+ # any params/config value that found its way here — silently disable the
54
+ # strict path, which is the exact hole this API exists to close. The
55
+ # escape hatch opens for a caller that means it, not for one that passed
56
+ # something along.
57
+ @scrub_permission_keys = scrub == true
58
+ # Blank entries are the grid's hidden-field padding, not typos (R2).
59
+ @pending_permission_keys = Array(keys).map(&:to_s).reject(&:blank?).uniq
33
60
  end
34
61
 
35
62
  def reload(...)
36
63
  @pending_permission_keys = nil
64
+ @scrub_permission_keys = false
37
65
  super
38
66
  end
39
67
 
40
68
  private
41
69
 
70
+ def permission_keys_in_catalog
71
+ return if @pending_permission_keys.nil? || @scrub_permission_keys
72
+
73
+ unknown = @pending_permission_keys.reject { |k| CurrentScope.catalog.include?(k) }
74
+ return if unknown.empty?
75
+
76
+ errors.add(
77
+ :permission_keys,
78
+ "not in the permission catalog: #{unknown.join(', ')} — check for typos, or use " \
79
+ "assign_permission_keys(..., scrub: true) to drop stale keys deliberately"
80
+ )
81
+ end
82
+
42
83
  def persist_permission_keys
43
84
  return if @pending_permission_keys.nil?
44
85
 
45
86
  # Capture the prior keys BEFORE delete_all so the diff survives the swap.
46
87
  previous = role_permissions.pluck(:permission_key)
47
- staged = @pending_permission_keys
48
- @permission_keys_change = { added: staged - previous, removed: previous - staged }
88
+ # Defense in depth: on the strict path validation already proved every key
89
+ # is in the catalog, so this filter is a no-op. It is what the scrub path
90
+ # relies on, and it means no future code path that skips validations
91
+ # (insert_all, update_column, a bare `save(validate: false)`) can smuggle
92
+ # an unknown key into the table.
93
+ staged = @pending_permission_keys.select { |k| CurrentScope.catalog.include?(k) }
94
+ @permission_keys_change = {
95
+ added: staged - previous,
96
+ removed: previous - staged,
97
+ rejected: @pending_permission_keys - staged
98
+ }
49
99
 
50
100
  role_permissions.delete_all
51
101
  role_permissions.insert_all(staged.map { |k| { permission_key: k } }) if staged.any?
52
102
  @pending_permission_keys = nil
103
+ @scrub_permission_keys = false
53
104
  end
54
105
  end
55
106
  end
@@ -4,7 +4,7 @@
4
4
 
5
5
  <%= form_with model: @role do |f| %>
6
6
  <% if @role.errors.any? %>
7
- <p class="cs-alert"><%= @role.errors.full_messages.to_sentence %></p>
7
+ <p class="cs-alert" role="alert"><%= @role.errors.full_messages.to_sentence %></p>
8
8
  <% end %>
9
9
 
10
10
  <p><%= f.label :name %> <%= f.text_field :name %></p>
@@ -20,10 +20,33 @@
20
20
  </p>
21
21
 
22
22
  <h2>Permission grid</h2>
23
+ <%# The injected break-glass action's bare name (nil when break-glass is
24
+ off) — the hint below and the per-row exemption check both read it. The
25
+ CATALOG owns the parse: its split("#", -1) + blank-raise catches a
26
+ malformed "reports#" that a loose split here would silently turn into a
27
+ believable wrong action name. (#79 review, qodo) %>
28
+ <% bypass_action = CurrentScope.config.allow_sod_bypass ? CurrentScope.catalog.bypass_action : nil %>
23
29
  <p class="cs-hint">
24
30
  One row per controller, one checkbox per action group — derived from the
25
31
  app's routes, so new controllers appear here automatically. Columns are
26
32
  fixed: a blank cell means the controller doesn't route that action.
33
+ <%# The break-glass column is the one thing here that is NOT an action you
34
+ can route, so the "derived from the app's routes" line above would
35
+ otherwise make it look like a mistake. %>
36
+ <% if CurrentScope.config.allow_sod_bypass %>
37
+ Break-glass is on, so controllers with a separation-of-duties action also
38
+ show a <code><%= bypass_action %></code>
39
+ column: granting it lets that role's holder override the four-eyes veto on
40
+ their own record, where the record allows it. Every use is audited.
41
+ <% end %>
42
+ </p>
43
+ <%# Unconditional on purpose (R9): the limit of the mark must be stated even
44
+ when nothing is marked — an unmarked row is a non-answer, not a proof. %>
45
+ <p class="cs-hint cs-ungated-hint">
46
+ Rows tagged "gate not run" are controllers provably never gated — the tag
47
+ appears only when the callback chain proves it. An unmarked row is not
48
+ proof the gate runs there (a conditional skip, for example, is unprovable
49
+ and renders unmarked).
27
50
  </p>
28
51
 
29
52
  <% grid = CurrentScope::PermissionGrid.new %>
@@ -42,15 +65,58 @@
42
65
  </thead>
43
66
  <tbody>
44
67
  <% grid.controllers.each do |controller| %>
68
+ <%# A broken controller body's NameError deliberately propagates out
69
+ of the reflection (KTD-2) — but ONE broken controller must not
70
+ take down the whole role editor for every other row. The view
71
+ renders that row as explicitly uninspectable (a visible state,
72
+ never silence, never "gated"); the rake task keeps propagating,
73
+ because its output makes proof claims a partial walk can't. %>
74
+ <% ungated = begin
75
+ grid.ungated?(controller)
76
+ rescue NameError
77
+ :uninspectable
78
+ end %>
79
+ <% uninspectable = ungated == :uninspectable
80
+ ungated = ungated == true %>
81
+ <% badge_id = "cs_ungated_#{controller.parameterize(separator: '_')}" if ungated %>
45
82
  <tr>
46
83
  <th scope="row">
47
84
  <%# Per-row master: enable/disable every action in this controller at once.
48
85
  Progressive enhancement — with JS off, each cell checkbox still works. %>
49
86
  <label class="cs-row-all" title="Enable all <%= controller %> permissions">
50
87
  <input type="checkbox" data-cs-row-all
51
- aria-label="Enable all <%= controller %> permissions">
88
+ aria-label="Enable all <%= controller %> permissions"<% if ungated %>
89
+ aria-describedby="<%= badge_id %>"<% end %>>
52
90
  <span><%= controller %></span>
53
91
  </label>
92
+ <% if uninspectable %>
93
+ <%# Not a badge and not silence: the gate state is UNKNOWN here,
94
+ and rendering nothing would read as "checked, and gated"
95
+ under the hint's own rules. %>
96
+ <span class="cs-uninspectable-note" id="cs_uninspectable_<%= controller.parameterize(separator: '_') %>">
97
+ could not inspect — this controller raised while loading, so its
98
+ gate state is unknown. Fix the load error, then reload.
99
+ </span>
100
+ <% end %>
101
+ <% if ungated %>
102
+ <%# Advisory only — marking is not disabling (KTD-8/R5): nothing
103
+ here is grayed or disabled. Same remediation vocabulary as
104
+ GatingTripwire's message. The claim is scoped to ROUTED
105
+ actions because the injected break-glass cell is live even
106
+ on a marked row (KTD-9) and carries its own note below. %>
107
+ <%# Each remedy names its cause: re-including Guard on a subclass
108
+ that inherited a skip is a NO-OP (the Concern short-circuits;
109
+ the skip already removed the callback the include would add),
110
+ so the inherited-skip host — the #62 shape this badge exists
111
+ for — must be pointed at re-assertion, not at include. %>
112
+ <span class="cs-ungated-badge" id="<%= badge_id %>">
113
+ <strong>gate not run</strong> — this controller does not run the
114
+ gate; ticking its routed actions grants nothing until it does.
115
+ If it inherited a skip, re-assert
116
+ <code>before_action :current_scope_check!</code>; if it never had
117
+ the gate, include CurrentScope::Guard.
118
+ </span>
119
+ <% end %>
54
120
  </th>
55
121
  <% grid.columns.each do |column| %>
56
122
  <% cell = grid.cell(controller, column, granted) %>
@@ -60,13 +126,35 @@
60
126
  readers; the "·" marker is decorative CSS. %>
61
127
  <td class="cs-cell-blank"></td>
62
128
  <% else %>
63
- <td>
129
+ <%# column.actions covers both shapes — an ungrouped column is
130
+ [action], and a permission_grid_groups entry containing the
131
+ bypass action must not defeat the exemption. The two guards:
132
+ the INJECTED key must actually sit on this controller (a
133
+ grouped cell can be non-blank via its other actions), and
134
+ routed? excludes the collision — a REAL routed action merely
135
+ named like the bypass permission is ordinary and inert on a
136
+ marked row; only the catalog-injected key stays live. (#79) %>
137
+ <% bypass_exempt = ungated && bypass_action &&
138
+ column.actions.include?(bypass_action) &&
139
+ CurrentScope.catalog.grouped[controller]&.include?(bypass_action) &&
140
+ !CurrentScope.catalog.routed?("#{controller}##{bypass_action}") %>
141
+ <% note_id = "cs_bypass_exempt_#{controller.parameterize(separator: '_')}" if bypass_exempt %>
142
+ <td<% if bypass_exempt %> class="cs-cell-bypass"<% end %>>
64
143
  <label>
65
144
  <%= check_box_tag cell.name, cell.value, cell.checked,
66
145
  id: "perm_#{cell.value.parameterize(separator: '_')}",
67
146
  data: { cs_partial: cell.partial },
68
- "aria-label": "#{controller} #{column.label}" %>
147
+ "aria-label": "#{controller} #{column.label}",
148
+ "aria-describedby": (bypass_exempt ? "#{badge_id} #{note_id}" : badge_id) %>
69
149
  </label>
150
+ <% if bypass_exempt %>
151
+ <%# KTD-9: break-glass is decided by GATED controllers acting
152
+ on the record type, so this grant is live despite the
153
+ row's badge — the one cell exempt from its claim. The
154
+ class is also the CSS hook that keeps the real granted
155
+ wash here while sibling cells are muted. %>
156
+ <span class="cs-bypass-exempt" id="<%= note_id %>">exempt — stays live</span>
157
+ <% end %>
70
158
  <%# Partial group: preserve the exact granted actions so a no-op
71
159
  save can't promote the group to a full grant. Interacting with
72
160
  the checkbox disables these (JS) so the group control takes over. %>
@@ -11,7 +11,7 @@
11
11
 
12
12
  <h2>Add org-wide members</h2>
13
13
  <% if @candidates.any? %>
14
- <%= form_with url: role_assignment_path, method: :post do %>
14
+ <%= form_with url: role_assignments_path, method: :post do %>
15
15
  <%= hidden_field_tag :role_id, @role.id %>
16
16
  <p>
17
17
  <%= label_tag "subject_gids", "Subjects" %>
@@ -46,7 +46,7 @@
46
46
  <td>
47
47
  <% if subject_gid %>
48
48
  <%# Removing = clearing this subject's org-wide role (blank role_id). %>
49
- <%= form_with url: role_assignment_path, method: :post,
49
+ <%= form_with url: role_assignments_path, method: :post,
50
50
  data: { cs_confirm: "Remove #{who} from #{@role.name}?" } do %>
51
51
  <%= hidden_field_tag :subject_gid, subject_gid %>
52
52
  <%= hidden_field_tag :role_id, "" %>
@@ -54,7 +54,7 @@
54
54
  <% end %>
55
55
  <% else %>
56
56
  <%# Orphaned assignment (subject deleted): remove it by id. %>
57
- <%= button_to "Remove", remove_role_assignment_path(assignment), method: :delete,
57
+ <%= button_to "Remove", role_assignment_path(assignment), method: :delete,
58
58
  class: "cs-btn", form: { data: { cs_confirm: "Remove this orphaned org-wide assignment?" } } %>
59
59
  <span class="cs-subtle">subject unavailable</span>
60
60
  <% end %>
@@ -2,7 +2,7 @@
2
2
 
3
3
  <%= form_with model: @role do |f| %>
4
4
  <% if @role.errors.any? %>
5
- <p class="cs-alert"><%= @role.errors.full_messages.to_sentence %></p>
5
+ <p class="cs-alert" role="alert"><%= @role.errors.full_messages.to_sentence %></p>
6
6
  <% end %>
7
7
 
8
8
  <p><%= f.label :name %> <%= f.text_field :name %></p>
@@ -102,9 +102,18 @@
102
102
  include_blank: "— choose a record —", form: "cs-cascade",
103
103
  data: { current_scope_autosubmit: true } %>
104
104
  <% if @searchable && params[:q].present? %>
105
- <span class="cs-hint">
106
- Showing up to <%= CurrentScope::ScopedRoleAssignmentsController::DISPLAY_LIMIT %> matches refine the search to narrow.
107
- </span>
105
+ <%# Keyed off the raw SEARCH results, not record_options — a
106
+ deep-linked @resource is prepended to the options even when the
107
+ query matched nothing, and must not fake a "matches shown". %>
108
+ <% if @records.blank? %>
109
+ <span class="cs-hint cs-picker-empty">
110
+ No records match “<%= params[:q] %>” — try a different search.
111
+ </span>
112
+ <% else %>
113
+ <span class="cs-hint">
114
+ Showing up to <%= CurrentScope::ScopedRoleAssignmentsController::DISPLAY_LIMIT %> matches — refine the search to narrow.
115
+ </span>
116
+ <% end %>
108
117
  <% end %>
109
118
  </p>
110
119
  <% end %>
@@ -0,0 +1,30 @@
1
+ <%# The engine's front-door 403. Deliberately NOT rendered in the console
2
+ layout: that layout is a sidebar of links to Roles / Subjects / Events, and
3
+ showing the console chrome to someone who cannot open any of it reads as
4
+ "you're in" while every link refuses. Standalone page, engine stylesheet, no
5
+ navigation offered that isn't there. %>
6
+ <!DOCTYPE html>
7
+ <html lang="en"<%= " data-cs-theme=\"#{cookies[:current_scope_theme]}\"".html_safe if %w[light dark].include?(cookies[:current_scope_theme]) %>>
8
+ <head>
9
+ <meta charset="utf-8">
10
+ <meta name="viewport" content="width=device-width, initial-scale=1">
11
+ <title>Access denied — CurrentScope</title>
12
+ <%= csp_meta_tag %>
13
+ <%= stylesheet_link_tag "current_scope/application", media: "all" %>
14
+ </head>
15
+ <body>
16
+ <main class="cs-card cs-denied">
17
+ <h1>This area needs a full-access role</h1>
18
+ <p>
19
+ The authorization console is where roles and permissions are granted, so it
20
+ can't be gated by a permission you could be granted — it's open only to
21
+ subjects holding a role marked <strong>full access</strong>.
22
+ </p>
23
+ <p class="cs-hint">
24
+ If you should have it, ask someone who already does to grant you a
25
+ full-access role. If nobody has one yet, it's granted from the console or a
26
+ rake task — see the CurrentScope README's bootstrap section.
27
+ </p>
28
+ </main>
29
+ </body>
30
+ </html>