current_scope 0.5.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 +7 -0
- data/MIT-LICENSE +20 -0
- data/README.md +377 -0
- data/Rakefile +6 -0
- data/app/assets/javascripts/current_scope/application.js +229 -0
- data/app/assets/stylesheets/current_scope/application.css +917 -0
- data/app/controllers/current_scope/application_controller.rb +96 -0
- data/app/controllers/current_scope/events_controller.rb +14 -0
- data/app/controllers/current_scope/role_assignments_controller.rb +190 -0
- data/app/controllers/current_scope/roles_controller.rb +256 -0
- data/app/controllers/current_scope/scoped_role_assignments_controller.rb +165 -0
- data/app/controllers/current_scope/subjects_controller.rb +75 -0
- data/app/helpers/current_scope/application_helper.rb +261 -0
- data/app/models/concerns/current_scope/storable_keys.rb +101 -0
- data/app/models/current_scope/application_record.rb +5 -0
- data/app/models/current_scope/current.rb +74 -0
- data/app/models/current_scope/event.rb +136 -0
- data/app/models/current_scope/role.rb +106 -0
- data/app/models/current_scope/role_assignment.rb +42 -0
- data/app/models/current_scope/role_permission.rb +9 -0
- data/app/models/current_scope/scoped_role_assignment.rb +98 -0
- data/app/views/current_scope/events/index.html.erb +41 -0
- data/app/views/current_scope/roles/edit.html.erb +229 -0
- data/app/views/current_scope/roles/index.html.erb +55 -0
- data/app/views/current_scope/roles/members.html.erb +114 -0
- data/app/views/current_scope/roles/new.html.erb +19 -0
- data/app/views/current_scope/scoped_role_assignments/new.html.erb +144 -0
- data/app/views/current_scope/shared/access_denied.html.erb +30 -0
- data/app/views/current_scope/subjects/index.html.erb +124 -0
- data/app/views/layouts/current_scope/application.html.erb +56 -0
- data/config/routes.rb +13 -0
- data/db/migrate/20260710000001_create_current_scope_tables.rb +31 -0
- data/db/migrate/20260710000002_create_current_scope_events.rb +32 -0
- data/db/migrate/20260714000001_add_description_to_current_scope_roles.rb +5 -0
- data/db/migrate/20260805000001_widen_current_scope_polymorphic_ids.rb +165 -0
- data/lib/current_scope/configuration.rb +613 -0
- data/lib/current_scope/context.rb +41 -0
- data/lib/current_scope/engine.rb +202 -0
- data/lib/current_scope/gating_reflection.rb +113 -0
- data/lib/current_scope/gating_tripwire.rb +84 -0
- data/lib/current_scope/grant_diagnosis.rb +216 -0
- data/lib/current_scope/guard.rb +750 -0
- data/lib/current_scope/mutation_guard.rb +90 -0
- data/lib/current_scope/parent_chain.rb +397 -0
- data/lib/current_scope/permission_catalog.rb +146 -0
- data/lib/current_scope/permission_grid.rb +132 -0
- data/lib/current_scope/permissions.rb +94 -0
- data/lib/current_scope/resolver.rb +669 -0
- data/lib/current_scope/schema_guard.rb +223 -0
- data/lib/current_scope/scopeable.rb +38 -0
- data/lib/current_scope/sod_preflight.rb +380 -0
- data/lib/current_scope/test_helpers.rb +53 -0
- data/lib/current_scope/version.rb +3 -0
- data/lib/current_scope.rb +432 -0
- data/lib/generators/current_scope/install/install_generator.rb +114 -0
- data/lib/generators/current_scope/install/templates/initializer.rb +175 -0
- data/lib/tasks/current_scope_tasks.rake +454 -0
- metadata +123 -0
|
@@ -0,0 +1,175 @@
|
|
|
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
|
+
|
|
37
|
+
# Controller method that returns the authenticated subject.
|
|
38
|
+
# config.user_method = :current_user
|
|
39
|
+
|
|
40
|
+
# Class whose instances hold roles, used by the management UI.
|
|
41
|
+
# config.subject_class = "User"
|
|
42
|
+
|
|
43
|
+
# How a subject is identified in the management UI (an id is meaningless with
|
|
44
|
+
# UUID keys). A Symbol names a method; a Proc takes the subject. Defaults to a
|
|
45
|
+
# best-effort label (current_scope_label, else name/email/title, else id).
|
|
46
|
+
# config.subject_label = :email
|
|
47
|
+
# config.subject_label = ->(u) { "#{u.first_name} #{u.last_name}" }
|
|
48
|
+
|
|
49
|
+
# Fold the role-editor grid's action columns. Default = CRUD (new/edit hide
|
|
50
|
+
# into create/update; index+show read as one). Set to nil to show every raw
|
|
51
|
+
# action as its own column.
|
|
52
|
+
# config.permission_grid_groups = { "read" => %w[index show], "create" => %w[new create],
|
|
53
|
+
# "update" => %w[edit update], "destroy" => %w[destroy] }
|
|
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
|
+
|
|
75
|
+
# --- Impersonation (act-as) ---------------------------------------------
|
|
76
|
+
# These three knobs layer, in this order:
|
|
77
|
+
#
|
|
78
|
+
# 1. actor_method — turns impersonation ON.
|
|
79
|
+
# 2. allow_mutations_while_impersonating — the read-only gate runs FIRST.
|
|
80
|
+
# 3. sod_identity — only observable once a mutation
|
|
81
|
+
# is allowed through (or on a
|
|
82
|
+
# GET-listed sod_action).
|
|
83
|
+
#
|
|
84
|
+
# Controller method returning the REAL actor while impersonating. Leave unset
|
|
85
|
+
# when no one impersonates — actor then equals the subject, so attribution
|
|
86
|
+
# reads current_scope_actor with no nil branch, and sod_identity below has no
|
|
87
|
+
# effect. See the "Impersonation (act-as)" section of the README.
|
|
88
|
+
# config.actor_method = :true_user
|
|
89
|
+
|
|
90
|
+
# false (default): an impersonated session is read-only — every non-GET/HEAD
|
|
91
|
+
# request is denied, INCLUDING the engine's management UI. Your
|
|
92
|
+
# stop-impersonation, sign-out, and sign-in endpoints must opt out with
|
|
93
|
+
# skip_before_action :current_scope_mutation_guard!, or impersonation (and
|
|
94
|
+
# sign-in) can never clear it.
|
|
95
|
+
# config.allow_mutations_while_impersonating = false
|
|
96
|
+
|
|
97
|
+
# Which identities the separation-of-duties veto weighs. :either (default)
|
|
98
|
+
# also vetoes a record the REAL actor initiated while impersonating, so
|
|
99
|
+
# impersonation can never approve your own record. :subject weighs only the
|
|
100
|
+
# effective subject. Identical when not impersonating.
|
|
101
|
+
# config.sod_identity = :either
|
|
102
|
+
# ------------------------------------------------------------------------
|
|
103
|
+
|
|
104
|
+
# Separation of duties is OPT-IN — empty by default. List the actions a
|
|
105
|
+
# record's initiator can never perform on their own record (four-eyes).
|
|
106
|
+
# Deliberately NOT editable in the UI. Records reached by these actions must
|
|
107
|
+
# define current_scope_initiator (return nil to exempt a record type) — the
|
|
108
|
+
# resolver raises if the hook is missing, in :report mode as well as :enforce.
|
|
109
|
+
# The engine logs a preflight warning naming the actions it can see this
|
|
110
|
+
# coming for (at boot in production; on the first request in development,
|
|
111
|
+
# whose route set is lazy); `rails current_scope:report` lists them too. Leave
|
|
112
|
+
# commented for RBAC-only apps.
|
|
113
|
+
# config.sod_actions = %w[approve]
|
|
114
|
+
|
|
115
|
+
# Audit ledger — tri-state: false | true (default) | :strict.
|
|
116
|
+
# false — no audit rows recorded.
|
|
117
|
+
# true — record management-UI mutations, impersonation boundary events,
|
|
118
|
+
# and grant!/rake/seeds bootstrap grants; if the events table
|
|
119
|
+
# hasn't been migrated yet, degrade gracefully (skip + warn once).
|
|
120
|
+
# Direct model writes and test helpers are not recorded.
|
|
121
|
+
# :strict — a missing events table RAISES (rolling the mutation back), so an
|
|
122
|
+
# audit-mandatory app never commits an unaudited change.
|
|
123
|
+
# config.audit = true
|
|
124
|
+
|
|
125
|
+
# --- Dev diagnostics -----------------------------------------------------
|
|
126
|
+
# Four failure modes this engine has that are SILENT, and silent in the bad
|
|
127
|
+
# direction — the thing going wrong looks exactly like the thing going right.
|
|
128
|
+
# All four are LOG-ONLY (no decision, exception, header, or audit row changes)
|
|
129
|
+
# and all four default ON in development and test, OFF in production.
|
|
130
|
+
#
|
|
131
|
+
# They are listed here rather than left to the docs on purpose: a named flag in
|
|
132
|
+
# your initializer is how you learn the failure mode exists at all.
|
|
133
|
+
|
|
134
|
+
# The SoD veto was SKIPPED because the gate had no record — an SoD member
|
|
135
|
+
# action whose current_scope_record returned nil (or was never declared). The
|
|
136
|
+
# request was ALLOWED, and a skipped veto looks identical to a veto that
|
|
137
|
+
# passed. The gem's #1 foot-gun.
|
|
138
|
+
# config.warn_on_nil_sod_record = Rails.env.local?
|
|
139
|
+
|
|
140
|
+
# Denied "no_grant", but the subject holds a scoped grant that WOULD have
|
|
141
|
+
# applied — and the controller declares no current_scope_record, so the gate
|
|
142
|
+
# had no record to apply it to. A member action that forgot its hook: it fails
|
|
143
|
+
# closed (correctly), but the 403 is indistinguishable from "never granted", so
|
|
144
|
+
# you go and stare at the grants, which are fine.
|
|
145
|
+
# config.warn_on_inert_scoped_grant = Rails.env.local?
|
|
146
|
+
|
|
147
|
+
# Short-form allowed_to?(:show, record) derived a DIFFERENT key than the gate
|
|
148
|
+
# on the current controller enforces (the namespaced/custom-named controller
|
|
149
|
+
# foot-gun): a link that 403s, or a hidden one that would have worked. A hint,
|
|
150
|
+
# not an accusation — asking about another resource derives a different key too,
|
|
151
|
+
# and that's correct — so it warns once per site and names both readings.
|
|
152
|
+
# config.warn_on_cross_controller_derivation = Rails.env.local?
|
|
153
|
+
|
|
154
|
+
# Denied "model_undeclared": a collection action declared with
|
|
155
|
+
# `current_scope_record = nil` on a controller that names no
|
|
156
|
+
# current_scope_model, while the subject holds a scoped grant ticking the
|
|
157
|
+
# key. The gate had no type to bind that grant to, so it failed closed —
|
|
158
|
+
# correctly, but the fix is one line: `def current_scope_model = TheType`.
|
|
159
|
+
# The same flag covers "model_invalid" — a declared hook returning
|
|
160
|
+
# something other than a concrete AR class ("Report" for Report); that
|
|
161
|
+
# nudge names the value the hook returned.
|
|
162
|
+
# config.warn_on_undeclared_collection_model = Rails.env.local?
|
|
163
|
+
# ------------------------------------------------------------------------
|
|
164
|
+
|
|
165
|
+
# Controller paths (regexps) excluded from the permission grid. Excluded
|
|
166
|
+
# controllers can't be granted, so they must also skip the gate with
|
|
167
|
+
# skip_before_action :current_scope_check! — Guard raises otherwise.
|
|
168
|
+
# Skipping the gate leaves the controller ungated by CurrentScope — protect
|
|
169
|
+
# it with your own authorization (e.g. require_admin!). See
|
|
170
|
+
# https://github.com/davidteren/current_scope/blob/main/docs/SECURITY-CHECKLIST.md
|
|
171
|
+
# config.excluded_controllers += [%r{\Awebhooks/}]
|
|
172
|
+
|
|
173
|
+
# Controller the management UI inherits from (for host auth + before_actions).
|
|
174
|
+
# config.parent_controller = "::ApplicationController"
|
|
175
|
+
end
|
|
@@ -0,0 +1,454 @@
|
|
|
1
|
+
namespace :current_scope do
|
|
2
|
+
desc "Apply the #151 grant-column shape to an existing database, idempotently. " \
|
|
3
|
+
"Usage: bin/rails current_scope:repair_schema"
|
|
4
|
+
task repair_schema: :environment do
|
|
5
|
+
# WHY THIS EXISTS SEPARATELY FROM db:migrate.
|
|
6
|
+
#
|
|
7
|
+
# A database built by `db:schema:load` / `db:setup` / `db:test:prepare` —
|
|
8
|
+
# every new app, every CI run, every fresh checkout — comes out with the
|
|
9
|
+
# right column TYPE and the server's default collation, because schema.rb
|
|
10
|
+
# cannot express a MySQL collation. Loading a schema also stamps every
|
|
11
|
+
# migration version as applied, so `db:migrate` has nothing pending and
|
|
12
|
+
# prints nothing. On MySQL that left a host unable to boot, with the boot
|
|
13
|
+
# error prescribing a command that could not possibly fix it.
|
|
14
|
+
#
|
|
15
|
+
# This task re-applies the migration's own logic directly. It is idempotent:
|
|
16
|
+
# where the columns are already correct it changes nothing.
|
|
17
|
+
path = Dir[CurrentScope::Engine.root.join("db/migrate/*_widen_current_scope_polymorphic_ids.rb")].first
|
|
18
|
+
abort "Could not find the widening migration inside the gem." if path.nil?
|
|
19
|
+
|
|
20
|
+
load path
|
|
21
|
+
migration = WidenCurrentScopePolymorphicIds.new
|
|
22
|
+
migration.verbose = false
|
|
23
|
+
migration.migrate(:up)
|
|
24
|
+
|
|
25
|
+
# Say what this adapter actually did. The binary collation is a MySQL-only
|
|
26
|
+
# step — PostgreSQL and SQLite already compare these columns byte for byte —
|
|
27
|
+
# so claiming it everywhere would tell a PostgreSQL operator their columns
|
|
28
|
+
# were re-collated when nothing of the sort happened.
|
|
29
|
+
shape = "#{CurrentScope::KEY_LIMIT}-character"
|
|
30
|
+
shape += ", binary-collated" if CurrentScope.mysql?(CurrentScope::RoleAssignment.connection)
|
|
31
|
+
puts "CurrentScope grant columns are in the #{shape} shape #151 requires."
|
|
32
|
+
end
|
|
33
|
+
|
|
34
|
+
desc "Grant the full-access Owner role to a subject (bootstrap the first admin). " \
|
|
35
|
+
"Usage: bin/rails current_scope:grant SUBJECT_ID=1"
|
|
36
|
+
task grant: :environment do
|
|
37
|
+
id = ENV["SUBJECT_ID"]
|
|
38
|
+
abort "SUBJECT_ID is required, e.g. bin/rails current_scope:grant SUBJECT_ID=1" if id.blank?
|
|
39
|
+
|
|
40
|
+
klass = CurrentScope.config.subject_class.constantize
|
|
41
|
+
subject = klass.find_by(id: id)
|
|
42
|
+
abort "No #{klass} with id=#{id}" if subject.nil?
|
|
43
|
+
|
|
44
|
+
# grant! seeds Owner on the default path — warn on replacement even when
|
|
45
|
+
# the Owner row does not exist yet (first-time Owner creation).
|
|
46
|
+
prior = CurrentScope::RoleAssignment.find_by(subject: subject)&.role
|
|
47
|
+
if prior && prior.name != "Owner"
|
|
48
|
+
warn "WARNING: #{klass}##{subject.id} already held the #{prior.name.inspect} role — " \
|
|
49
|
+
"replacing it with full-access Owner."
|
|
50
|
+
end
|
|
51
|
+
|
|
52
|
+
CurrentScope.grant!(subject)
|
|
53
|
+
puts "Granted the full-access Owner role to #{klass}##{subject.id}."
|
|
54
|
+
end
|
|
55
|
+
|
|
56
|
+
desc "Summarize would-be denials recorded in report mode into a starter role grid. " \
|
|
57
|
+
"Usage: bin/rails current_scope:report"
|
|
58
|
+
task report: :environment do
|
|
59
|
+
# The subject's current org-wide role, when resolvable — the grid reads
|
|
60
|
+
# differently if someone already holds a role that just doesn't tick these
|
|
61
|
+
# keys. Best-effort: a rollout aid must not abort everyone else's summary
|
|
62
|
+
# because one subject's record was deleted or its class no longer loads.
|
|
63
|
+
# A lambda, not a def — a rake file's `def` lands on Object.
|
|
64
|
+
org_role_suffix = lambda do |subject_gid|
|
|
65
|
+
subject = GlobalID::Locator.locate(subject_gid)
|
|
66
|
+
role = subject && CurrentScope::RoleAssignment.find_by(subject: subject)&.role
|
|
67
|
+
role ? " — currently #{role.name}" : ""
|
|
68
|
+
rescue StandardError
|
|
69
|
+
""
|
|
70
|
+
end
|
|
71
|
+
|
|
72
|
+
begin
|
|
73
|
+
rows = CurrentScope::Event.where(event: "access.would_deny")
|
|
74
|
+
.pluck(:subject, :target_label, :details)
|
|
75
|
+
# #73: SoD blind-spot 403s are NOT would_deny (granting won't fix them).
|
|
76
|
+
# Surface them as a separate section so the survey is complete.
|
|
77
|
+
blind_rows = CurrentScope::Event.where(event: "access.sod_blind_spot")
|
|
78
|
+
.pluck(:subject, :target_label, :details)
|
|
79
|
+
# #133: an SoD action whose model defines no current_scope_initiator did
|
|
80
|
+
# not 403 — it RAISED. Its own section, because it is the one report-mode
|
|
81
|
+
# outcome that is a 500, and neither granting nor the record hook fixes it.
|
|
82
|
+
initiator_rows = CurrentScope::Event.where(event: "access.sod_initiator_missing")
|
|
83
|
+
.pluck(:subject, :target_label, :details)
|
|
84
|
+
rescue ActiveRecord::StatementInvalid => e
|
|
85
|
+
# Report mode without the migration records nothing (the ledger degrades and
|
|
86
|
+
# warns once). Reaching for this summary is exactly how a host discovers
|
|
87
|
+
# that, so it must name the fix rather than raise a stack trace at them.
|
|
88
|
+
raise unless e.message.match?(/current_scope_events/i)
|
|
89
|
+
|
|
90
|
+
abort "The current_scope_events table doesn't exist, so nothing was recorded.\n" \
|
|
91
|
+
"Run: bin/rails current_scope:install:migrations && bin/rails db:migrate"
|
|
92
|
+
end
|
|
93
|
+
|
|
94
|
+
# #134: these two are STATIC — derived from the grants table, not the
|
|
95
|
+
# ledger — so they are present with zero traffic. That is the whole point:
|
|
96
|
+
# a grant that can never match is most likely to exist BEFORE report mode
|
|
97
|
+
# was ever exercised, which is exactly when the ledger is empty.
|
|
98
|
+
# find_each, not a full load: a host's grants table can be large and this is
|
|
99
|
+
# a scan. Verdict computed once and handed to the advisory, which would
|
|
100
|
+
# otherwise recompute it (and re-query role_permissions) per grant.
|
|
101
|
+
# #133: static too, and for the same reason the grant scans are — an SoD
|
|
102
|
+
# action with no initiator behind it exists before report mode is ever
|
|
103
|
+
# exercised, which is exactly when the ledger is empty. Never raises; it
|
|
104
|
+
# degrades to no findings and logs.
|
|
105
|
+
# One scan, carried as a value. Nothing below has to reason about whether
|
|
106
|
+
# some other call has since reset a flag on the module.
|
|
107
|
+
preflight = CurrentScope::SodPreflight.scan
|
|
108
|
+
preflight_rows = preflight.rows
|
|
109
|
+
|
|
110
|
+
dead_grants = []
|
|
111
|
+
untargeted_grants = []
|
|
112
|
+
begin
|
|
113
|
+
CurrentScope::ScopedRoleAssignment.includes(role: :role_permissions)
|
|
114
|
+
.in_batches(of: 500) do |relation|
|
|
115
|
+
batch = relation.to_a
|
|
116
|
+
# orphaned? reads the polymorphic resource, which includes() cannot
|
|
117
|
+
# cover — without this the scan costs one extra query per grant.
|
|
118
|
+
CurrentScope::ScopedRoleAssignment.preload_resolvable_resources!(batch)
|
|
119
|
+
batch.each do |grant|
|
|
120
|
+
verdict = CurrentScope::GrantDiagnosis.verdict_for(grant)
|
|
121
|
+
if verdict
|
|
122
|
+
dead_grants << [ grant, verdict ]
|
|
123
|
+
elsif CurrentScope::GrantDiagnosis.type_untargeted?(grant, verdict: verdict)
|
|
124
|
+
untargeted_grants << grant
|
|
125
|
+
end
|
|
126
|
+
end
|
|
127
|
+
end
|
|
128
|
+
rescue ActiveRecord::ActiveRecordError => e
|
|
129
|
+
# This scan is an ADDITION to the ledger survey, so a database problem
|
|
130
|
+
# here must not take the whole task down — the would-deny summary is the
|
|
131
|
+
# part a host is mid-rollout depending on. Degrade to no static findings
|
|
132
|
+
# and say so. (cubic)
|
|
133
|
+
warn "[CurrentScope] could not scan scoped grants (#{e.class}: #{e.message}); " \
|
|
134
|
+
"skipping the static grant sections."
|
|
135
|
+
dead_grants = []
|
|
136
|
+
untargeted_grants = []
|
|
137
|
+
end
|
|
138
|
+
|
|
139
|
+
# Same rescue the org_role_suffix lambda above needs, for the same reason: a
|
|
140
|
+
# rollout aid must not abort the whole survey because one subject's class was
|
|
141
|
+
# removed. Without it this task now raises NameError where it never did.
|
|
142
|
+
grant_line = lambda do |grant|
|
|
143
|
+
# Through the canonical guard: a non-canonical stored subject_id must not
|
|
144
|
+
# be labeled as the unrelated live record it would cast into (#151).
|
|
145
|
+
subject = grant.current_scope_resolved_record("subject")
|
|
146
|
+
who =
|
|
147
|
+
begin
|
|
148
|
+
subject ? CurrentScope.label_for(subject) : "#{grant.subject_type} ##{grant.subject_id}"
|
|
149
|
+
rescue StandardError
|
|
150
|
+
"#{grant.subject_type} ##{grant.subject_id}"
|
|
151
|
+
end
|
|
152
|
+
" #{who} — role \"#{grant.role&.name}\" on #{grant.resource_type}##{grant.resource_id}"
|
|
153
|
+
end
|
|
154
|
+
|
|
155
|
+
# One running flag, not a per-section list of every section before it. That
|
|
156
|
+
# chain grew a term each time a section was added (#134 added two, #133 two
|
|
157
|
+
# more), and it made every new section an edit to every LATER section's
|
|
158
|
+
# guard — miss one and the task prints a wrong blank line that no test
|
|
159
|
+
# catches. `separate` emits the blank only when something already printed.
|
|
160
|
+
printed_section = false
|
|
161
|
+
separate = lambda do
|
|
162
|
+
puts if printed_section
|
|
163
|
+
printed_section = true
|
|
164
|
+
end
|
|
165
|
+
|
|
166
|
+
# THE QUESTION THIS TASK IS RUN TO ANSWER is "can I flip to :enforce yet?",
|
|
167
|
+
# and six independently-gated sections made the reader OR them together by
|
|
168
|
+
# hand. One count line first, so the answer is on screen before the detail.
|
|
169
|
+
#
|
|
170
|
+
# Deliberately NOT a verdict. "Safe to enforce" is not a claim this task can
|
|
171
|
+
# make: the preflight is partial by construction, the ledger is historical
|
|
172
|
+
# and only as complete as the traffic that has run, and neither can see a
|
|
173
|
+
# controller nobody exercised. It reports what it FOUND and names what it
|
|
174
|
+
# cannot see — the same rule the preflight caveat and the ungated task
|
|
175
|
+
# follow. (#133 review)
|
|
176
|
+
signals = {
|
|
177
|
+
"would-be denials (grant these)" => rows.count,
|
|
178
|
+
"scoped grants that can never match" => dead_grants.count,
|
|
179
|
+
"scoped grants worth checking" => untargeted_grants.count,
|
|
180
|
+
"SoD actions that will RAISE (500, not grantable)" => preflight_rows.count,
|
|
181
|
+
"SoD blind-spot denials (not grantable)" => blind_rows.count,
|
|
182
|
+
"SoD actions that already RAISED (500, not grantable)" => initiator_rows.count
|
|
183
|
+
}.reject { |_label, count| count.zero? }
|
|
184
|
+
|
|
185
|
+
separate.call
|
|
186
|
+
if signals.empty?
|
|
187
|
+
puts "CurrentScope report: nothing found in any category."
|
|
188
|
+
else
|
|
189
|
+
puts "CurrentScope report — #{signals.count} category(ies) with something in them:"
|
|
190
|
+
signals.each { |label, count| puts " #{count.to_s.rjust(6)} #{label}" }
|
|
191
|
+
end
|
|
192
|
+
puts
|
|
193
|
+
# The SoD clause only when the host opted into SoD. A project that never set
|
|
194
|
+
# config.sod_actions has no preflight to qualify, and naming one is noise
|
|
195
|
+
# about a feature they do not use — pinned by "no SoD config means no
|
|
196
|
+
# preflight section at all".
|
|
197
|
+
caveat_line = +" This is a survey, not a clearance: the ledger only knows traffic that " \
|
|
198
|
+
"has already run."
|
|
199
|
+
if CurrentScope.config.sod_actions.any?
|
|
200
|
+
caveat_line << " The SoD preflight is also partial by construction (its own note says how)."
|
|
201
|
+
caveat_line << " It could not complete this run." if preflight.degraded?
|
|
202
|
+
end
|
|
203
|
+
puts caveat_line
|
|
204
|
+
|
|
205
|
+
# Still the ledger guard, but it no longer RETURNS: the sections below are
|
|
206
|
+
# derived from the grants table, not the ledger. (#134)
|
|
207
|
+
if rows.empty? && blind_rows.empty? && initiator_rows.empty?
|
|
208
|
+
separate.call
|
|
209
|
+
# "No output" is indistinguishable from "the task is broken", and the two
|
|
210
|
+
# likeliest causes are both SILENT: report mode never on, or audit off.
|
|
211
|
+
# Name them — this is the first thing a host runs, and an unexplained blank
|
|
212
|
+
# is how they conclude the feature doesn't work.
|
|
213
|
+
puts "No would-be denials recorded."
|
|
214
|
+
puts
|
|
215
|
+
puts " config.enforcement is #{CurrentScope.config.enforcement.inspect} " \
|
|
216
|
+
"(needs :report to record any)"
|
|
217
|
+
puts " config.audit is #{CurrentScope.config.audit.inspect} " \
|
|
218
|
+
"(needs true or :strict — the ledger is where these rows live)"
|
|
219
|
+
puts
|
|
220
|
+
puts "With both on, exercise the app or run your suite, then re-run this."
|
|
221
|
+
# NOT `next`. The sections below are derived from the grants table and the
|
|
222
|
+
# routes, not the ledger, so they are present with zero traffic — which is
|
|
223
|
+
# exactly when a grant that can never match is most likely to exist.
|
|
224
|
+
# Returning here would hide them in that case. The config explanation
|
|
225
|
+
# above still prints, because "nothing was recorded" stays true and
|
|
226
|
+
# unexplained silence is how a host concludes the feature does not
|
|
227
|
+
# work. (#134) The blank line before whatever follows is `separate`'s job
|
|
228
|
+
# now, so this branch no longer has to look ahead at the other sections.
|
|
229
|
+
end
|
|
230
|
+
|
|
231
|
+
# ponytail: group in Ruby, not SQL. `details` is a JSON column and querying
|
|
232
|
+
# into it is adapter-specific; this is a rollout aid run by hand over a
|
|
233
|
+
# transitional table, so portability beats a smarter query.
|
|
234
|
+
#
|
|
235
|
+
# Shared tally so would_deny and sod_blind_spot sections cannot drift on
|
|
236
|
+
# ordering / unknown-permission handling (PR #103 review).
|
|
237
|
+
print_permission_counts = lambda do |event_rows|
|
|
238
|
+
event_rows
|
|
239
|
+
.group_by { |_s, _l, details| details.is_a?(Hash) ? details["permission"] : nil }
|
|
240
|
+
.transform_values(&:count)
|
|
241
|
+
.sort_by { |permission, count| [ -count, permission.to_s ] }
|
|
242
|
+
.each { |permission, count| puts " #{count.to_s.rjust(5)}x #{permission || '(unknown)'}" }
|
|
243
|
+
end
|
|
244
|
+
|
|
245
|
+
unless rows.empty?
|
|
246
|
+
separate.call
|
|
247
|
+
grouped = rows.group_by { |subject, _label, _details| subject }
|
|
248
|
+
|
|
249
|
+
puts "Would-be denials — grant these to stop them (most-denied first):"
|
|
250
|
+
puts
|
|
251
|
+
|
|
252
|
+
grouped.each do |subject_gid, subject_rows|
|
|
253
|
+
label = subject_rows.first[1].presence || subject_gid
|
|
254
|
+
puts " #{label}#{org_role_suffix.call(subject_gid)}"
|
|
255
|
+
print_permission_counts.call(subject_rows)
|
|
256
|
+
puts
|
|
257
|
+
end
|
|
258
|
+
|
|
259
|
+
puts "Total: #{rows.count} would-be denials across #{grouped.size} subject(s)."
|
|
260
|
+
end
|
|
261
|
+
|
|
262
|
+
unless dead_grants.empty?
|
|
263
|
+
separate.call
|
|
264
|
+
puts "Scoped grants that can never match — granting more will not help:"
|
|
265
|
+
puts
|
|
266
|
+
dead_grants.group_by { |_g, verdict| verdict }.each do |verdict, pairs|
|
|
267
|
+
puts " #{CurrentScope::GrantDiagnosis.verdict_label(verdict)}"
|
|
268
|
+
pairs.each { |grant, _v| puts grant_line.call(grant) }
|
|
269
|
+
puts " → #{CurrentScope::GrantDiagnosis.verdict_fix(verdict)}"
|
|
270
|
+
puts
|
|
271
|
+
end
|
|
272
|
+
puts "Total: #{dead_grants.count} grant(s) that cannot match any gated action."
|
|
273
|
+
end
|
|
274
|
+
|
|
275
|
+
unless untargeted_grants.empty?
|
|
276
|
+
separate.call
|
|
277
|
+
puts "Worth checking — no ticked key targets this grant's type:"
|
|
278
|
+
puts
|
|
279
|
+
untargeted_grants.each { |grant| puts grant_line.call(grant) }
|
|
280
|
+
puts
|
|
281
|
+
puts " #{CurrentScope::GrantDiagnosis.untargeted_caveat}"
|
|
282
|
+
end
|
|
283
|
+
|
|
284
|
+
# An EMPTY preflight still speaks when SoD is on. Suppressing the section
|
|
285
|
+
# entirely made a check that blew up (a host hook that raises, no database
|
|
286
|
+
# connection yet, a controller that will not load) look identical on stdout
|
|
287
|
+
# to a check that ran clean — and the PARTIAL caveat, the thing that stops
|
|
288
|
+
# this being read as a verdict, lived inside the suppressed branch. The
|
|
289
|
+
# degrade warning goes to the log, not to the terminal the operator is
|
|
290
|
+
# reading right before an enforce flip. Same rule as the ungated task: a
|
|
291
|
+
# vacuous all-clear is worse than a blank. (#133 review)
|
|
292
|
+
if preflight_rows.empty? && CurrentScope.config.sod_actions.any?
|
|
293
|
+
separate.call
|
|
294
|
+
if preflight.degraded?
|
|
295
|
+
puts "Separation-of-duties preflight: COULD NOT COMPLETE — this section is incomplete."
|
|
296
|
+
puts
|
|
297
|
+
# The reason printed HERE rather than "see the log": an operator reading
|
|
298
|
+
# a terminal must not be sent off to find a log file.
|
|
299
|
+
puts " #{CurrentScope::SodPreflight.skip_summary(preflight)}"
|
|
300
|
+
puts " Do NOT read the absence of findings below as an all-clear."
|
|
301
|
+
else
|
|
302
|
+
puts "Separation-of-duties preflight: inspected #{preflight.inspected} of " \
|
|
303
|
+
"#{preflight.in_scope} routed SoD action(s); none named a model missing " \
|
|
304
|
+
"#{CurrentScope::Resolver::INITIATOR_METHOD}."
|
|
305
|
+
if preflight.blind?
|
|
306
|
+
puts
|
|
307
|
+
puts " NOTHING was inspected — none of those controllers declares " \
|
|
308
|
+
"current_scope_model, so there was nothing for this check to read. This is " \
|
|
309
|
+
"not an all-clear."
|
|
310
|
+
end
|
|
311
|
+
end
|
|
312
|
+
puts
|
|
313
|
+
puts " #{CurrentScope::SodPreflight.caveat}"
|
|
314
|
+
end
|
|
315
|
+
|
|
316
|
+
unless preflight_rows.empty?
|
|
317
|
+
separate.call
|
|
318
|
+
puts "Separation-of-duties actions that will RAISE (500) — not a denial, a misconfiguration:"
|
|
319
|
+
puts
|
|
320
|
+
preflight_rows.each do |permission, model|
|
|
321
|
+
puts " #{permission} — #{model.name} defines no " \
|
|
322
|
+
"#{CurrentScope::Resolver::INITIATOR_METHOD}"
|
|
323
|
+
end
|
|
324
|
+
puts
|
|
325
|
+
# SHARED with the boot warning, not re-spelled. The remedies are not
|
|
326
|
+
# coequal on a list that can be wrong — defining the hook wires a control,
|
|
327
|
+
# removing the action deletes one — and a private copy here is how that
|
|
328
|
+
# correction reached one surface and not the other for a commit. (#133)
|
|
329
|
+
puts " #{CurrentScope::SodPreflight.fix_line}"
|
|
330
|
+
puts " Report mode does NOT downgrade these — the request 500s exactly as it would " \
|
|
331
|
+
"under :enforce."
|
|
332
|
+
puts
|
|
333
|
+
puts " #{CurrentScope::SodPreflight.caveat}"
|
|
334
|
+
end
|
|
335
|
+
|
|
336
|
+
unless blind_rows.empty?
|
|
337
|
+
separate.call
|
|
338
|
+
puts "SoD blind-spot denials — NOT fixed by granting (declare current_scope_record):"
|
|
339
|
+
puts
|
|
340
|
+
print_permission_counts.call(blind_rows)
|
|
341
|
+
puts
|
|
342
|
+
puts "Total: #{blind_rows.count} blind-spot 403(s). Granting these permissions will not " \
|
|
343
|
+
"clear them — fix the record hook (or remove the action from config.sod_actions)."
|
|
344
|
+
end
|
|
345
|
+
|
|
346
|
+
# #133: the traffic-found half. The static section above catches these only
|
|
347
|
+
# where a controller declares current_scope_model; these rows are the ones
|
|
348
|
+
# that reached a real request first, so they name the model the gate ACTUALLY
|
|
349
|
+
# held — a proof where the static list is a lead.
|
|
350
|
+
unless initiator_rows.empty?
|
|
351
|
+
separate.call
|
|
352
|
+
puts "SoD actions that RAISED (500s) — NOT fixed by granting, a missing " \
|
|
353
|
+
"current_scope_initiator:"
|
|
354
|
+
puts
|
|
355
|
+
initiator_rows
|
|
356
|
+
.group_by { |_s, _l, details| details.is_a?(Hash) ? [ details["permission"], details["model"] ] : nil }
|
|
357
|
+
.transform_values(&:count)
|
|
358
|
+
.sort_by { |pair, count| [ -count, pair.to_a.map(&:to_s) ] }
|
|
359
|
+
.each do |pair, count|
|
|
360
|
+
permission, model = pair
|
|
361
|
+
puts " #{count.to_s.rjust(5)}x #{permission || '(unknown)'} — " \
|
|
362
|
+
"#{model || '(unknown model)'}"
|
|
363
|
+
end
|
|
364
|
+
puts
|
|
365
|
+
puts "Total: #{initiator_rows.count} raised request(s). These are NOT denials and granting " \
|
|
366
|
+
"changes nothing — define #{CurrentScope::Resolver::INITIATOR_METHOD} on each model " \
|
|
367
|
+
"listed (return nil to exempt a record), or remove the action from config.sod_actions."
|
|
368
|
+
end
|
|
369
|
+
end
|
|
370
|
+
|
|
371
|
+
desc "Inventory the routed controllers that provably never run the gate — the static " \
|
|
372
|
+
"half of the ungated-surface audit (config.gating_tripwire = :warn is the runtime half). " \
|
|
373
|
+
"Usage: bin/rails current_scope:ungated"
|
|
374
|
+
task ungated: :environment do
|
|
375
|
+
# One reflection for the whole walk — its request object memoizes (KTD-8).
|
|
376
|
+
# A broken controller body's NameError propagates on purpose (KTD-2): a
|
|
377
|
+
# rescue here would report a broken controller as gated.
|
|
378
|
+
gating = CurrentScope::GatingReflection.new
|
|
379
|
+
catalog = CurrentScope.catalog
|
|
380
|
+
grouped = catalog.grouped
|
|
381
|
+
|
|
382
|
+
# The catalog injects the break-glass key onto any row routing an SoD
|
|
383
|
+
# action, and that grant is LIVE even on an ungated controller — honored by
|
|
384
|
+
# whatever gated controller decides SoD on the record (the grid's own
|
|
385
|
+
# KTD-9 exemption). Printing it under "grants nothing" would tell an
|
|
386
|
+
# operator the most sensitive grant in the grid is inert. Strip it from
|
|
387
|
+
# the listing and say so once. Only the INJECTED key is stripped —
|
|
388
|
+
# catalog.routed? keeps a real routed action that merely shares the bypass
|
|
389
|
+
# name in the audit, because omitting it would hide a real fail-open route.
|
|
390
|
+
# The catalog also owns the permission parse (split("#", -1) + shape
|
|
391
|
+
# checks) — a loose split here would accept a malformed value. (#79 review)
|
|
392
|
+
bypass_action = CurrentScope.config.allow_sod_bypass ? catalog.bypass_action : nil
|
|
393
|
+
stripped_bypass = false
|
|
394
|
+
|
|
395
|
+
# Build the printable rows BEFORE deciding emptiness: a synthetic
|
|
396
|
+
# bypass-only row (a namespace-only SoD resource) reflects as "ungated"
|
|
397
|
+
# while routing nothing, and a header over an empty body reads as a broken
|
|
398
|
+
# task. Rows first, then branch on what there is to say.
|
|
399
|
+
rows = grouped.keys.sort.filter_map { |controller|
|
|
400
|
+
next unless gating.ungated?(controller)
|
|
401
|
+
|
|
402
|
+
actions = grouped[controller].sort
|
|
403
|
+
if bypass_action && actions.include?(bypass_action) && !catalog.routed?("#{controller}##{bypass_action}")
|
|
404
|
+
actions -= [ bypass_action ]
|
|
405
|
+
stripped_bypass = true
|
|
406
|
+
end
|
|
407
|
+
next if actions.empty? # nothing routed here — nothing to audit
|
|
408
|
+
|
|
409
|
+
[ controller, actions ]
|
|
410
|
+
}
|
|
411
|
+
|
|
412
|
+
if grouped.empty?
|
|
413
|
+
# A vacuous all-clear is worse than a blank: with nothing routed there
|
|
414
|
+
# was nothing to inspect, and "every routed controller has the callback"
|
|
415
|
+
# is technically true of an empty set and completely misleading.
|
|
416
|
+
puts "No routed controllers found in the permission catalog — nothing was " \
|
|
417
|
+
"inspected. Check your routes and config.excluded_controllers."
|
|
418
|
+
elsif rows.empty?
|
|
419
|
+
# An unexplained blank reads as "the task is broken" — and a bare blank
|
|
420
|
+
# would also overclaim. Claim only what the reflection proved: nothing
|
|
421
|
+
# was PROVEN ungated. A route whose controller doesn't resolve is
|
|
422
|
+
# unclassified, not vouched for (#43 owns that badge) — "every controller
|
|
423
|
+
# has the callback" would vouch for rows nobody inspected.
|
|
424
|
+
puts "No controller was proven ungated. (A routed path whose controller " \
|
|
425
|
+
"does not resolve is unclassified, not verified — see issue #43.)"
|
|
426
|
+
else
|
|
427
|
+
puts "Provably ungated — current_scope_check! is absent from these controllers' " \
|
|
428
|
+
"callback chains, so the gate never runs there:"
|
|
429
|
+
puts
|
|
430
|
+
rows.each { |controller, actions| puts " #{controller} (#{actions.join(', ')})" }
|
|
431
|
+
puts
|
|
432
|
+
puts "Ticking these in the role grid grants nothing until the gate runs. " \
|
|
433
|
+
"If a controller inherited a skip, re-assert before_action " \
|
|
434
|
+
":current_scope_check! on it; if it never had the gate, include " \
|
|
435
|
+
"CurrentScope::Guard."
|
|
436
|
+
if stripped_bypass
|
|
437
|
+
puts
|
|
438
|
+
puts "(#{bypass_action} omitted from the listing — break-glass stays LIVE " \
|
|
439
|
+
"even on an ungated controller; see the role grid's exempt note.)"
|
|
440
|
+
end
|
|
441
|
+
end
|
|
442
|
+
|
|
443
|
+
# The limit of the proof, stated even when nothing is listed (KTD-3): a
|
|
444
|
+
# conditional skip (skip_before_action only:/except:) leaves the callback
|
|
445
|
+
# PRESENT wearing a condition — unprovable by reflection, so never shown
|
|
446
|
+
# here even though some of its actions really run open. The runtime half
|
|
447
|
+
# catches those.
|
|
448
|
+
puts
|
|
449
|
+
puts "Limit: this lists only what the callback chain PROVES. A conditional skip " \
|
|
450
|
+
"(skip_before_action only:/except:) does not appear here — set " \
|
|
451
|
+
"config.gating_tripwire = :warn and include CurrentScope::GatingTripwire " \
|
|
452
|
+
"to inventory those at runtime."
|
|
453
|
+
end
|
|
454
|
+
end
|