current_scope 0.2.0 → 0.3.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- checksums.yaml +4 -4
- data/README.md +317 -18
- data/app/assets/javascripts/current_scope/application.js +4 -0
- data/app/assets/stylesheets/current_scope/application.css +99 -0
- data/app/controllers/current_scope/application_controller.rb +39 -1
- data/app/controllers/current_scope/role_assignments_controller.rb +14 -14
- data/app/controllers/current_scope/roles_controller.rb +6 -2
- data/app/helpers/current_scope/application_helper.rb +168 -12
- data/app/models/current_scope/current.rb +24 -0
- data/app/models/current_scope/event.rb +10 -6
- data/app/models/current_scope/role.rb +61 -10
- data/app/views/current_scope/roles/edit.html.erb +92 -4
- data/app/views/current_scope/roles/members.html.erb +3 -3
- data/app/views/current_scope/roles/new.html.erb +1 -1
- data/app/views/current_scope/scoped_role_assignments/new.html.erb +12 -3
- data/app/views/current_scope/shared/access_denied.html.erb +30 -0
- data/app/views/current_scope/subjects/index.html.erb +17 -5
- data/app/views/layouts/current_scope/application.html.erb +4 -1
- data/config/routes.rb +3 -4
- data/lib/current_scope/configuration.rb +378 -16
- data/lib/current_scope/engine.rb +7 -0
- data/lib/current_scope/gating_reflection.rb +62 -0
- data/lib/current_scope/gating_tripwire.rb +36 -5
- data/lib/current_scope/guard.rb +406 -8
- data/lib/current_scope/mutation_guard.rb +30 -5
- data/lib/current_scope/permission_catalog.rb +116 -3
- data/lib/current_scope/permission_grid.rb +34 -4
- data/lib/current_scope/permissions.rb +42 -8
- data/lib/current_scope/resolver.rb +317 -13
- data/lib/current_scope/version.rb +1 -1
- data/lib/current_scope.rb +113 -5
- data/lib/generators/current_scope/install/install_generator.rb +64 -0
- data/lib/generators/current_scope/install/templates/initializer.rb +94 -5
- data/lib/tasks/current_scope_tasks.rake +153 -0
- metadata +6 -2
|
@@ -104,7 +104,7 @@ module CurrentScope
|
|
|
104
104
|
# name AND the grid diff), else role.updated for a pure grid change. Emits
|
|
105
105
|
# nothing when neither the name nor the grid moved.
|
|
106
106
|
def record_role_update(role, previous_name)
|
|
107
|
-
diff = role.permission_keys_change || { added: [], removed: [] }
|
|
107
|
+
diff = role.permission_keys_change || { added: [], removed: [], rejected: [] }
|
|
108
108
|
renamed = previous_name != role.name
|
|
109
109
|
return unless renamed || diff[:added].any? || diff[:removed].any?
|
|
110
110
|
|
|
@@ -126,7 +126,11 @@ module CurrentScope
|
|
|
126
126
|
# Grid group columns (CRUD checkboxes) submit "controller:group" tokens on
|
|
127
127
|
# a separate, optional channel — permitted leniently so a raw permission_keys
|
|
128
128
|
# post (no groups) still works. Expand them into action keys; the model's
|
|
129
|
-
# permission_keys= dedups and
|
|
129
|
+
# permission_keys= dedups, and REJECTS anything not in the catalog (the
|
|
130
|
+
# save fails and `edit` re-renders with the error). The grid can't submit
|
|
131
|
+
# such a key — cells are built from routed actions only — so a rejection
|
|
132
|
+
# here means a hand-crafted request, which is worth saying out loud rather
|
|
133
|
+
# than dropping silently.
|
|
130
134
|
groups = params.fetch(:role, {}).permit(permission_groups: [])[:permission_groups]
|
|
131
135
|
permitted[:permission_keys] = Array(permitted[:permission_keys]) + PermissionGrid.new.expand(groups)
|
|
132
136
|
permitted
|
|
@@ -1,15 +1,13 @@
|
|
|
1
1
|
module CurrentScope
|
|
2
2
|
module ApplicationHelper
|
|
3
|
-
# Best-effort human label for any host subject/resource.
|
|
4
|
-
#
|
|
5
|
-
#
|
|
6
|
-
#
|
|
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
|
-
|
|
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
|
|
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.
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
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
|
-
#
|
|
94
|
-
#
|
|
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
|
-
|
|
97
|
-
return "#{record.model_name.human} ##{record.id}" if record.respond_to?(:model_name)
|
|
98
|
-
|
|
99
|
-
record.to_s
|
|
97
|
+
CurrentScope.label_for(record)
|
|
100
98
|
end
|
|
101
99
|
|
|
102
100
|
# Recognizes "table is missing" across adapters and Rails' own schema
|
|
@@ -112,6 +110,12 @@ module CurrentScope
|
|
|
112
110
|
!message.match?(/\bcolumn\b/i) &&
|
|
113
111
|
message.match?(/no such table|could not find table|does(?:n't| not) exist|unknown table|undefined table/i)
|
|
114
112
|
end
|
|
113
|
+
# PUBLIC: report mode's ledger warning asks this too (#37). It is one
|
|
114
|
+
# question — "is this the un-migrated-table case?" — and one adapter-shaped
|
|
115
|
+
# answer, so it gets one definition. A second opinion elsewhere is a second
|
|
116
|
+
# opinion that drifts, and this one decides which fix an operator is sent
|
|
117
|
+
# after. (#59 review)
|
|
118
|
+
public :missing_events_table?
|
|
115
119
|
end
|
|
116
120
|
end
|
|
117
121
|
end
|
|
@@ -8,13 +8,17 @@ module CurrentScope
|
|
|
8
8
|
has_many :scoped_role_assignments, dependent: :destroy
|
|
9
9
|
|
|
10
10
|
validates :name, presence: true, uniqueness: true
|
|
11
|
+
validate :permission_keys_in_catalog
|
|
11
12
|
|
|
12
13
|
after_save :persist_permission_keys
|
|
13
14
|
|
|
14
|
-
# The grid diff computed by the last save:
|
|
15
|
-
#
|
|
16
|
-
# save
|
|
17
|
-
#
|
|
15
|
+
# The grid diff computed by the last save:
|
|
16
|
+
# { added: [...], removed: [...], rejected: [...] }. Empty arrays on a no-op
|
|
17
|
+
# save; nil when no grid was staged (a programmatic save that never set
|
|
18
|
+
# permission_keys). `added`/`removed` describe what actually persisted;
|
|
19
|
+
# `rejected` names keys the scrub path threw away, so a caller that opted
|
|
20
|
+
# into silence can still log it. Controllers read this to record the change
|
|
21
|
+
# — the model itself records nothing (seeds must stay silent).
|
|
18
22
|
attr_reader :permission_keys_change
|
|
19
23
|
|
|
20
24
|
def grants?(permission_key)
|
|
@@ -25,31 +29,78 @@ module CurrentScope
|
|
|
25
29
|
@pending_permission_keys || role_permissions.pluck(:permission_key)
|
|
26
30
|
end
|
|
27
31
|
|
|
28
|
-
# Stages a replacement permission set
|
|
29
|
-
# catalog
|
|
30
|
-
#
|
|
32
|
+
# Stages a replacement permission set. STRICT: a key that isn't in the
|
|
33
|
+
# route-derived catalog makes the record invalid rather than disappearing.
|
|
34
|
+
# This is the mass-assignment writer (update!, strong params, the role
|
|
35
|
+
# grid), so it is deliberately the strict one — the scrub escape hatch must
|
|
36
|
+
# never be reachable from form params.
|
|
31
37
|
def permission_keys=(keys)
|
|
32
|
-
|
|
38
|
+
assign_permission_keys(keys, scrub: false)
|
|
39
|
+
end
|
|
40
|
+
|
|
41
|
+
# ponytail: one implementation, two intents — the setter is the strict
|
|
42
|
+
# front door, this is the same staging with the scrub opt-in named at the
|
|
43
|
+
# call site.
|
|
44
|
+
#
|
|
45
|
+
# `scrub: true` is the ONLY sanctioned silent drop, and it exists for one
|
|
46
|
+
# real case: a controller was removed, so a role still holds keys that no
|
|
47
|
+
# longer route. Cleaning those up is legitimate and shouldn't require the
|
|
48
|
+
# operator to name every dead key. Everything else — typos, unrouted
|
|
49
|
+
# programmatic grants, the never-routed break-glass permission — is a
|
|
50
|
+
# mistake, and a security-grant API must not swallow mistakes.
|
|
51
|
+
def assign_permission_keys(keys, scrub: false)
|
|
52
|
+
# Literal `true` only. Ruby truthiness would let the string "false" — or
|
|
53
|
+
# any params/config value that found its way here — silently disable the
|
|
54
|
+
# strict path, which is the exact hole this API exists to close. The
|
|
55
|
+
# escape hatch opens for a caller that means it, not for one that passed
|
|
56
|
+
# something along.
|
|
57
|
+
@scrub_permission_keys = scrub == true
|
|
58
|
+
# Blank entries are the grid's hidden-field padding, not typos (R2).
|
|
59
|
+
@pending_permission_keys = Array(keys).map(&:to_s).reject(&:blank?).uniq
|
|
33
60
|
end
|
|
34
61
|
|
|
35
62
|
def reload(...)
|
|
36
63
|
@pending_permission_keys = nil
|
|
64
|
+
@scrub_permission_keys = false
|
|
37
65
|
super
|
|
38
66
|
end
|
|
39
67
|
|
|
40
68
|
private
|
|
41
69
|
|
|
70
|
+
def permission_keys_in_catalog
|
|
71
|
+
return if @pending_permission_keys.nil? || @scrub_permission_keys
|
|
72
|
+
|
|
73
|
+
unknown = @pending_permission_keys.reject { |k| CurrentScope.catalog.include?(k) }
|
|
74
|
+
return if unknown.empty?
|
|
75
|
+
|
|
76
|
+
errors.add(
|
|
77
|
+
:permission_keys,
|
|
78
|
+
"not in the permission catalog: #{unknown.join(', ')} — check for typos, or use " \
|
|
79
|
+
"assign_permission_keys(..., scrub: true) to drop stale keys deliberately"
|
|
80
|
+
)
|
|
81
|
+
end
|
|
82
|
+
|
|
42
83
|
def persist_permission_keys
|
|
43
84
|
return if @pending_permission_keys.nil?
|
|
44
85
|
|
|
45
86
|
# Capture the prior keys BEFORE delete_all so the diff survives the swap.
|
|
46
87
|
previous = role_permissions.pluck(:permission_key)
|
|
47
|
-
|
|
48
|
-
|
|
88
|
+
# Defense in depth: on the strict path validation already proved every key
|
|
89
|
+
# is in the catalog, so this filter is a no-op. It is what the scrub path
|
|
90
|
+
# relies on, and it means no future code path that skips validations
|
|
91
|
+
# (insert_all, update_column, a bare `save(validate: false)`) can smuggle
|
|
92
|
+
# an unknown key into the table.
|
|
93
|
+
staged = @pending_permission_keys.select { |k| CurrentScope.catalog.include?(k) }
|
|
94
|
+
@permission_keys_change = {
|
|
95
|
+
added: staged - previous,
|
|
96
|
+
removed: previous - staged,
|
|
97
|
+
rejected: @pending_permission_keys - staged
|
|
98
|
+
}
|
|
49
99
|
|
|
50
100
|
role_permissions.delete_all
|
|
51
101
|
role_permissions.insert_all(staged.map { |k| { permission_key: k } }) if staged.any?
|
|
52
102
|
@pending_permission_keys = nil
|
|
103
|
+
@scrub_permission_keys = false
|
|
53
104
|
end
|
|
54
105
|
end
|
|
55
106
|
end
|
|
@@ -4,7 +4,7 @@
|
|
|
4
4
|
|
|
5
5
|
<%= form_with model: @role do |f| %>
|
|
6
6
|
<% if @role.errors.any? %>
|
|
7
|
-
<p class="cs-alert"><%= @role.errors.full_messages.to_sentence %></p>
|
|
7
|
+
<p class="cs-alert" role="alert"><%= @role.errors.full_messages.to_sentence %></p>
|
|
8
8
|
<% end %>
|
|
9
9
|
|
|
10
10
|
<p><%= f.label :name %> <%= f.text_field :name %></p>
|
|
@@ -20,10 +20,33 @@
|
|
|
20
20
|
</p>
|
|
21
21
|
|
|
22
22
|
<h2>Permission grid</h2>
|
|
23
|
+
<%# The injected break-glass action's bare name (nil when break-glass is
|
|
24
|
+
off) — the hint below and the per-row exemption check both read it. The
|
|
25
|
+
CATALOG owns the parse: its split("#", -1) + blank-raise catches a
|
|
26
|
+
malformed "reports#" that a loose split here would silently turn into a
|
|
27
|
+
believable wrong action name. (#79 review, qodo) %>
|
|
28
|
+
<% bypass_action = CurrentScope.config.allow_sod_bypass ? CurrentScope.catalog.bypass_action : nil %>
|
|
23
29
|
<p class="cs-hint">
|
|
24
30
|
One row per controller, one checkbox per action group — derived from the
|
|
25
31
|
app's routes, so new controllers appear here automatically. Columns are
|
|
26
32
|
fixed: a blank cell means the controller doesn't route that action.
|
|
33
|
+
<%# The break-glass column is the one thing here that is NOT an action you
|
|
34
|
+
can route, so the "derived from the app's routes" line above would
|
|
35
|
+
otherwise make it look like a mistake. %>
|
|
36
|
+
<% if CurrentScope.config.allow_sod_bypass %>
|
|
37
|
+
Break-glass is on, so controllers with a separation-of-duties action also
|
|
38
|
+
show a <code><%= bypass_action %></code>
|
|
39
|
+
column: granting it lets that role's holder override the four-eyes veto on
|
|
40
|
+
their own record, where the record allows it. Every use is audited.
|
|
41
|
+
<% end %>
|
|
42
|
+
</p>
|
|
43
|
+
<%# Unconditional on purpose (R9): the limit of the mark must be stated even
|
|
44
|
+
when nothing is marked — an unmarked row is a non-answer, not a proof. %>
|
|
45
|
+
<p class="cs-hint cs-ungated-hint">
|
|
46
|
+
Rows tagged "gate not run" are controllers provably never gated — the tag
|
|
47
|
+
appears only when the callback chain proves it. An unmarked row is not
|
|
48
|
+
proof the gate runs there (a conditional skip, for example, is unprovable
|
|
49
|
+
and renders unmarked).
|
|
27
50
|
</p>
|
|
28
51
|
|
|
29
52
|
<% grid = CurrentScope::PermissionGrid.new %>
|
|
@@ -42,15 +65,58 @@
|
|
|
42
65
|
</thead>
|
|
43
66
|
<tbody>
|
|
44
67
|
<% grid.controllers.each do |controller| %>
|
|
68
|
+
<%# A broken controller body's NameError deliberately propagates out
|
|
69
|
+
of the reflection (KTD-2) — but ONE broken controller must not
|
|
70
|
+
take down the whole role editor for every other row. The view
|
|
71
|
+
renders that row as explicitly uninspectable (a visible state,
|
|
72
|
+
never silence, never "gated"); the rake task keeps propagating,
|
|
73
|
+
because its output makes proof claims a partial walk can't. %>
|
|
74
|
+
<% ungated = begin
|
|
75
|
+
grid.ungated?(controller)
|
|
76
|
+
rescue NameError
|
|
77
|
+
:uninspectable
|
|
78
|
+
end %>
|
|
79
|
+
<% uninspectable = ungated == :uninspectable
|
|
80
|
+
ungated = ungated == true %>
|
|
81
|
+
<% badge_id = "cs_ungated_#{controller.parameterize(separator: '_')}" if ungated %>
|
|
45
82
|
<tr>
|
|
46
83
|
<th scope="row">
|
|
47
84
|
<%# Per-row master: enable/disable every action in this controller at once.
|
|
48
85
|
Progressive enhancement — with JS off, each cell checkbox still works. %>
|
|
49
86
|
<label class="cs-row-all" title="Enable all <%= controller %> permissions">
|
|
50
87
|
<input type="checkbox" data-cs-row-all
|
|
51
|
-
aria-label="Enable all <%= controller %> permissions"
|
|
88
|
+
aria-label="Enable all <%= controller %> permissions"<% if ungated %>
|
|
89
|
+
aria-describedby="<%= badge_id %>"<% end %>>
|
|
52
90
|
<span><%= controller %></span>
|
|
53
91
|
</label>
|
|
92
|
+
<% if uninspectable %>
|
|
93
|
+
<%# Not a badge and not silence: the gate state is UNKNOWN here,
|
|
94
|
+
and rendering nothing would read as "checked, and gated"
|
|
95
|
+
under the hint's own rules. %>
|
|
96
|
+
<span class="cs-uninspectable-note" id="cs_uninspectable_<%= controller.parameterize(separator: '_') %>">
|
|
97
|
+
could not inspect — this controller raised while loading, so its
|
|
98
|
+
gate state is unknown. Fix the load error, then reload.
|
|
99
|
+
</span>
|
|
100
|
+
<% end %>
|
|
101
|
+
<% if ungated %>
|
|
102
|
+
<%# Advisory only — marking is not disabling (KTD-8/R5): nothing
|
|
103
|
+
here is grayed or disabled. Same remediation vocabulary as
|
|
104
|
+
GatingTripwire's message. The claim is scoped to ROUTED
|
|
105
|
+
actions because the injected break-glass cell is live even
|
|
106
|
+
on a marked row (KTD-9) and carries its own note below. %>
|
|
107
|
+
<%# Each remedy names its cause: re-including Guard on a subclass
|
|
108
|
+
that inherited a skip is a NO-OP (the Concern short-circuits;
|
|
109
|
+
the skip already removed the callback the include would add),
|
|
110
|
+
so the inherited-skip host — the #62 shape this badge exists
|
|
111
|
+
for — must be pointed at re-assertion, not at include. %>
|
|
112
|
+
<span class="cs-ungated-badge" id="<%= badge_id %>">
|
|
113
|
+
<strong>gate not run</strong> — this controller does not run the
|
|
114
|
+
gate; ticking its routed actions grants nothing until it does.
|
|
115
|
+
If it inherited a skip, re-assert
|
|
116
|
+
<code>before_action :current_scope_check!</code>; if it never had
|
|
117
|
+
the gate, include CurrentScope::Guard.
|
|
118
|
+
</span>
|
|
119
|
+
<% end %>
|
|
54
120
|
</th>
|
|
55
121
|
<% grid.columns.each do |column| %>
|
|
56
122
|
<% cell = grid.cell(controller, column, granted) %>
|
|
@@ -60,13 +126,35 @@
|
|
|
60
126
|
readers; the "·" marker is decorative CSS. %>
|
|
61
127
|
<td class="cs-cell-blank"></td>
|
|
62
128
|
<% else %>
|
|
63
|
-
|
|
129
|
+
<%# column.actions covers both shapes — an ungrouped column is
|
|
130
|
+
[action], and a permission_grid_groups entry containing the
|
|
131
|
+
bypass action must not defeat the exemption. The two guards:
|
|
132
|
+
the INJECTED key must actually sit on this controller (a
|
|
133
|
+
grouped cell can be non-blank via its other actions), and
|
|
134
|
+
routed? excludes the collision — a REAL routed action merely
|
|
135
|
+
named like the bypass permission is ordinary and inert on a
|
|
136
|
+
marked row; only the catalog-injected key stays live. (#79) %>
|
|
137
|
+
<% bypass_exempt = ungated && bypass_action &&
|
|
138
|
+
column.actions.include?(bypass_action) &&
|
|
139
|
+
CurrentScope.catalog.grouped[controller]&.include?(bypass_action) &&
|
|
140
|
+
!CurrentScope.catalog.routed?("#{controller}##{bypass_action}") %>
|
|
141
|
+
<% note_id = "cs_bypass_exempt_#{controller.parameterize(separator: '_')}" if bypass_exempt %>
|
|
142
|
+
<td<% if bypass_exempt %> class="cs-cell-bypass"<% end %>>
|
|
64
143
|
<label>
|
|
65
144
|
<%= check_box_tag cell.name, cell.value, cell.checked,
|
|
66
145
|
id: "perm_#{cell.value.parameterize(separator: '_')}",
|
|
67
146
|
data: { cs_partial: cell.partial },
|
|
68
|
-
"aria-label": "#{controller} #{column.label}"
|
|
147
|
+
"aria-label": "#{controller} #{column.label}",
|
|
148
|
+
"aria-describedby": (bypass_exempt ? "#{badge_id} #{note_id}" : badge_id) %>
|
|
69
149
|
</label>
|
|
150
|
+
<% if bypass_exempt %>
|
|
151
|
+
<%# KTD-9: break-glass is decided by GATED controllers acting
|
|
152
|
+
on the record type, so this grant is live despite the
|
|
153
|
+
row's badge — the one cell exempt from its claim. The
|
|
154
|
+
class is also the CSS hook that keeps the real granted
|
|
155
|
+
wash here while sibling cells are muted. %>
|
|
156
|
+
<span class="cs-bypass-exempt" id="<%= note_id %>">exempt — stays live</span>
|
|
157
|
+
<% end %>
|
|
70
158
|
<%# Partial group: preserve the exact granted actions so a no-op
|
|
71
159
|
save can't promote the group to a full grant. Interacting with
|
|
72
160
|
the checkbox disables these (JS) so the group control takes over. %>
|
|
@@ -11,7 +11,7 @@
|
|
|
11
11
|
|
|
12
12
|
<h2>Add org-wide members</h2>
|
|
13
13
|
<% if @candidates.any? %>
|
|
14
|
-
<%= form_with url:
|
|
14
|
+
<%= form_with url: role_assignments_path, method: :post do %>
|
|
15
15
|
<%= hidden_field_tag :role_id, @role.id %>
|
|
16
16
|
<p>
|
|
17
17
|
<%= label_tag "subject_gids", "Subjects" %>
|
|
@@ -46,7 +46,7 @@
|
|
|
46
46
|
<td>
|
|
47
47
|
<% if subject_gid %>
|
|
48
48
|
<%# Removing = clearing this subject's org-wide role (blank role_id). %>
|
|
49
|
-
<%= form_with url:
|
|
49
|
+
<%= form_with url: role_assignments_path, method: :post,
|
|
50
50
|
data: { cs_confirm: "Remove #{who} from #{@role.name}?" } do %>
|
|
51
51
|
<%= hidden_field_tag :subject_gid, subject_gid %>
|
|
52
52
|
<%= hidden_field_tag :role_id, "" %>
|
|
@@ -54,7 +54,7 @@
|
|
|
54
54
|
<% end %>
|
|
55
55
|
<% else %>
|
|
56
56
|
<%# Orphaned assignment (subject deleted): remove it by id. %>
|
|
57
|
-
<%= button_to "Remove",
|
|
57
|
+
<%= button_to "Remove", role_assignment_path(assignment), method: :delete,
|
|
58
58
|
class: "cs-btn", form: { data: { cs_confirm: "Remove this orphaned org-wide assignment?" } } %>
|
|
59
59
|
<span class="cs-subtle">subject unavailable</span>
|
|
60
60
|
<% end %>
|
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
|
|
3
3
|
<%= form_with model: @role do |f| %>
|
|
4
4
|
<% if @role.errors.any? %>
|
|
5
|
-
<p class="cs-alert"><%= @role.errors.full_messages.to_sentence %></p>
|
|
5
|
+
<p class="cs-alert" role="alert"><%= @role.errors.full_messages.to_sentence %></p>
|
|
6
6
|
<% end %>
|
|
7
7
|
|
|
8
8
|
<p><%= f.label :name %> <%= f.text_field :name %></p>
|
|
@@ -102,9 +102,18 @@
|
|
|
102
102
|
include_blank: "— choose a record —", form: "cs-cascade",
|
|
103
103
|
data: { current_scope_autosubmit: true } %>
|
|
104
104
|
<% if @searchable && params[:q].present? %>
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
105
|
+
<%# Keyed off the raw SEARCH results, not record_options — a
|
|
106
|
+
deep-linked @resource is prepended to the options even when the
|
|
107
|
+
query matched nothing, and must not fake a "matches shown". %>
|
|
108
|
+
<% if @records.blank? %>
|
|
109
|
+
<span class="cs-hint cs-picker-empty">
|
|
110
|
+
No records match “<%= params[:q] %>” — try a different search.
|
|
111
|
+
</span>
|
|
112
|
+
<% else %>
|
|
113
|
+
<span class="cs-hint">
|
|
114
|
+
Showing up to <%= CurrentScope::ScopedRoleAssignmentsController::DISPLAY_LIMIT %> matches — refine the search to narrow.
|
|
115
|
+
</span>
|
|
116
|
+
<% end %>
|
|
108
117
|
<% end %>
|
|
109
118
|
</p>
|
|
110
119
|
<% end %>
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
<%# The engine's front-door 403. Deliberately NOT rendered in the console
|
|
2
|
+
layout: that layout is a sidebar of links to Roles / Subjects / Events, and
|
|
3
|
+
showing the console chrome to someone who cannot open any of it reads as
|
|
4
|
+
"you're in" while every link refuses. Standalone page, engine stylesheet, no
|
|
5
|
+
navigation offered that isn't there. %>
|
|
6
|
+
<!DOCTYPE html>
|
|
7
|
+
<html lang="en"<%= " data-cs-theme=\"#{cookies[:current_scope_theme]}\"".html_safe if %w[light dark].include?(cookies[:current_scope_theme]) %>>
|
|
8
|
+
<head>
|
|
9
|
+
<meta charset="utf-8">
|
|
10
|
+
<meta name="viewport" content="width=device-width, initial-scale=1">
|
|
11
|
+
<title>Access denied — CurrentScope</title>
|
|
12
|
+
<%= csp_meta_tag %>
|
|
13
|
+
<%= stylesheet_link_tag "current_scope/application", media: "all" %>
|
|
14
|
+
</head>
|
|
15
|
+
<body>
|
|
16
|
+
<main class="cs-card cs-denied">
|
|
17
|
+
<h1>This area needs a full-access role</h1>
|
|
18
|
+
<p>
|
|
19
|
+
The authorization console is where roles and permissions are granted, so it
|
|
20
|
+
can't be gated by a permission you could be granted — it's open only to
|
|
21
|
+
subjects holding a role marked <strong>full access</strong>.
|
|
22
|
+
</p>
|
|
23
|
+
<p class="cs-hint">
|
|
24
|
+
If you should have it, ask someone who already does to grant you a
|
|
25
|
+
full-access role. If nobody has one yet, it's granted from the console or a
|
|
26
|
+
rake task — see the CurrentScope README's bootstrap section.
|
|
27
|
+
</p>
|
|
28
|
+
</main>
|
|
29
|
+
</body>
|
|
30
|
+
</html>
|