current_scope 0.3.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.
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: cb3cfaf17408e822d2ce269223bed45714e97dad48d68ad68826cb398c39434f
4
- data.tar.gz: 20729feebd795748b0898e62d4934b1ca3ed490ee585fcf911ad37d5d500e2a5
3
+ metadata.gz: b43df7725417ffd6088533edb72032fd26a4bad9e8eefb6a56fa52d9a63a29e3
4
+ data.tar.gz: b77bb0cdb38b19efd19ac754666d23abe14220ae3a0b70566f217eda26b016cf
5
5
  SHA512:
6
- metadata.gz: 7b6ede764a4924731f2a92c118dd0a2c018de8a808ddbd8d9010eacd862e0e57e972dcf3fe56a4337b239e052039dc0e022a1ef0bd7113c1fa1835e5d4c55f7a
7
- data.tar.gz: 9b8823b93863c93d01eecb46a2773ad7fa3dd7fe0b9908f9a5f50137492ffb7ad4a10cbddc8207aaab0f4ed4ba27f572d5624b8687da6069878abff6465838e3
6
+ metadata.gz: dedf447ca858342304bbd1dddeff2187291bd78780271f01328e9d328b090081f3c1bc293639dc4e2855b719ba9a61f4d49befac17673cb7f44dca10d2405037
7
+ data.tar.gz: 56cd899f391607a79bf550fb416f961911b9c192e6a430bceedc3a92fabcab251bb0cad7aeabce20f4b10c9463939f0bd6afa6e55d7b535d6fbd047ea9fc426d
@@ -659,6 +659,8 @@ input[type="checkbox"], input[type="radio"] {
659
659
  host might place in the sidebar or topbar. */
660
660
  .cs-main form p { margin: 0 0 1rem; max-width: 30rem; }
661
661
 
662
+ /* Non-control captions (bulk subjects line) share form-label type. */
663
+ .cs-main form p > .cs-label,
662
664
  .cs-main form p > label {
663
665
  display: block;
664
666
  font-size: 0.7rem;
@@ -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,6 +105,54 @@ 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
157
  # to clear (so the caller's count stays accurate). Atomicity comes from
73
158
  # create's outer bulk transaction — only called from inside it. (No inner
@@ -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)
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)
107
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
@@ -30,9 +30,23 @@
30
30
  </td>
31
31
  <td>
32
32
  <%= link_to "Members", members_role_path(role), class: "cs-btn" %>
33
+ <%
34
+ org_n = role.role_assignments.size
35
+ scoped_n = role.scoped_role_assignments.size
36
+ parts = []
37
+ parts << "#{org_n} org-wide #{"holder".pluralize(org_n)}" if org_n.positive?
38
+ parts << "#{scoped_n} scoped #{"holder".pluralize(scoped_n)}" if scoped_n.positive?
39
+ delete_prompt = if parts.empty?
40
+ "Delete role #{role.name}?"
41
+ else
42
+ "Delete role #{role.name}? This also removes it from #{parts.to_sentence}."
43
+ end
44
+ %>
33
45
  <%= button_to "Delete", role_path(role), method: :delete,
34
- form: { data: { cs_confirm: "Delete role #{role.name}?" } },
35
- data: { turbo_confirm: "Delete role #{role.name}?" } %>
46
+ class: "cs-btn cs-btn-danger",
47
+ form: { data: { cs_confirm: delete_prompt } },
48
+ aria: { label: "Delete role #{role.name}" },
49
+ data: { turbo_confirm: delete_prompt } %>
36
50
  </td>
37
51
  </tr>
38
52
  <% end %>
@@ -24,15 +24,15 @@
24
24
  <%= form_with url: new_scoped_role_assignment_path, method: :get, id: "cs-cascade",
25
25
  data: { turbo_frame: "cascade" }, class: "cs-picker" do %>
26
26
  <p>
27
- <label>Role</label>
27
+ <%= label_tag :role_id, "Role" %>
28
28
  <%= select_tag :role_id,
29
29
  options_from_collection_for_select(@roles, :id, :name, params[:role_id]),
30
30
  data: { current_scope_autosubmit: true } %>
31
31
  </p>
32
32
  <% if @bulk_subjects.any? %>
33
33
  <p>
34
- <label>Subjects</label>
35
- <span class="cs-hint">
34
+ <span class="cs-label" id="cs_bulk_subjects_label">Subjects</span>
35
+ <span class="cs-hint" aria-labelledby="cs_bulk_subjects_label">
36
36
  Granting to <strong><%= @bulk_subjects.size %></strong>:
37
37
  <%= @bulk_subjects.map { |s| current_scope_subject_label(s) }.to_sentence %>
38
38
  </span>
@@ -42,7 +42,7 @@
42
42
  </p>
43
43
  <% else %>
44
44
  <p>
45
- <label>Subject</label>
45
+ <%= label_tag :subject_gid, "Subject" %>
46
46
  <%= select_tag :subject_gid,
47
47
  options_for_select(@subjects.map { |s| [ current_scope_subject_label(s), s.to_gid.to_s ] },
48
48
  params[:subject_gid]),
@@ -67,7 +67,7 @@
67
67
  end
68
68
  %>
69
69
  <p>
70
- <label>Resource type</label>
70
+ <%= label_tag :resource_type, "Resource type" %>
71
71
  <%= select_tag :resource_type,
72
72
  options_for_select(type_options, @resource_type&.name),
73
73
  include_blank: "— choose a type —", form: "cs-cascade",
@@ -82,13 +82,13 @@
82
82
  <% else %>
83
83
  <% if @searchable %>
84
84
  <p>
85
- <label>Search records</label>
85
+ <%= label_tag :q, "Search records" %>
86
86
  <%= search_field_tag :q, params[:q], placeholder: "Filter by label",
87
87
  form: "cs-cascade", data: { current_scope_autosubmit: true } %>
88
88
  </p>
89
89
  <% end %>
90
90
  <p>
91
- <label>Record</label>
91
+ <%= label_tag :resource_gid, "Record" %>
92
92
  <%
93
93
  record_options = (@records || []).map { |record| [ current_scope_label(record), record.to_gid.to_s ] }
94
94
  # Keep a deep-linked record selectable even if it fell outside the
@@ -77,8 +77,10 @@
77
77
  <%= hidden_field_tag :subject_gid, subject.to_gid %>
78
78
  <%= select_tag :role_id,
79
79
  options_from_collection_for_select(@roles, :id, :name, @assignments[key]&.role_id),
80
- include_blank: "— none —" %>
81
- <%= submit_tag "Set", name: nil %>
80
+ include_blank: "— none —",
81
+ aria: { label: "Org-wide role for #{label}" } %>
82
+ <%= submit_tag "Set", name: nil,
83
+ aria: { label: "Set org-wide role for #{label}" } %>
82
84
  <% end %>
83
85
  </td>
84
86
  <td>
@@ -17,7 +17,11 @@ module CurrentScope
17
17
  # EMPTY BY DEFAULT — SoD is opt-in. The engine's baseline is scoped RBAC;
18
18
  # many hosts want nothing to do with four-eyes. Enable it by listing the
19
19
  # actions to gate, e.g. `config.sod_actions = %w[approve]`.
20
- attr_accessor :sod_actions
20
+ #
21
+ # Matched as STRINGS against the key's action segment (see Resolver#sod_action?).
22
+ # Use the writer below — a plain Symbol list ([:approve]) used to silently
23
+ # disable the veto (#91). Same shape as collection_read_actions=.
24
+ attr_reader :sod_actions
21
25
 
22
26
  # Which identities the separation-of-duties veto weighs:
23
27
  # :either (default) — veto if the effective subject OR (while
@@ -32,6 +36,32 @@ module CurrentScope
32
36
 
33
37
  SOD_IDENTITY_MODES = %i[either subject].freeze
34
38
 
39
+ # Normalizing writer for sod_actions (#91). The resolver matches with
40
+ # include?(permission.split("#").last) — a String — so symbols never match
41
+ # and silently turn the fraud control off. Normalize symbols to strings,
42
+ # reject non-String/Symbol elements and full keys (same honesty as
43
+ # collection_read_actions=), freeze the list.
44
+ def sod_actions=(value)
45
+ elements = Array(value)
46
+
47
+ if (bad = elements.reject { |e| e.is_a?(String) || e.is_a?(Symbol) }).any?
48
+ raise ConfigurationError,
49
+ "config.sod_actions takes action NAMES (strings or symbols); got " \
50
+ "#{bad.map(&:inspect).join(', ')}. Write e.g. %w[approve] or [:approve]."
51
+ end
52
+
53
+ actions = elements.map(&:to_s)
54
+
55
+ if (keyed = actions.grep(/#/)).any?
56
+ raise ConfigurationError,
57
+ "config.sod_actions is matched on the ACTION segment of a key — " \
58
+ "#{keyed.map(&:inspect).join(', ')} can never match. Write \"approve\", " \
59
+ "not \"reports#approve\"."
60
+ end
61
+
62
+ @sod_actions = actions.freeze
63
+ end
64
+
35
65
  # Validating writer, same contract as enforcement=: the resolver compares
36
66
  # `== :either`, so a typo (`:both`, `:actor`) would otherwise silently
37
67
  # behave as :subject — narrowing the fraud control with no signal. Raise at
@@ -161,12 +191,11 @@ module CurrentScope
161
191
  # reads, so review the declaration like the record hook.
162
192
  attr_reader :collection_read_actions
163
193
 
164
- # Normalizing writer, unlike sod_actions' plain accessor (that footgun is
165
- # grandfathered, not precedent): the list is matched as STRINGS, so
166
- # [:index] matching nothing would silently un-fix #65. Normalize rather
167
- # than raise for shape (nil [], symbols strings — unambiguous), but
168
- # two inputs get a voice, because a silently-inert or silently-widened
169
- # security knob is the failure this writer exists to prevent:
194
+ # Normalizing writer: the list is matched as STRINGS, so [:index] matching
195
+ # nothing would silently un-fix #65. Normalize rather than raise for shape
196
+ # (nil ⇒ [], symbols strings unambiguous), but two inputs get a voice,
197
+ # because a silently-inert or silently-widened security knob is the failure
198
+ # this writer exists to prevent:
170
199
  #
171
200
  # - a full KEY ("reports#index") RAISES — the list is matched on the
172
201
  # action segment and applies to every controller, so a keyed member
@@ -213,9 +242,11 @@ module CurrentScope
213
242
  @collection_read_actions = actions.freeze
214
243
  end
215
244
 
216
- # The three canonical Rails write actions the unambiguous slice of the
217
- # mutating-action space the writer above warns about.
218
- MUTATING_ACTION_NAMES = %w[create update destroy].freeze
245
+ # Canonical mutating / bulk-write action names the collection_read writer
246
+ # warns about. Includes destroy_all/update_all the docs' escalation
247
+ # examples so those cannot reintroduce #49 via config with no signal.
248
+ # Custom names still evade any partial blocklist; that ceiling is deliberate.
249
+ MUTATING_ACTION_NAMES = %w[create update destroy destroy_all update_all].freeze
219
250
 
220
251
  # Tri-state: false | true (default) | :strict — controls
221
252
  # CurrentScope::Event.record!.
@@ -407,7 +438,7 @@ module CurrentScope
407
438
  def initialize
408
439
  @user_method = :current_user
409
440
  @actor_method = nil
410
- @sod_actions = []
441
+ @sod_actions = [].freeze
411
442
  @sod_identity = :either
412
443
  @allow_sod_bypass = false
413
444
  @sod_bypass_permission = "bypass_sod"
@@ -134,8 +134,10 @@ module CurrentScope
134
134
  CurrentScope::Current.collection_model = record.equal?(NO_RECORD) ? nil : model
135
135
  CurrentScope::Current.collection_model_path = controller_path
136
136
 
137
- # The real actor (Current.actor) enters here explicitly the resolver
138
- # never reads Current itself (PDP purity). It only matters under SoD
137
+ # Decision *inputs* (subject, permission, record, model, actor) are
138
+ # explicit the resolver does not read ambient identity for the allow/
139
+ # deny answer. Org-role *lookup* may use Current.memoized_org_role (a
140
+ # per-request cache, not a decision input). Actor only widens SoD under
139
141
  # :either while impersonating; otherwise actor == subject.
140
142
  allowed, reason = CurrentScope.resolver.decide(
141
143
  subject: CurrentScope::Current.user, permission: permission,
@@ -60,17 +60,6 @@ module CurrentScope
60
60
  # default — the class form allowed_to?(:index, Report) is how you ask about
61
61
  # another controller, and it binds from its argument (R5). (KTD-6)
62
62
  #
63
- # The match keys on the KEY's controller, not just the controller: kwarg: a
64
- # full "reports#index" key from a projects view names "reports" and must NOT
65
- # borrow the projects ambient (a Project grant answering a reports key). A
66
- # bare action uses the resolved controller. (#50 review, cubic)
67
- def ambient_collection_model(action, controller)
68
- key_controller = action.to_s.include?("#") ? action.to_s.split("#").first : controller
69
- return nil unless key_controller && key_controller == CurrentScope::Current.collection_model_path
70
-
71
- CurrentScope::Current.collection_model
72
- end
73
-
74
63
  def current_scope_user
75
64
  CurrentScope::Current.user
76
65
  end
@@ -87,5 +76,19 @@ module CurrentScope
87
76
  def impersonating?
88
77
  CurrentScope::Current.impersonating?
89
78
  end
79
+
80
+ private
81
+
82
+ # Internal binding for gate/view agreement (#50 KTD-6) — not host API.
83
+ # Private so it does not appear in controller action_methods. Matches on
84
+ # the KEY's controller, not just the controller: kwarg: a full
85
+ # "reports#index" key from a projects view must not borrow the projects
86
+ # ambient. A bare action uses the resolved controller. (#50 review, cubic)
87
+ def ambient_collection_model(action, controller)
88
+ key_controller = action.to_s.include?("#") ? action.to_s.split("#").first : controller
89
+ return nil unless key_controller && key_controller == CurrentScope::Current.collection_model_path
90
+
91
+ CurrentScope::Current.collection_model
92
+ end
90
93
  end
91
94
  end
@@ -1,3 +1,3 @@
1
1
  module CurrentScope
2
- VERSION = "0.3.0"
2
+ VERSION = "0.3.1"
3
3
  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.3.0
4
+ version: 0.3.1
5
5
  platform: ruby
6
6
  authors:
7
7
  - David Teren