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
|
@@ -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.0
|
|
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
|