current_scope 0.2.0 → 0.3.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Potentially problematic release.
This version of current_scope might be problematic. Click here for more details.
- 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 +101 -0
- data/app/controllers/current_scope/application_controller.rb +39 -1
- data/app/controllers/current_scope/role_assignments_controller.rb +110 -25
- data/app/controllers/current_scope/roles_controller.rb +130 -32
- 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/index.html.erb +16 -2
- 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 +19 -10
- data/app/views/current_scope/shared/access_denied.html.erb +30 -0
- data/app/views/current_scope/subjects/index.html.erb +21 -7
- data/app/views/layouts/current_scope/application.html.erb +4 -1
- data/config/routes.rb +3 -4
- data/lib/current_scope/configuration.rb +411 -18
- 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 +410 -10
- 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 +45 -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
data/lib/current_scope.rb
CHANGED
|
@@ -9,12 +9,30 @@ require "current_scope/scopeable"
|
|
|
9
9
|
require "current_scope/mutation_guard"
|
|
10
10
|
require "current_scope/guard"
|
|
11
11
|
require "current_scope/gating_tripwire"
|
|
12
|
+
require "current_scope/gating_reflection"
|
|
12
13
|
require "current_scope/engine"
|
|
13
14
|
|
|
14
15
|
module CurrentScope
|
|
15
16
|
# Raised when the resolver denies an action gated by Guard (or when the
|
|
16
17
|
# management UI is accessed without a full-access role). Carries an optional
|
|
17
|
-
# machine-readable reason
|
|
18
|
+
# machine-readable reason, surfaced on the response as X-Current-Scope-Reason
|
|
19
|
+
# by current_scope_denied:
|
|
20
|
+
#
|
|
21
|
+
# :sod_veto — the record's initiator can't perform an SoD action on it
|
|
22
|
+
# :no_grant — nothing granted the permission (the default deny)
|
|
23
|
+
# :model_undeclared — a record-less deny that a scoped grant would have
|
|
24
|
+
# opened, had the controller declared current_scope_model
|
|
25
|
+
# to bind it to a type (#50). Fail-closed, with the fix named.
|
|
26
|
+
# :model_invalid — its sibling: current_scope_model WAS declared but
|
|
27
|
+
# returned something the shape guard refuses (a String,
|
|
28
|
+
# an instance, an abstract class — not a concrete AR
|
|
29
|
+
# class). Same cell, different fix, so a different label.
|
|
30
|
+
# :impersonation_gate — a mutation while impersonating, which is read-only
|
|
31
|
+
# :not_full_access — the engine's management UI, which only full_access enters
|
|
32
|
+
#
|
|
33
|
+
# Every denial in the gem raises this and lands in current_scope_denied, so a
|
|
34
|
+
# denial cannot exist that forgets its reason. (:sod_bypassed is the one
|
|
35
|
+
# audited ALLOW, so it is set by the Guard rather than raised here.)
|
|
18
36
|
class AccessDenied < StandardError
|
|
19
37
|
attr_reader :reason
|
|
20
38
|
|
|
@@ -50,6 +68,14 @@ module CurrentScope
|
|
|
50
68
|
@catalog = nil
|
|
51
69
|
end
|
|
52
70
|
|
|
71
|
+
# The cross-controller nudge warns once per site (see below). That latch is
|
|
72
|
+
# per-process, so it must be clearable: a leaked one silently disarms the
|
|
73
|
+
# warning for every later test and makes the suite order-dependent. Also
|
|
74
|
+
# cleared on engine to_prepare, since a reload can change what's routed.
|
|
75
|
+
def reset_cross_controller_warnings!
|
|
76
|
+
@cross_controller_warned = nil
|
|
77
|
+
end
|
|
78
|
+
|
|
53
79
|
# Models that opted into the scoped-role picker via CurrentScope::Scopeable.
|
|
54
80
|
# Stored as class-name strings and resolved lazily so dev-mode reloading
|
|
55
81
|
# never pins a stale constant. Rebuilt from scratch on every engine
|
|
@@ -74,12 +100,13 @@ module CurrentScope
|
|
|
74
100
|
# `action` is either a full permission key ("admin/reports#approve") or a
|
|
75
101
|
# bare action name resolved against `record`'s route key, falling back to
|
|
76
102
|
# `controller_path`.
|
|
77
|
-
def allowed?(action, subject:, record: nil, controller_path: nil, actor: nil)
|
|
103
|
+
def allowed?(action, subject:, record: nil, controller_path: nil, actor: nil, model: nil)
|
|
78
104
|
resolver.allow?(
|
|
79
105
|
subject: subject,
|
|
80
106
|
permission: permission_key(action, record: record, controller_path: controller_path),
|
|
81
107
|
record: record,
|
|
82
|
-
actor: actor
|
|
108
|
+
actor: actor,
|
|
109
|
+
model: model
|
|
83
110
|
)
|
|
84
111
|
end
|
|
85
112
|
|
|
@@ -91,6 +118,24 @@ module CurrentScope
|
|
|
91
118
|
resolver.scope_for(subject: subject, model: model, permission: permission)
|
|
92
119
|
end
|
|
93
120
|
|
|
121
|
+
# THE human-label fallback chain, shared by the UI helpers
|
|
122
|
+
# (ApplicationHelper#current_scope_label) and the audit ledger
|
|
123
|
+
# (Event.label_for) — one definition, so a record can never render as
|
|
124
|
+
# "Apollo" on screen while being frozen into the ledger as "Project #7".
|
|
125
|
+
# Chain: the record's own current_scope_label (Scopeable provides one) →
|
|
126
|
+
# human identifiers (name/email/title) → "Model #id" → to_s. Returns nil
|
|
127
|
+
# for nil; callers choose their own nil presentation ("(none)" in views).
|
|
128
|
+
def label_for(record)
|
|
129
|
+
return if record.nil?
|
|
130
|
+
return record.current_scope_label if record.respond_to?(:current_scope_label)
|
|
131
|
+
|
|
132
|
+
name = record.try(:name).presence || record.try(:email).presence || record.try(:title).presence
|
|
133
|
+
return name if name
|
|
134
|
+
return "#{record.model_name.human} ##{record.id}" if record.respond_to?(:model_name)
|
|
135
|
+
|
|
136
|
+
record.to_s
|
|
137
|
+
end
|
|
138
|
+
|
|
94
139
|
def permission_key(action, record: nil, controller_path: nil)
|
|
95
140
|
action = action.to_s
|
|
96
141
|
return action if action.include?("#")
|
|
@@ -102,6 +147,7 @@ module CurrentScope
|
|
|
102
147
|
# Guard enforces, so prefer it: the view must agree with the gate.
|
|
103
148
|
return "#{controller_path}##{action}" if controller_path&.split("/")&.last == route_key
|
|
104
149
|
|
|
150
|
+
warn_on_cross_controller_derivation(action, route_key, controller_path)
|
|
105
151
|
return "#{route_key}##{action}"
|
|
106
152
|
end
|
|
107
153
|
return "#{controller_path}##{action}" if controller_path
|
|
@@ -111,6 +157,7 @@ module CurrentScope
|
|
|
111
157
|
"a full \"controller#action\" string, or call from a controller/view"
|
|
112
158
|
end
|
|
113
159
|
|
|
160
|
+
|
|
114
161
|
# Impersonation boundary events. The impersonated identity is an EXPLICIT
|
|
115
162
|
# argument (not read from the ambient pair): at act-as START the ambient
|
|
116
163
|
# actor still equals the effective user — Current re-resolves next request —
|
|
@@ -139,14 +186,75 @@ module CurrentScope
|
|
|
139
186
|
# same subject's org role to `role` rather than creating a duplicate (which
|
|
140
187
|
# the one-role-per-subject uniqueness would reject anyway). Backs the
|
|
141
188
|
# `current_scope:grant` rake task, so a fresh install doesn't need a console.
|
|
189
|
+
#
|
|
190
|
+
# Seeds the default Owner/Member roles ONLY on the default path — the name
|
|
191
|
+
# promises "assign a role", so a caller granting an explicit role must not
|
|
192
|
+
# get a full-access Owner row created in their roles table as a side effect.
|
|
142
193
|
def grant!(subject, role: nil)
|
|
143
|
-
|
|
144
|
-
|
|
194
|
+
role ||= begin
|
|
195
|
+
seed_defaults!
|
|
196
|
+
Role.find_by!(name: "Owner")
|
|
197
|
+
end
|
|
145
198
|
RoleAssignment.find_or_initialize_by(subject: subject).tap { |a| a.update!(role: role) }
|
|
146
199
|
end
|
|
147
200
|
|
|
148
201
|
private
|
|
149
202
|
|
|
203
|
+
# The documented namespaced/custom-named controller foot-gun (#41): the short
|
|
204
|
+
# form derived a DIFFERENT key than the gate on this controller enforces, so a
|
|
205
|
+
# view can show a link that 403s (or hide one that works). Silent, and the
|
|
206
|
+
# symptom appears nowhere near the cause.
|
|
207
|
+
#
|
|
208
|
+
# THIS SIGNAL IS AMBIGUOUS AND CANNOT BE MADE PRECISE. Two callers produce
|
|
209
|
+
# byte-identical inputs here:
|
|
210
|
+
#
|
|
211
|
+
# DashboardController renders Reports; allowed_to?(:show, report) is meant
|
|
212
|
+
# to mirror THIS controller's gate (dashboard#show) -> foot-gun.
|
|
213
|
+
# DocumentsController lists documents with links to reports;
|
|
214
|
+
# allowed_to?(:show, report) genuinely means reports#show -> correct.
|
|
215
|
+
#
|
|
216
|
+
# Both have a controller path that doesn't end in the record's route_key, and
|
|
217
|
+
# both route "{controller_path}##{action}". Nothing at the call site
|
|
218
|
+
# distinguishes intent. An earlier draft treated the catalog hit as proof of
|
|
219
|
+
# the foot-gun and warned "they disagree" — which is a false positive on every
|
|
220
|
+
# row of the second case. (#59/#61 review, cubic)
|
|
221
|
+
#
|
|
222
|
+
# So: warn ONCE per (controller_path, action, route_key), and say plainly that
|
|
223
|
+
# either reading may be right. One line per distinct site is a hint; one line
|
|
224
|
+
# per row is noise people learn to filter — and a diagnostic that cries wolf is
|
|
225
|
+
# worse than none, which is the whole thesis of this PR.
|
|
226
|
+
#
|
|
227
|
+
# ponytail: derivation is a hot path (every view helper call), so the flag is
|
|
228
|
+
# checked FIRST — off costs one boolean and never touches the catalog.
|
|
229
|
+
def warn_on_cross_controller_derivation(action, route_key, controller_path)
|
|
230
|
+
return unless config.warn_on_cross_controller_derivation
|
|
231
|
+
return if controller_path.nil? || controller_path.empty?
|
|
232
|
+
# A "log-only" diagnostic that raises isn't log-only. catalog reads
|
|
233
|
+
# Rails.application.routes, so a host that forces the flag on outside a
|
|
234
|
+
# booted Rails must get silence, not a NameError out of key derivation.
|
|
235
|
+
# (#61 review, qodo)
|
|
236
|
+
return unless defined?(Rails) && Rails.respond_to?(:application) && Rails.application
|
|
237
|
+
|
|
238
|
+
gate_key = "#{controller_path}##{action}"
|
|
239
|
+
return unless catalog.include?(gate_key)
|
|
240
|
+
return unless cross_controller_warning_unseen?(gate_key, route_key)
|
|
241
|
+
|
|
242
|
+
Rails.logger&.warn(
|
|
243
|
+
"[CurrentScope] allowed_to?(#{action.to_sym.inspect}, <#{route_key.singularize.camelize}>) on " \
|
|
244
|
+
"#{controller_path} derived \"#{route_key}##{action}\", but the gate here enforces " \
|
|
245
|
+
"\"#{gate_key}\". If you meant this controller's own gate, they disagree — pass the explicit " \
|
|
246
|
+
"key: allowed_to?(\"#{gate_key}\"). If you're asking about a different resource than this " \
|
|
247
|
+
"controller handles, the derived key is correct and this is expected. Warned once per site."
|
|
248
|
+
)
|
|
249
|
+
end
|
|
250
|
+
|
|
251
|
+
# ponytail: a plain Set, not a Mutex — worst case under a race is one extra
|
|
252
|
+
# line, and a flood is the thing being prevented. Dev/test only by default.
|
|
253
|
+
def cross_controller_warning_unseen?(gate_key, route_key)
|
|
254
|
+
@cross_controller_warned ||= Set.new
|
|
255
|
+
@cross_controller_warned.add?("#{gate_key}|#{route_key}") ? true : false
|
|
256
|
+
end
|
|
257
|
+
|
|
150
258
|
# A2: the boundary events are the one place a host declares it is actually
|
|
151
259
|
# impersonating. If actor_method is unset there, the entire act-as security
|
|
152
260
|
# model is silently inert — so fail LOUD instead of recording an
|
|
@@ -27,7 +27,71 @@ module CurrentScope
|
|
|
27
27
|
4. Manage roles at /current_scope (full-access subjects only).
|
|
28
28
|
|
|
29
29
|
NEXT
|
|
30
|
+
|
|
31
|
+
say_retrofit_warning if existing_app?
|
|
30
32
|
end
|
|
33
|
+
|
|
34
|
+
# An ABSOLUTE URL, not a repo-relative path. This prints in the HOST's
|
|
35
|
+
# terminal, in the HOST's app directory: "docs/guides/..." would resolve
|
|
36
|
+
# against their app, where it does not exist — and the gemspec ships only
|
|
37
|
+
# {app,config,db,lib} + README, so it is not in the installed gem either.
|
|
38
|
+
# Shipping docs/ wouldn't fix it (nobody reads docs out of a gem's install
|
|
39
|
+
# dir); a URL is the only form that resolves from where the reader is
|
|
40
|
+
# standing, and terminals make it clickable. (#64 review, qodo)
|
|
41
|
+
#
|
|
42
|
+
# Points at blob/main deliberately: the guide does not exist at the last
|
|
43
|
+
# release tag (it landed after v0.2.0), so a version-pinned URL would 404
|
|
44
|
+
# today. Revisit pinning to "blob/v#{VERSION}" at the next release, when a
|
|
45
|
+
# tag containing the guide exists. (#71 review, qodo)
|
|
46
|
+
GUIDE_PATH = "docs/guides/adopting-in-an-existing-app.md".freeze
|
|
47
|
+
GUIDE_URL = "https://github.com/davidteren/current_scope/blob/main/#{GUIDE_PATH}".freeze
|
|
48
|
+
|
|
49
|
+
private
|
|
50
|
+
|
|
51
|
+
# Adding a fail-closed gate to an app that already has controllers means
|
|
52
|
+
# every one of them starts denying: nothing is granted yet. That is the
|
|
53
|
+
# engine working, but it reads as "the gem broke my app" — and it lands
|
|
54
|
+
# AFTER step 2 above, when the suite is already red and the person is
|
|
55
|
+
# already reaching for git revert. Say it before that happens.
|
|
56
|
+
def say_retrofit_warning
|
|
57
|
+
say <<~RETROFIT, :yellow
|
|
58
|
+
Heads up — this app already has controllers.
|
|
59
|
+
|
|
60
|
+
Step 2 mounts a FAIL-CLOSED gate: anything not granted is denied. No
|
|
61
|
+
grants exist yet, so your controller specs will go red and your users
|
|
62
|
+
will get 403s. Nothing is misconfigured when that happens; it is the
|
|
63
|
+
gate doing its job on an app that hasn't been granted anything.
|
|
64
|
+
|
|
65
|
+
To retrofit incrementally instead, set this before step 2:
|
|
66
|
+
|
|
67
|
+
config.enforcement = :report # config/initializers/current_scope.rb
|
|
68
|
+
|
|
69
|
+
The gate then logs what it WOULD deny and lets it through. Run your
|
|
70
|
+
suite, then read the gaps back as a starter role grid:
|
|
71
|
+
|
|
72
|
+
bin/rails current_scope:report
|
|
73
|
+
|
|
74
|
+
Seed the roles it names, watch it empty out, then flip to :enforce.
|
|
75
|
+
One line back at any point.
|
|
76
|
+
|
|
77
|
+
The full retrofit guide — callback ordering vs. your authentication,
|
|
78
|
+
the Devise recipe, the skip_before_action fail-open trap, and a
|
|
79
|
+
rollout ladder:
|
|
80
|
+
|
|
81
|
+
#{GUIDE_URL}
|
|
82
|
+
|
|
83
|
+
RETROFIT
|
|
84
|
+
end
|
|
85
|
+
|
|
86
|
+
# ponytail: "does this app have controllers of its own" — the closed set
|
|
87
|
+
# is app/controllers/*.rb minus the one Rails generates for every app.
|
|
88
|
+
# A fresh `rails new` has only application_controller.rb, so it gets the
|
|
89
|
+
# clean install message; anything more means a real app is being
|
|
90
|
+
# retrofitted. Wrong guess costs a paragraph of advice, not correctness.
|
|
91
|
+
def existing_app?
|
|
92
|
+
controllers = Dir.glob(File.join(destination_root, "app/controllers/**/*_controller.rb"))
|
|
93
|
+
controllers.reject { |f| File.basename(f) == "application_controller.rb" }.any?
|
|
94
|
+
end
|
|
31
95
|
end
|
|
32
96
|
end
|
|
33
97
|
end
|
|
@@ -1,4 +1,39 @@
|
|
|
1
1
|
CurrentScope.configure do |config|
|
|
2
|
+
# --- Retrofitting an existing app? Start here ---------------------------
|
|
3
|
+
#
|
|
4
|
+
# This engine is fail-closed: once the gate is mounted, anything not granted
|
|
5
|
+
# is denied. In an app that already has users and traffic, that means your
|
|
6
|
+
# controller suite goes RED and your users get 403s the moment you mount it —
|
|
7
|
+
# not because anything is misconfigured, but because no grants exist yet.
|
|
8
|
+
#
|
|
9
|
+
# So don't cut over blind. Run in report mode first:
|
|
10
|
+
#
|
|
11
|
+
# config.enforcement = :report
|
|
12
|
+
#
|
|
13
|
+
# The gate then LOGS what it would have denied and lets the request through.
|
|
14
|
+
# Exercise the app (or run your suite), then read the gaps back out — each row
|
|
15
|
+
# names a subject and the permission they were missing:
|
|
16
|
+
#
|
|
17
|
+
# bin/rails current_scope:report
|
|
18
|
+
#
|
|
19
|
+
# That list IS your grant-seeding work. Seed the roles it names, watch the
|
|
20
|
+
# would_deny rows stop appearing, then flip to :enforce. Reversible at every
|
|
21
|
+
# step — it's one line back.
|
|
22
|
+
#
|
|
23
|
+
# Report mode is an ADOPTION ramp, not a way to run in production. It relaxes
|
|
24
|
+
# exactly one thing: "nobody has granted this yet". A separation-of-duties
|
|
25
|
+
# veto still refuses, and the management console is never opened by it.
|
|
26
|
+
#
|
|
27
|
+
# config.enforcement = :enforce # :enforce (default) | :report
|
|
28
|
+
# ------------------------------------------------------------------------
|
|
29
|
+
|
|
30
|
+
# What the opt-in GatingTripwire mixin does when it catches an action that
|
|
31
|
+
# completed WITHOUT running the gate. :raise fails loudly (CI goes red);
|
|
32
|
+
# :warn logs once per controller#action and lets the response through, so a
|
|
33
|
+
# real app can inventory its ungated surface without 500ing. There is no
|
|
34
|
+
# :off — not including CurrentScope::GatingTripwire is off.
|
|
35
|
+
# config.gating_tripwire = Rails.env.local? ? :raise : :warn
|
|
36
|
+
|
|
2
37
|
# Controller method that returns the authenticated subject.
|
|
3
38
|
# config.user_method = :current_user
|
|
4
39
|
|
|
@@ -17,6 +52,26 @@ CurrentScope.configure do |config|
|
|
|
17
52
|
# config.permission_grid_groups = { "read" => %w[index show], "create" => %w[new create],
|
|
18
53
|
# "update" => %w[edit update], "destroy" => %w[destroy] }
|
|
19
54
|
|
|
55
|
+
# Action names whose record-less gate derives its answer from the scoped
|
|
56
|
+
# list: for these, "may they open this list?" is answered by the same
|
|
57
|
+
# id-narrowed query scope_for renders from, so a scoped full_access role
|
|
58
|
+
# ("Owner of Report #7") opens exactly the collections that would show it
|
|
59
|
+
# records — gate and list agree by construction. Matched on the action
|
|
60
|
+
# segment of the key, like sod_actions. Default ["index"]; set [] to
|
|
61
|
+
# restore the pre-#65 behavior (explicit ticks still open type-bound
|
|
62
|
+
# record-less gates; scoped full_access opens none). A full key
|
|
63
|
+
# ("reports#index") raises — the list is action-segment matched, app-wide —
|
|
64
|
+
# and a canonical mutating name (create/update/destroy) warns at assignment.
|
|
65
|
+
#
|
|
66
|
+
# LIST-NARROWING READS ONLY: the safety of honoring full_access here comes
|
|
67
|
+
# from the answer being derived from record ids, so it is only sound for
|
|
68
|
+
# actions with a list side. Never name a mutating action ("create",
|
|
69
|
+
# "destroy_all") — that would hand a scoped full_access holder the action
|
|
70
|
+
# on every record of the type off a grant on one record. Custom read
|
|
71
|
+
# actions (export, search) are the intended additions. The declared
|
|
72
|
+
# current_scope_model is trusted like current_scope_record: review both.
|
|
73
|
+
# config.collection_read_actions = %w[index]
|
|
74
|
+
|
|
20
75
|
# --- Impersonation (act-as) ---------------------------------------------
|
|
21
76
|
# These three knobs layer, in this order:
|
|
22
77
|
#
|
|
@@ -61,11 +116,45 @@ CurrentScope.configure do |config|
|
|
|
61
116
|
# audit-mandatory app never commits an unaudited change.
|
|
62
117
|
# config.audit = true
|
|
63
118
|
|
|
64
|
-
# Dev
|
|
65
|
-
#
|
|
66
|
-
#
|
|
67
|
-
# changes
|
|
68
|
-
#
|
|
119
|
+
# --- Dev diagnostics -----------------------------------------------------
|
|
120
|
+
# Four failure modes this engine has that are SILENT, and silent in the bad
|
|
121
|
+
# direction — the thing going wrong looks exactly like the thing going right.
|
|
122
|
+
# All four are LOG-ONLY (no decision, exception, header, or audit row changes)
|
|
123
|
+
# and all four default ON in development and test, OFF in production.
|
|
124
|
+
#
|
|
125
|
+
# They are listed here rather than left to the docs on purpose: a named flag in
|
|
126
|
+
# your initializer is how you learn the failure mode exists at all.
|
|
127
|
+
|
|
128
|
+
# The SoD veto was SKIPPED because the gate had no record — an SoD member
|
|
129
|
+
# action whose current_scope_record returned nil (or was never declared). The
|
|
130
|
+
# request was ALLOWED, and a skipped veto looks identical to a veto that
|
|
131
|
+
# passed. The gem's #1 foot-gun.
|
|
132
|
+
# config.warn_on_nil_sod_record = Rails.env.local?
|
|
133
|
+
|
|
134
|
+
# Denied "no_grant", but the subject holds a scoped grant that WOULD have
|
|
135
|
+
# applied — and the controller declares no current_scope_record, so the gate
|
|
136
|
+
# had no record to apply it to. A member action that forgot its hook: it fails
|
|
137
|
+
# closed (correctly), but the 403 is indistinguishable from "never granted", so
|
|
138
|
+
# you go and stare at the grants, which are fine.
|
|
139
|
+
# config.warn_on_inert_scoped_grant = Rails.env.local?
|
|
140
|
+
|
|
141
|
+
# Short-form allowed_to?(:show, record) derived a DIFFERENT key than the gate
|
|
142
|
+
# on the current controller enforces (the namespaced/custom-named controller
|
|
143
|
+
# foot-gun): a link that 403s, or a hidden one that would have worked. A hint,
|
|
144
|
+
# not an accusation — asking about another resource derives a different key too,
|
|
145
|
+
# and that's correct — so it warns once per site and names both readings.
|
|
146
|
+
# config.warn_on_cross_controller_derivation = Rails.env.local?
|
|
147
|
+
|
|
148
|
+
# Denied "model_undeclared": a collection action declared with
|
|
149
|
+
# `current_scope_record = nil` on a controller that names no
|
|
150
|
+
# current_scope_model, while the subject holds a scoped grant ticking the
|
|
151
|
+
# key. The gate had no type to bind that grant to, so it failed closed —
|
|
152
|
+
# correctly, but the fix is one line: `def current_scope_model = TheType`.
|
|
153
|
+
# The same flag covers "model_invalid" — a declared hook returning
|
|
154
|
+
# something other than a concrete AR class ("Report" for Report); that
|
|
155
|
+
# nudge names the value the hook returned.
|
|
156
|
+
# config.warn_on_undeclared_collection_model = Rails.env.local?
|
|
157
|
+
# ------------------------------------------------------------------------
|
|
69
158
|
|
|
70
159
|
# Controller paths (regexps) excluded from the permission grid. Excluded
|
|
71
160
|
# controllers can't be granted, so they must also skip the gate with
|
|
@@ -12,4 +12,157 @@ namespace :current_scope do
|
|
|
12
12
|
CurrentScope.grant!(subject)
|
|
13
13
|
puts "Granted the full-access Owner role to #{klass}##{subject.id}."
|
|
14
14
|
end
|
|
15
|
+
|
|
16
|
+
desc "Summarize would-be denials recorded in report mode into a starter role grid. " \
|
|
17
|
+
"Usage: bin/rails current_scope:report"
|
|
18
|
+
task report: :environment do
|
|
19
|
+
# The subject's current org-wide role, when resolvable — the grid reads
|
|
20
|
+
# differently if someone already holds a role that just doesn't tick these
|
|
21
|
+
# keys. Best-effort: a rollout aid must not abort everyone else's summary
|
|
22
|
+
# because one subject's record was deleted or its class no longer loads.
|
|
23
|
+
# A lambda, not a def — a rake file's `def` lands on Object.
|
|
24
|
+
org_role_suffix = lambda do |subject_gid|
|
|
25
|
+
subject = GlobalID::Locator.locate(subject_gid)
|
|
26
|
+
role = subject && CurrentScope::RoleAssignment.find_by(subject: subject)&.role
|
|
27
|
+
role ? " — currently #{role.name}" : ""
|
|
28
|
+
rescue StandardError
|
|
29
|
+
""
|
|
30
|
+
end
|
|
31
|
+
|
|
32
|
+
begin
|
|
33
|
+
rows = CurrentScope::Event.where(event: "access.would_deny")
|
|
34
|
+
.pluck(:subject, :target_label, :details)
|
|
35
|
+
rescue ActiveRecord::StatementInvalid => e
|
|
36
|
+
# Report mode without the migration records nothing (the ledger degrades and
|
|
37
|
+
# warns once). Reaching for this summary is exactly how a host discovers
|
|
38
|
+
# that, so it must name the fix rather than raise a stack trace at them.
|
|
39
|
+
raise unless e.message.match?(/current_scope_events/i)
|
|
40
|
+
|
|
41
|
+
abort "The current_scope_events table doesn't exist, so nothing was recorded.\n" \
|
|
42
|
+
"Run: bin/rails current_scope:install:migrations && bin/rails db:migrate"
|
|
43
|
+
end
|
|
44
|
+
|
|
45
|
+
if rows.empty?
|
|
46
|
+
# "No output" is indistinguishable from "the task is broken", and the two
|
|
47
|
+
# likeliest causes are both SILENT: report mode never on, or audit off.
|
|
48
|
+
# Name them — this is the first thing a host runs, and an unexplained blank
|
|
49
|
+
# is how they conclude the feature doesn't work.
|
|
50
|
+
puts "No would-be denials recorded."
|
|
51
|
+
puts
|
|
52
|
+
puts " config.enforcement is #{CurrentScope.config.enforcement.inspect} " \
|
|
53
|
+
"(needs :report to record any)"
|
|
54
|
+
puts " config.audit is #{CurrentScope.config.audit.inspect} " \
|
|
55
|
+
"(needs true or :strict — the ledger is where these rows live)"
|
|
56
|
+
puts
|
|
57
|
+
puts "With both on, exercise the app or run your suite, then re-run this."
|
|
58
|
+
next
|
|
59
|
+
end
|
|
60
|
+
|
|
61
|
+
# ponytail: group in Ruby, not SQL. `details` is a JSON column and querying
|
|
62
|
+
# into it is adapter-specific; this is a rollout aid run by hand over a
|
|
63
|
+
# transitional table, so portability beats a smarter query.
|
|
64
|
+
grouped = rows.group_by { |subject, _label, _details| subject }
|
|
65
|
+
|
|
66
|
+
puts "Would-be denials — grant these to stop them (most-denied first):"
|
|
67
|
+
puts
|
|
68
|
+
|
|
69
|
+
grouped.each do |subject_gid, subject_rows|
|
|
70
|
+
label = subject_rows.first[1].presence || subject_gid
|
|
71
|
+
puts " #{label}#{org_role_suffix.call(subject_gid)}"
|
|
72
|
+
|
|
73
|
+
subject_rows
|
|
74
|
+
.group_by { |_s, _l, details| details.is_a?(Hash) ? details["permission"] : nil }
|
|
75
|
+
.transform_values(&:count)
|
|
76
|
+
.sort_by { |permission, count| [ -count, permission.to_s ] }
|
|
77
|
+
.each { |permission, count| puts " #{count.to_s.rjust(5)}x #{permission || '(unknown)'}" }
|
|
78
|
+
|
|
79
|
+
puts
|
|
80
|
+
end
|
|
81
|
+
|
|
82
|
+
puts "Total: #{rows.count} would-be denials across #{grouped.size} subject(s)."
|
|
83
|
+
end
|
|
84
|
+
|
|
85
|
+
desc "Inventory the routed controllers that provably never run the gate — the static " \
|
|
86
|
+
"half of the ungated-surface audit (config.gating_tripwire = :warn is the runtime half). " \
|
|
87
|
+
"Usage: bin/rails current_scope:ungated"
|
|
88
|
+
task ungated: :environment do
|
|
89
|
+
# One reflection for the whole walk — its request object memoizes (KTD-8).
|
|
90
|
+
# A broken controller body's NameError propagates on purpose (KTD-2): a
|
|
91
|
+
# rescue here would report a broken controller as gated.
|
|
92
|
+
gating = CurrentScope::GatingReflection.new
|
|
93
|
+
catalog = CurrentScope.catalog
|
|
94
|
+
grouped = catalog.grouped
|
|
95
|
+
|
|
96
|
+
# The catalog injects the break-glass key onto any row routing an SoD
|
|
97
|
+
# action, and that grant is LIVE even on an ungated controller — honored by
|
|
98
|
+
# whatever gated controller decides SoD on the record (the grid's own
|
|
99
|
+
# KTD-9 exemption). Printing it under "grants nothing" would tell an
|
|
100
|
+
# operator the most sensitive grant in the grid is inert. Strip it from
|
|
101
|
+
# the listing and say so once. Only the INJECTED key is stripped —
|
|
102
|
+
# catalog.routed? keeps a real routed action that merely shares the bypass
|
|
103
|
+
# name in the audit, because omitting it would hide a real fail-open route.
|
|
104
|
+
# The catalog also owns the permission parse (split("#", -1) + shape
|
|
105
|
+
# checks) — a loose split here would accept a malformed value. (#79 review)
|
|
106
|
+
bypass_action = CurrentScope.config.allow_sod_bypass ? catalog.bypass_action : nil
|
|
107
|
+
stripped_bypass = false
|
|
108
|
+
|
|
109
|
+
# Build the printable rows BEFORE deciding emptiness: a synthetic
|
|
110
|
+
# bypass-only row (a namespace-only SoD resource) reflects as "ungated"
|
|
111
|
+
# while routing nothing, and a header over an empty body reads as a broken
|
|
112
|
+
# task. Rows first, then branch on what there is to say.
|
|
113
|
+
rows = grouped.keys.sort.filter_map { |controller|
|
|
114
|
+
next unless gating.ungated?(controller)
|
|
115
|
+
|
|
116
|
+
actions = grouped[controller].sort
|
|
117
|
+
if bypass_action && actions.include?(bypass_action) && !catalog.routed?("#{controller}##{bypass_action}")
|
|
118
|
+
actions -= [ bypass_action ]
|
|
119
|
+
stripped_bypass = true
|
|
120
|
+
end
|
|
121
|
+
next if actions.empty? # nothing routed here — nothing to audit
|
|
122
|
+
|
|
123
|
+
[ controller, actions ]
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
if grouped.empty?
|
|
127
|
+
# A vacuous all-clear is worse than a blank: with nothing routed there
|
|
128
|
+
# was nothing to inspect, and "every routed controller has the callback"
|
|
129
|
+
# is technically true of an empty set and completely misleading.
|
|
130
|
+
puts "No routed controllers found in the permission catalog — nothing was " \
|
|
131
|
+
"inspected. Check your routes and config.excluded_controllers."
|
|
132
|
+
elsif rows.empty?
|
|
133
|
+
# An unexplained blank reads as "the task is broken" — and a bare blank
|
|
134
|
+
# would also overclaim. Claim only what the reflection proved: nothing
|
|
135
|
+
# was PROVEN ungated. A route whose controller doesn't resolve is
|
|
136
|
+
# unclassified, not vouched for (#43 owns that badge) — "every controller
|
|
137
|
+
# has the callback" would vouch for rows nobody inspected.
|
|
138
|
+
puts "No controller was proven ungated. (A routed path whose controller " \
|
|
139
|
+
"does not resolve is unclassified, not verified — see issue #43.)"
|
|
140
|
+
else
|
|
141
|
+
puts "Provably ungated — current_scope_check! is absent from these controllers' " \
|
|
142
|
+
"callback chains, so the gate never runs there:"
|
|
143
|
+
puts
|
|
144
|
+
rows.each { |controller, actions| puts " #{controller} (#{actions.join(', ')})" }
|
|
145
|
+
puts
|
|
146
|
+
puts "Ticking these in the role grid grants nothing until the gate runs. " \
|
|
147
|
+
"If a controller inherited a skip, re-assert before_action " \
|
|
148
|
+
":current_scope_check! on it; if it never had the gate, include " \
|
|
149
|
+
"CurrentScope::Guard."
|
|
150
|
+
if stripped_bypass
|
|
151
|
+
puts
|
|
152
|
+
puts "(#{bypass_action} omitted from the listing — break-glass stays LIVE " \
|
|
153
|
+
"even on an ungated controller; see the role grid's exempt note.)"
|
|
154
|
+
end
|
|
155
|
+
end
|
|
156
|
+
|
|
157
|
+
# The limit of the proof, stated even when nothing is listed (KTD-3): a
|
|
158
|
+
# conditional skip (skip_before_action only:/except:) leaves the callback
|
|
159
|
+
# PRESENT wearing a condition — unprovable by reflection, so never shown
|
|
160
|
+
# here even though some of its actions really run open. The runtime half
|
|
161
|
+
# catches those.
|
|
162
|
+
puts
|
|
163
|
+
puts "Limit: this lists only what the callback chain PROVES. A conditional skip " \
|
|
164
|
+
"(skip_before_action only:/except:) does not appear here — set " \
|
|
165
|
+
"config.gating_tripwire = :warn and include CurrentScope::GatingTripwire " \
|
|
166
|
+
"to inventory those at runtime."
|
|
167
|
+
end
|
|
15
168
|
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.
|
|
4
|
+
version: 0.3.1
|
|
5
5
|
platform: ruby
|
|
6
6
|
authors:
|
|
7
7
|
- David Teren
|
|
@@ -32,7 +32,9 @@ dependencies:
|
|
|
32
32
|
description: 'A mountable Rails engine for authorization: permissions auto-derived
|
|
33
33
|
from controller actions, roles as editable data, per-record scoped roles, a separation-of-duties
|
|
34
34
|
veto, and an ambient authorization context that makes allowed_to? work identically
|
|
35
|
-
in controllers, views, and components.
|
|
35
|
+
in controllers, views, and components. NOT PRODUCTION-READY: pre-1.0 with known
|
|
36
|
+
issues under active work — good for experimentation and spikes, not yet for real
|
|
37
|
+
users. See the README and the issue tracker.'
|
|
36
38
|
email:
|
|
37
39
|
- dteren@gmail.com
|
|
38
40
|
executables: []
|
|
@@ -64,6 +66,7 @@ files:
|
|
|
64
66
|
- app/views/current_scope/roles/members.html.erb
|
|
65
67
|
- app/views/current_scope/roles/new.html.erb
|
|
66
68
|
- app/views/current_scope/scoped_role_assignments/new.html.erb
|
|
69
|
+
- app/views/current_scope/shared/access_denied.html.erb
|
|
67
70
|
- app/views/current_scope/subjects/index.html.erb
|
|
68
71
|
- app/views/layouts/current_scope/application.html.erb
|
|
69
72
|
- config/routes.rb
|
|
@@ -74,6 +77,7 @@ files:
|
|
|
74
77
|
- lib/current_scope/configuration.rb
|
|
75
78
|
- lib/current_scope/context.rb
|
|
76
79
|
- lib/current_scope/engine.rb
|
|
80
|
+
- lib/current_scope/gating_reflection.rb
|
|
77
81
|
- lib/current_scope/gating_tripwire.rb
|
|
78
82
|
- lib/current_scope/guard.rb
|
|
79
83
|
- lib/current_scope/mutation_guard.rb
|