current_scope 0.3.0 → 0.4.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.
@@ -33,30 +33,45 @@ module CurrentScope
33
33
  # details: { name: "Owner" })
34
34
  #
35
35
  # Raises ConfigurationError (loud, matching the SoD posture) when there is
36
- # no ambient actor. Silent no-op (returns nil) when config.audit is false.
37
- def record!(event:, target:, details: nil)
36
+ # no ambient actor and no explicit actor: override. Silent no-op (returns
37
+ # nil) when config.audit is false.
38
+ #
39
+ # Optional actor:/subject: overrides (#30): when non-nil they replace the
40
+ # ambient reads so bootstrap paths (grant!, rake, seeds) can self-attribute
41
+ # without a controller. Omit both for byte-for-byte ambient behavior.
42
+ # Pin BOTH on a self-attributed grant so an ambient Current.user cannot
43
+ # leak into subject and mis-record the row as impersonation.
44
+ def record!(event:, target:, details: nil, actor: nil, subject: nil)
38
45
  return unless CurrentScope.config.audit
39
46
 
40
- actor = CurrentScope::Current.actor
47
+ actor ||= CurrentScope::Current.actor
41
48
  if actor.nil?
42
49
  raise CurrentScope::ConfigurationError,
43
50
  "CurrentScope::Event.record! has no actor — CurrentScope::Current.actor is nil. " \
44
51
  "Set the ambient context (the controller hook, or with_current_user in tests) before recording."
45
52
  end
46
53
 
47
- # Current.user is the effective subject; fall back to actor so subject
48
- # is never nil (it equals actor whenever not impersonating).
49
- subject = CurrentScope::Current.user || actor
54
+ # Explicit subject wins; else Current.user; else actor (never nil when
55
+ # actor is set, equals actor when not impersonating).
56
+ subject ||= CurrentScope::Current.user || actor
50
57
 
51
- create!(
52
- event: event.to_s,
53
- actor: actor.to_gid.to_s,
54
- subject: subject.to_gid.to_s,
55
- target: target.to_gid.to_s,
56
- target_label: label_for(target),
57
- details: details,
58
- request_id: CurrentScope::Current.request_id
59
- )
58
+ # requires_new: on PostgreSQL a StatementInvalid aborts the *whole*
59
+ # open transaction even if rescued — so a missing events table would
60
+ # poison grant!/controller mutation transactions that wrap record!.
61
+ # A savepoint isolates the audit write: default audit=true degrades
62
+ # and the assignment commits; :strict re-raises and rolls the outer
63
+ # mutation back (PR #102 review).
64
+ Event.transaction(requires_new: true) do
65
+ create!(
66
+ event: event.to_s,
67
+ actor: actor.to_gid.to_s,
68
+ subject: subject.to_gid.to_s,
69
+ target: target.to_gid.to_s,
70
+ target_label: label_for(target),
71
+ details: details,
72
+ request_id: CurrentScope::Current.request_id
73
+ )
74
+ end
60
75
  rescue ActiveRecord::StatementInvalid => e
61
76
  raise unless missing_events_table?(e)
62
77
 
@@ -2,6 +2,10 @@ module CurrentScope
2
2
  # A role held on ONE specific record: "Editor of Project #7" grants nothing
3
3
  # on Project #8. Never touches the subject's org-wide role — the two are
4
4
  # independent axes.
5
+ #
6
+ # Rows survive host resource destruction by design (polymorphic, no
7
+ # dependent:). Since #65 those orphan grants open nothing (empty list = 403)
8
+ # but still rendered like live access until labeled (#90).
5
9
  class ScopedRoleAssignment < ApplicationRecord
6
10
  belongs_to :role
7
11
  belongs_to :subject, polymorphic: true
@@ -10,5 +14,52 @@ module CurrentScope
10
14
  validates :role_id, uniqueness: {
11
15
  scope: [ :subject_type, :subject_id, :resource_type, :resource_id ]
12
16
  }
17
+
18
+ # Batch-load polymorphic resources for resolvable types only. A global
19
+ # includes(:resource) NameErrors when any resource_type is stale; this
20
+ # constantizes per type and skips unresolvable ones so they stay lazy
21
+ # and orphaned_resource? labels them inert (#90 / PR #104 review).
22
+ def self.preload_resolvable_resources!(assignments)
23
+ list = Array(assignments)
24
+ return list if list.empty?
25
+
26
+ list.group_by(&:resource_type).each do |type, rows|
27
+ next if type.blank?
28
+
29
+ klass =
30
+ begin
31
+ type.constantize
32
+ rescue NameError
33
+ next
34
+ end
35
+ next unless klass.respond_to?(:where)
36
+
37
+ records = klass.where(id: rows.map(&:resource_id).uniq).index_by { |r| r.id }
38
+ rows.each do |row|
39
+ assoc = row.association(:resource)
40
+ assoc.target = records[row.resource_id]
41
+ assoc.loaded!
42
+ end
43
+ end
44
+
45
+ list
46
+ end
47
+
48
+ # True when the pointed-at resource is gone (deleted row or unresolvable
49
+ # type). The grant is inert for authorization (#65) but still a console row.
50
+ # Memoized: views call this plus the label helper once each; a reset-every-
51
+ # call would re-query the resource twice per row (PR #104 cubic follow-up).
52
+ def orphaned_resource?
53
+ return @orphaned_resource if defined?(@orphaned_resource)
54
+
55
+ @orphaned_resource =
56
+ if resource_id.blank?
57
+ false
58
+ else
59
+ resource.nil?
60
+ end
61
+ rescue NameError, ActiveRecord::RecordNotFound
62
+ @orphaned_resource = true
63
+ end
13
64
  end
14
65
  end
@@ -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 %>
@@ -77,12 +77,24 @@
77
77
  <% @scoped_holders.each do |sra| %>
78
78
  <% who = current_scope_holder_subject_label(sra) %>
79
79
  <% what = current_scope_holder_resource_label(sra) %>
80
- <tr>
80
+ <% orphaned = sra.orphaned_resource? %>
81
+ <tr id="scoped_holder_<%= sra.id %>"
82
+ class="cs-scoped-holder<%= ' cs-row--inert' if orphaned %>">
81
83
  <td><%= who %></td>
82
- <td><%= what %></td>
83
84
  <td>
84
- <% revoke_prompt = "Revoke #{@role.name} on #{what} from #{who}?" %>
85
- <%= button_to "Revoke", scoped_role_assignment_path(sra), method: :delete,
85
+ <%= what %>
86
+ <% if orphaned %>
87
+ <span class="cs-inert-badge"
88
+ title="This resource no longer resolves; the grant opens nothing on collection reads">inert</span>
89
+ <% end %>
90
+ </td>
91
+ <td>
92
+ <% revoke_prompt = orphaned ?
93
+ "Remove inert scoped grant (resource unavailable) for #{who}?" :
94
+ "Revoke #{@role.name} on #{what} from #{who}?" %>
95
+ <%= button_to (orphaned ? "Remove inert" : "Revoke"),
96
+ scoped_role_assignment_path(sra), method: :delete,
97
+ id: "scoped_revoke_#{sra.id}",
86
98
  class: "cs-btn", form: { data: { cs_confirm: revoke_prompt } },
87
99
  data: { turbo_confirm: revoke_prompt } %>
88
100
  </td>
@@ -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
@@ -63,7 +63,9 @@
63
63
  <select> listing all role names, which would match every query. %>
64
64
  <% org_role_name = @roles.find { |r| r.id == @assignments[key]&.role_id }&.name %>
65
65
  <% filter_text = [ label, org_role_name,
66
- *(@scoped[key] || []).map { |sra| "#{sra.role.name} #{current_scope_label(sra.resource)}" } ].compact.join(" ") %>
66
+ *(@scoped[key] || []).map { |sra|
67
+ "#{sra.role.name} #{current_scope_holder_resource_label(sra)}"
68
+ } ].compact.join(" ") %>
67
69
  <tr data-cs-row data-cs-filter-text="<%= filter_text %>">
68
70
  <td class="cs-select-cell">
69
71
  <input type="checkbox" data-cs-select value="<%= subject.to_gid %>"
@@ -77,20 +79,30 @@
77
79
  <%= hidden_field_tag :subject_gid, subject.to_gid %>
78
80
  <%= select_tag :role_id,
79
81
  options_from_collection_for_select(@roles, :id, :name, @assignments[key]&.role_id),
80
- include_blank: "— none —" %>
81
- <%= submit_tag "Set", name: nil %>
82
+ include_blank: "— none —",
83
+ aria: { label: "Org-wide role for #{label}" } %>
84
+ <%= submit_tag "Set", name: nil,
85
+ aria: { label: "Set org-wide role for #{label}" } %>
82
86
  <% end %>
83
87
  </td>
84
88
  <td>
85
89
  <% (@scoped[key] || []).each do |sra| %>
86
- <span class="cs-chip">
87
- <span class="cs-chip-label"><%= sra.role.name %> of <%= current_scope_label(sra.resource) %></span>
88
- <% revoke_prompt = "Revoke #{sra.role.name} on #{current_scope_label(sra.resource)}?" %>
90
+ <% resource_label = current_scope_holder_resource_label(sra) %>
91
+ <% orphaned = sra.orphaned_resource? %>
92
+ <span class="cs-chip cs-scoped-chip<%= ' cs-chip--inert' if orphaned %>" id="scoped_chip_<%= sra.id %>">
93
+ <span class="cs-chip-label">
94
+ <%= sra.role.name %> of <%= resource_label %>
95
+ <% if orphaned %><span class="cs-inert-badge">inert</span><% end %>
96
+ </span>
97
+ <% revoke_prompt = orphaned ?
98
+ "Remove inert scoped grant (#{resource_label})?" :
99
+ "Revoke #{sra.role.name} on #{resource_label}?" %>
89
100
  <%= button_to "×", scoped_role_assignment_path(sra), method: :delete,
90
101
  class: "cs-chip-remove",
91
- title: "Revoke this scoped role",
102
+ id: "scoped_chip_revoke_#{sra.id}",
103
+ title: (orphaned ? "Remove inert scoped grant" : "Revoke this scoped role"),
92
104
  form: { data: { cs_confirm: revoke_prompt } },
93
- aria: { label: "Revoke #{sra.role.name} on #{current_scope_label(sra.resource)}" },
105
+ aria: { label: (orphaned ? "Remove inert #{sra.role.name} grant on #{resource_label}" : "Revoke #{sra.role.name} on #{resource_label}") },
94
106
  data: { turbo_confirm: revoke_prompt } %>
95
107
  </span>
96
108
  <% end %>
@@ -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
@@ -76,6 +106,8 @@ module CurrentScope
76
106
  # controllers out of the permission grid. An excluded controller cannot be
77
107
  # granted, so it must also skip the gate
78
108
  # (skip_before_action :current_scope_check!) — Guard raises otherwise.
109
+ # Skipping the gate leaves the controller ungated by CurrentScope — protect
110
+ # it with your own authorization (e.g. require_admin!). See docs/SECURITY-CHECKLIST.md.
79
111
  attr_accessor :excluded_controllers
80
112
 
81
113
  # Class the management UI's controllers inherit from, so they pick up the
@@ -161,12 +193,11 @@ module CurrentScope
161
193
  # reads, so review the declaration like the record hook.
162
194
  attr_reader :collection_read_actions
163
195
 
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:
196
+ # Normalizing writer: the list is matched as STRINGS, so [:index] matching
197
+ # nothing would silently un-fix #65. Normalize rather than raise for shape
198
+ # (nil ⇒ [], symbols strings unambiguous), but two inputs get a voice,
199
+ # because a silently-inert or silently-widened security knob is the failure
200
+ # this writer exists to prevent:
170
201
  #
171
202
  # - a full KEY ("reports#index") RAISES — the list is matched on the
172
203
  # action segment and applies to every controller, so a keyed member
@@ -213,9 +244,11 @@ module CurrentScope
213
244
  @collection_read_actions = actions.freeze
214
245
  end
215
246
 
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
247
+ # Canonical mutating / bulk-write action names the collection_read writer
248
+ # warns about. Includes destroy_all/update_all the docs' escalation
249
+ # examples so those cannot reintroduce #49 via config with no signal.
250
+ # Custom names still evade any partial blocklist; that ceiling is deliberate.
251
+ MUTATING_ACTION_NAMES = %w[create update destroy destroy_all update_all].freeze
219
252
 
220
253
  # Tri-state: false | true (default) | :strict — controls
221
254
  # CurrentScope::Event.record!.
@@ -407,7 +440,7 @@ module CurrentScope
407
440
  def initialize
408
441
  @user_method = :current_user
409
442
  @actor_method = nil
410
- @sod_actions = []
443
+ @sod_actions = [].freeze
411
444
  @sod_identity = :either
412
445
  @allow_sod_bypass = false
413
446
  @sod_bypass_permission = "bypass_sod"
@@ -461,6 +494,43 @@ module CurrentScope
461
494
  @allow_mutations_while_impersonating = value
462
495
  end
463
496
 
497
+ # Action segment of sod_bypass_permission — bare name or "controller#action".
498
+ # Shared by boot validation and the resolver's recursion guard so the two
499
+ # never normalize differently (#40). Uses split("#", -1) like
500
+ # PermissionCatalog#bypass_action: plain split("#").last turns "reports#"
501
+ # into "reports" (trailing empty dropped) and multi-hash values into the
502
+ # wrong last segment — false conflicts or missed recursion guards.
503
+ # Malformed shapes return nil (no false conflict); the catalog still raises
504
+ # loudly when allow_sod_bypass is on and the key is malformed.
505
+ def sod_bypass_action
506
+ segments = sod_bypass_permission.to_s.split("#", -1)
507
+ return if segments.empty? || segments.size > 2 || segments.any?(&:blank?)
508
+
509
+ segments.last
510
+ end
511
+
512
+ # True when the break-glass bypass permission is also listed in sod_actions.
513
+ # That pairing would re-enter the SoD step on every bypass check and stack
514
+ # overflow. Never valid in any environment (#40).
515
+ def sod_bypass_permission_conflicts_with_sod_actions?
516
+ action = sod_bypass_action
517
+ action.present? && sod_actions.include?(action)
518
+ end
519
+
520
+ # Boot-time config invariants. Wired from Engine#after_initialize after the
521
+ # host initializer has finalized both fields. Extensible seam for future
522
+ # multi-field checks; today only the bypass-in-sod_actions recursion rule.
523
+ def validate!
524
+ return unless sod_bypass_permission_conflicts_with_sod_actions?
525
+
526
+ action = sod_bypass_action
527
+ raise ConfigurationError,
528
+ "config.sod_bypass_permission (#{sod_bypass_permission.inspect}) is the " \
529
+ "action #{action.inspect}, which is also in config.sod_actions. The bypass " \
530
+ "permission must not be an SoD action — it would recurse. Remove " \
531
+ "#{action.inspect} from sod_actions."
532
+ end
533
+
464
534
  private
465
535
 
466
536
  # The env var's VALUE means what it says — presence alone is not consent.
@@ -23,6 +23,11 @@ module CurrentScope
23
23
  # falls back to the subject. Only resolve when the host opts in.
24
24
  actor_method = CurrentScope.config.actor_method
25
25
  CurrentScope::Current.actor = resolve_current_scope_subject(actor_method) if actor_method
26
+
27
+ # Correlation for the audit ledger (#30). ActionDispatch::RequestId runs
28
+ # ahead of app before_actions; job/console contexts never enter this hook
29
+ # and leave request_id nil by design.
30
+ CurrentScope::Current.request_id = request.request_id
26
31
  end
27
32
 
28
33
  def resolve_current_scope_subject(method)
@@ -2,6 +2,35 @@ module CurrentScope
2
2
  class Engine < ::Rails::Engine
3
3
  isolate_namespace CurrentScope
4
4
 
5
+ # An AccessDenied that escapes any Guard rescue (PORO, Context-only
6
+ # controller, re-raise) must 403, not 500. Never turns a deny into an
7
+ # allow — the exception already blocked the action (#39).
8
+ #
9
+ # ||= so a host that already set this mapping (e.g. :not_found to hide
10
+ # existence) in config/application.rb is not clobbered. before:
11
+ # action_dispatch.configure so ExceptionWrapper.rescue_responses picks the
12
+ # entry up when it merge!s the config hash.
13
+ initializer "current_scope.rescue_responses", before: "action_dispatch.configure" do |app|
14
+ app.config.action_dispatch.rescue_responses["CurrentScope::AccessDenied"] ||= :forbidden
15
+ end
16
+
17
+ # Belt for Rails upgrades: if action_dispatch.configure is renamed/reordered
18
+ # and the config merge is missed, still pin the class map. Use key? — the
19
+ # ExceptionWrapper hash defaults missing keys to :internal_server_error
20
+ # (truthy), so ||= would never write.
21
+ initializer "current_scope.rescue_responses_apply", after: "action_dispatch.configure" do
22
+ map = ActionDispatch::ExceptionWrapper.rescue_responses
23
+ map["CurrentScope::AccessDenied"] = :forbidden unless map.key?("CurrentScope::AccessDenied")
24
+ end
25
+
26
+ # Cross-field config invariants (e.g. bypass permission ∉ sod_actions) must
27
+ # run AFTER the host initializer has assigned every field — a writer on
28
+ # either attr alone is order-dependent. once, not on to_prepare (config
29
+ # does not change on code reload). #40.
30
+ config.after_initialize do
31
+ CurrentScope.config.validate!
32
+ end
33
+
5
34
  # Routes (and therefore the derived permission catalog) can change on
6
35
  # every code reload in development, and reloaded host models must re-register
7
36
  # as scopeable rather than pile up stale/duplicate entries. Both reset here,