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
@@ -16,16 +16,35 @@ module CurrentScope
16
16
  # only the subjects that ACTUALLY changed so the notice can't over-report
17
17
  # (a re-set to the same role, or a clear on a subject with no role, is a
18
18
  # no-op and shouldn't be counted).
19
+ #
20
+ # Lock full-access holders inside the same transaction as the writes so
21
+ # concurrent clears cannot both observe "another holder remains" and then
22
+ # both proceed to zero holders.
19
23
  changed = 0
24
+ refused = false
20
25
  RoleAssignment.transaction do
21
- subjects.each do |subject|
22
- assignment = RoleAssignment.find_or_initialize_by(subject: subject)
23
- prior_role = assignment.role # nil for a brand-new assignment
24
- did = clearing ? clear_org_role(subject, assignment, prior_role) : set_org_role(subject, assignment, prior_role)
25
- changed += 1 if did
26
+ lock_full_access_org_holders!
27
+
28
+ if would_remove_last_full_access_holders?(subjects, clearing: clearing)
29
+ refused = true
30
+ else
31
+ subjects.each do |subject|
32
+ assignment = RoleAssignment.find_or_initialize_by(subject: subject)
33
+ prior_role = assignment.role # nil for a brand-new assignment
34
+ did = clearing ? clear_org_role(subject, assignment, prior_role) : set_org_role(subject, assignment, prior_role)
35
+ changed += 1 if did
36
+ end
26
37
  end
27
38
  end
28
39
 
40
+ if refused
41
+ redirect_back_or_to subjects_path,
42
+ alert: "Refusing to remove the last full-access org-wide assignment — " \
43
+ "it would lock everyone out of this UI. Grant full access to " \
44
+ "another subject first, then retry."
45
+ return
46
+ end
47
+
29
48
  # Return to wherever the action was invoked (the subjects page or a role's
30
49
  # members page); falls back to subjects when there's no referrer.
31
50
  redirect_back_or_to subjects_path, notice: org_notice(clearing, changed)
@@ -37,14 +56,32 @@ module CurrentScope
37
56
  # clean up an orphaned assignment whose subject was deleted (the subject-keyed
38
57
  # clear on `create` can't target a subject that no longer resolves).
39
58
  def destroy
40
- assignment = RoleAssignment.find(params[:id])
41
- subject = resolve_subject(assignment)
42
- role_name = assignment.role.name
43
-
59
+ refused = false
44
60
  RoleAssignment.transaction do
45
- assignment.destroy!
46
- Event.record!(event: "org_role.removed", target: subject || assignment, details: { role: role_name })
61
+ # Lock FA state BEFORE the target assignment so order matches create/
62
+ # role demote/delete (FA roles FA holders target row). Locking the
63
+ # assignment first inverted that order and could deadlock.
64
+ lock_full_access_org_holders!
65
+ assignment = RoleAssignment.lock.find(params[:id])
66
+ subject = resolve_subject(assignment)
67
+ role_name = assignment.role.name
68
+
69
+ if last_full_access_org_assignment?(assignment)
70
+ refused = true
71
+ else
72
+ assignment.destroy!
73
+ Event.record!(event: "org_role.removed", target: subject || assignment, details: { role: role_name })
74
+ end
47
75
  end
76
+
77
+ if refused
78
+ redirect_back_or_to subjects_path,
79
+ alert: "Refusing to remove the last full-access org-wide assignment — " \
80
+ "it would lock everyone out of this UI. Grant full access to " \
81
+ "another subject first, then retry."
82
+ return
83
+ end
84
+
48
85
  redirect_back_or_to subjects_path, notice: "Org-wide role removed."
49
86
  rescue ActiveRecord::RecordNotFound
50
87
  redirect_back_or_to subjects_path, notice: "That org-wide role was already removed."
@@ -68,15 +105,64 @@ module CurrentScope
68
105
  count == 1 ? "Org-wide role #{verb}." : "Org-wide role #{verb} for #{count} subjects."
69
106
  end
70
107
 
108
+ # True when this assignment is a live full_access org holder and no other
109
+ # full_access org assignment exists. Orphan rows (deleted subject → nil)
110
+ # must not permanently block cleanup of the last FA assignment.
111
+ def last_full_access_org_assignment?(assignment)
112
+ return false unless assignment.role&.full_access?
113
+ return false if resolve_subject(assignment).nil?
114
+
115
+ !full_access_org_assignments.where.not(id: assignment.id).exists?
116
+ end
117
+
118
+ # True when applying clear (or reassign to a non-full_access role) to these
119
+ # subjects would leave zero full_access org holders.
120
+ def would_remove_last_full_access_holders?(subjects, clearing:)
121
+ holders = full_access_org_assignments.to_a
122
+ return false if holders.empty?
123
+
124
+ affected_ids = holders.select { |a| subjects.any? { |s| same_subject?(a, s) } }.map(&:id)
125
+ return false if affected_ids.empty?
126
+
127
+ unless clearing
128
+ new_role = Role.find_by(id: params[:role_id])
129
+ return false if new_role&.full_access?
130
+ end
131
+
132
+ remaining = holders.reject { |a| affected_ids.include?(a.id) }
133
+ remaining.empty?
134
+ end
135
+
136
+ def full_access_org_assignments
137
+ RoleAssignment.joins(:role).where(current_scope_roles: { full_access: true })
138
+ end
139
+
140
+ # Lock full-access holder rows (and their roles) so concurrent remove/demote
141
+ # paths serialize on the same set the precheck reads. Call only inside a
142
+ # transaction. Prefer locking by id after a join pluck — FOR UPDATE with
143
+ # joins is adapter-fragile.
144
+ def lock_full_access_org_holders!
145
+ Role.where(full_access: true).lock.load
146
+ ids = full_access_org_assignments.pluck(:id)
147
+ RoleAssignment.where(id: ids).lock.load if ids.any?
148
+ end
149
+
150
+ def same_subject?(assignment, subject)
151
+ # Match the polymorphic storage name the subjects page uses (not only
152
+ # base_class.name — hosts can customize polymorphic_name).
153
+ assignment.subject_type == subject.class.polymorphic_name && assignment.subject_id == subject.id
154
+ end
155
+
71
156
  # Returns true when a role was actually cleared, false when there was nothing
72
- # to clear (so the caller's count stays accurate).
157
+ # to clear (so the caller's count stays accurate). Atomicity comes from
158
+ # create's outer bulk transaction — only called from inside it. (No inner
159
+ # transaction: without requires_new it would be a bare yield, and it isn't
160
+ # wanted — a failure anywhere rolls back the whole batch by design.)
73
161
  def clear_org_role(subject, assignment, prior_role)
74
162
  return false unless assignment.persisted? # nothing to clear ⇒ no event
75
163
 
76
- RoleAssignment.transaction do
77
- assignment.destroy!
78
- Event.record!(event: "org_role.removed", target: subject, details: { role: prior_role.name })
79
- end
164
+ assignment.destroy!
165
+ Event.record!(event: "org_role.removed", target: subject, details: { role: prior_role.name })
80
166
  true
81
167
  end
82
168
 
@@ -88,16 +174,15 @@ module CurrentScope
88
174
  new_role = Role.find(params.expect(:role_id))
89
175
  changed = prior_role.nil? || prior_role.id != new_role.id
90
176
 
91
- RoleAssignment.transaction do
92
- assignment.update!(role: new_role)
93
- if prior_role.nil?
94
- Event.record!(event: "org_role.assigned", target: subject, details: { role: new_role.name })
95
- elsif prior_role.id != new_role.id
96
- Event.record!(event: "org_role.changed", target: subject,
97
- details: { from: prior_role.name, to: new_role.name })
98
- end
99
- # same role re-set ⇒ no change ⇒ no event
177
+ # Atomicity comes from create's outer bulk transaction (see clear_org_role).
178
+ assignment.update!(role: new_role)
179
+ if prior_role.nil?
180
+ Event.record!(event: "org_role.assigned", target: subject, details: { role: new_role.name })
181
+ elsif prior_role.id != new_role.id
182
+ Event.record!(event: "org_role.changed", target: subject,
183
+ details: { from: prior_role.name, to: new_role.name })
100
184
  end
185
+ # same role re-set ⇒ no change ⇒ no event
101
186
  changed
102
187
  end
103
188
  end
@@ -1,7 +1,8 @@
1
1
  module CurrentScope
2
2
  class RolesController < ApplicationController
3
3
  def index
4
- @roles = Role.order(:name)
4
+ # Includes for delete-confirm holder counts (cascade warning).
5
+ @roles = Role.order(:name).includes(:role_assignments, :scoped_role_assignments)
5
6
  end
6
7
 
7
8
  def new
@@ -55,12 +56,36 @@ module CurrentScope
55
56
  end
56
57
 
57
58
  def update
58
- @role = Role.find(params[:id])
59
- previous_name = @role.name
59
+ permitted = role_params
60
+ previous_name = nil
60
61
  saved = false
62
+ refused = false
63
+
64
+ # Lock full-access roles + holders inside the write transaction so two
65
+ # concurrent demotions of the last held full-access roles cannot both pass
66
+ # a pre-transaction check and then both commit.
61
67
  Role.transaction do
62
- saved = @role.update(role_params)
63
- record_role_update(@role, previous_name) if saved
68
+ # Lock FA console state before the target role so concurrent demote/delete
69
+ # of two FA roles cannot invert lock order (roles first, then assignments).
70
+ lock_full_access_console_state!
71
+ @role = Role.lock.find(params[:id])
72
+
73
+ if demoting_would_lock_console?(@role, permitted)
74
+ refused = true
75
+ else
76
+ previous_name = @role.name
77
+ previous_full_access = @role.full_access?
78
+ saved = @role.update(permitted)
79
+ record_role_update(@role, previous_name, previous_full_access) if saved
80
+ end
81
+ end
82
+
83
+ if refused
84
+ redirect_to edit_role_path(@role),
85
+ alert: "Refusing to remove full access — this is the last full-access role " \
86
+ "any subject holds and would lock everyone out of this UI. Grant " \
87
+ "full access to another subject first, then retry."
88
+ return
64
89
  end
65
90
 
66
91
  if saved
@@ -71,44 +96,81 @@ module CurrentScope
71
96
  end
72
97
 
73
98
  def destroy
74
- role = Role.find(params[:id])
99
+ refused = false
100
+
101
+ Role.transaction do
102
+ lock_full_access_console_state!
103
+ role = Role.lock.find(params[:id])
104
+
105
+ if would_lock_console_by_removing_role?(role)
106
+ refused = true
107
+ else
108
+ # Snapshot WITHOUT polymorphic includes — includes(:subject)/:resource
109
+ # can raise NameError for stale types at preload (members page avoids
110
+ # this for the same reason). Resolve each row inside the helpers.
111
+ org_removed = role.role_assignments.to_a
112
+ scoped_revoked = role.scoped_role_assignments.to_a
113
+
114
+ role.destroy!
115
+ Event.record!(event: "role.deleted", target: role, details: { name: role.name })
116
+ org_removed.each do |a|
117
+ Event.record!(event: "org_role.removed", target: cascade_subject(a), details: { role: role.name })
118
+ end
119
+ scoped_revoked.each do |a|
120
+ Event.record!(event: "scoped_role.revoked", target: cascade_subject(a),
121
+ details: { role: role.name, resource: cascade_resource_label(a) })
122
+ end
123
+ end
124
+ end
75
125
 
76
- if last_full_access?(role)
126
+ if refused
77
127
  redirect_to roles_path,
78
- alert: "Refusing to delete the last full-access role — it would lock everyone out of this UI."
128
+ alert: "Refusing to delete this full-access role — it is the last one held by any " \
129
+ "subject and would lock everyone out of this UI. Grant full access to " \
130
+ "another subject first, then retry."
79
131
  return
80
132
  end
81
133
 
82
- # Snapshot the cascade BEFORE destroy! — dependent: :destroy takes the
83
- # assignments with the role, so they can't be read afterwards.
84
- org_removed = role.role_assignments.includes(:subject).to_a
85
- scoped_revoked = role.scoped_role_assignments.includes(:subject, :resource).to_a
86
-
87
- Role.transaction do
88
- role.destroy!
89
- Event.record!(event: "role.deleted", target: role, details: { name: role.name })
90
- org_removed.each do |a|
91
- Event.record!(event: "org_role.removed", target: a.subject, details: { role: role.name })
92
- end
93
- scoped_revoked.each do |a|
94
- Event.record!(event: "scoped_role.revoked", target: a.subject,
95
- details: { role: role.name, resource: helpers.current_scope_label(a.resource) })
96
- end
97
- end
98
134
  redirect_to roles_path, notice: "Role deleted."
99
135
  end
100
136
 
101
137
  private
102
138
 
139
+ # Polymorphic subject/resource may be deleted or unresolvable — never 500
140
+ # the cascade audit. Deleted records return nil without raising (especially
141
+ # after includes preload), so use || assignment, not rescue-only.
142
+ def cascade_subject(assignment)
143
+ assignment.subject || assignment
144
+ rescue ActiveRecord::RecordNotFound, NameError
145
+ assignment
146
+ end
147
+
148
+ def cascade_resource_label(assignment)
149
+ resource = begin
150
+ assignment.resource
151
+ rescue ActiveRecord::RecordNotFound, NameError
152
+ nil
153
+ end
154
+ return "#{assignment.resource_type}##{assignment.resource_id}" if resource.nil?
155
+
156
+ helpers.current_scope_label(resource)
157
+ rescue StandardError
158
+ "#{assignment.resource_type}##{assignment.resource_id}"
159
+ end
160
+
103
161
  # One event per save: role.renamed when the name changed (carries old/new
104
- # name AND the grid diff), else role.updated for a pure grid change. Emits
105
- # nothing when neither the name nor the grid moved.
106
- def record_role_update(role, previous_name)
107
- diff = role.permission_keys_change || { added: [], removed: [] }
162
+ # name AND the grid/full_access diff), else role.updated. Emits nothing on
163
+ # a pure no-op (same name, same grid, same full_access).
164
+ def record_role_update(role, previous_name, previous_full_access)
165
+ diff = role.permission_keys_change || { added: [], removed: [], rejected: [] }
108
166
  renamed = previous_name != role.name
109
- return unless renamed || diff[:added].any? || diff[:removed].any?
167
+ full_access_changed = previous_full_access != role.full_access?
168
+ return unless renamed || full_access_changed || diff[:added].any? || diff[:removed].any?
110
169
 
111
170
  details = { added: diff[:added], removed: diff[:removed] }
171
+ if full_access_changed
172
+ details.merge!(full_access_from: previous_full_access, full_access_to: role.full_access?)
173
+ end
112
174
  event = "role.updated"
113
175
  if renamed
114
176
  event = "role.renamed"
@@ -117,8 +179,40 @@ module CurrentScope
117
179
  Event.record!(event: event, target: role, details: details)
118
180
  end
119
181
 
120
- def last_full_access?(role)
121
- role.full_access? && !Role.where(full_access: true).where.not(id: role.id).exists?
182
+ # True when removing/demoting this full_access role would leave zero
183
+ # full_access org holders. An unassigned full_access role is always safe
184
+ # to delete/demote (cubic). An empty spare full_access role must NOT
185
+ # authorize demoting the held Owner (CE) — check holders, not role rows.
186
+ def would_lock_console_by_removing_role?(role)
187
+ return false unless role.full_access?
188
+ return false unless RoleAssignment.where(role: role).exists?
189
+
190
+ !RoleAssignment.joins(:role)
191
+ .where(current_scope_roles: { full_access: true })
192
+ .where.not(role_id: role.id)
193
+ .exists?
194
+ end
195
+
196
+ # True when the update would turn off full_access and lock the console.
197
+ # Only treats an EXPLICIT full_access=false as demotion — a missing key
198
+ # would not change the column and must not false-positive refuse.
199
+ def demoting_would_lock_console?(role, permitted)
200
+ return false unless role.full_access?
201
+ return false unless permitted.key?(:full_access)
202
+ return false if ActiveModel::Type::Boolean.new.cast(permitted[:full_access])
203
+
204
+ would_lock_console_by_removing_role?(role)
205
+ end
206
+
207
+ # Serialize demote/delete against concurrent last-holder removal. Lock FA
208
+ # role rows and their org-wide holder assignments (by id — FOR UPDATE + join
209
+ # is adapter-fragile). Call only inside a transaction.
210
+ def lock_full_access_console_state!
211
+ Role.where(full_access: true).lock.load
212
+ ids = RoleAssignment.joins(:role)
213
+ .where(current_scope_roles: { full_access: true })
214
+ .pluck(:id)
215
+ RoleAssignment.where(id: ids).lock.load if ids.any?
122
216
  end
123
217
 
124
218
  def role_params
@@ -126,7 +220,11 @@ module CurrentScope
126
220
  # Grid group columns (CRUD checkboxes) submit "controller:group" tokens on
127
221
  # a separate, optional channel — permitted leniently so a raw permission_keys
128
222
  # post (no groups) still works. Expand them into action keys; the model's
129
- # permission_keys= dedups and drops anything not in the catalog.
223
+ # permission_keys= dedups, and REJECTS anything not in the catalog (the
224
+ # save fails and `edit` re-renders with the error). The grid can't submit
225
+ # such a key — cells are built from routed actions only — so a rejection
226
+ # here means a hand-crafted request, which is worth saying out loud rather
227
+ # than dropping silently.
130
228
  groups = params.fetch(:role, {}).permit(permission_groups: [])[:permission_groups]
131
229
  permitted[:permission_keys] = Array(permitted[:permission_keys]) + PermissionGrid.new.expand(groups)
132
230
  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