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.
- checksums.yaml +4 -4
- data/README.md +94 -17
- data/app/assets/stylesheets/current_scope/application.css +26 -0
- data/app/controllers/current_scope/application_controller.rb +5 -1
- data/app/controllers/current_scope/role_assignments_controller.rb +96 -11
- data/app/controllers/current_scope/roles_controller.rb +129 -34
- data/app/controllers/current_scope/subjects_controller.rb +6 -3
- data/app/helpers/current_scope/application_helper.rb +6 -0
- data/app/models/current_scope/event.rb +30 -15
- data/app/models/current_scope/scoped_role_assignment.rb +51 -0
- data/app/views/current_scope/roles/index.html.erb +16 -2
- data/app/views/current_scope/roles/members.html.erb +16 -4
- data/app/views/current_scope/scoped_role_assignments/new.html.erb +7 -7
- data/app/views/current_scope/subjects/index.html.erb +20 -8
- data/lib/current_scope/configuration.rb +81 -11
- data/lib/current_scope/context.rb +5 -0
- data/lib/current_scope/engine.rb +29 -0
- data/lib/current_scope/guard.rb +120 -25
- data/lib/current_scope/mutation_guard.rb +11 -1
- data/lib/current_scope/permissions.rb +14 -11
- data/lib/current_scope/resolver.rb +5 -9
- data/lib/current_scope/version.rb +1 -1
- data/lib/current_scope.rb +53 -6
- data/lib/generators/current_scope/install/templates/initializer.rb +7 -2
- data/lib/tasks/current_scope_tasks.rake +41 -12
- metadata +1 -1
|
@@ -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
|
|
37
|
-
|
|
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
|
|
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
|
-
#
|
|
48
|
-
# is
|
|
49
|
-
subject
|
|
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
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
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
|
-
|
|
35
|
-
data: {
|
|
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
|
-
|
|
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
|
-
|
|
85
|
-
|
|
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
|
-
|
|
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</
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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|
|
|
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
|
-
|
|
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
|
-
|
|
87
|
-
|
|
88
|
-
|
|
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
|
-
|
|
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: "
|
|
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
|
-
|
|
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
|
|
165
|
-
#
|
|
166
|
-
# [
|
|
167
|
-
#
|
|
168
|
-
#
|
|
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
|
-
#
|
|
217
|
-
#
|
|
218
|
-
|
|
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)
|
data/lib/current_scope/engine.rb
CHANGED
|
@@ -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,
|