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
|
@@ -1,3 +1,7 @@
|
|
|
1
|
+
# The gating: default below constructs one at call time — a caller requiring
|
|
2
|
+
# this file directly (not via the current_scope entrypoint) must not NameError.
|
|
3
|
+
require "current_scope/gating_reflection"
|
|
4
|
+
|
|
1
5
|
module CurrentScope
|
|
2
6
|
# Presents the route-derived permission catalog as an ALIGNED matrix for the
|
|
3
7
|
# role editor: fixed columns, one row per controller, blank cells where a
|
|
@@ -13,15 +17,31 @@ module CurrentScope
|
|
|
13
17
|
Column = Struct.new(:label, :actions, :group, keyword_init: true)
|
|
14
18
|
Cell = Struct.new(:blank, :group, :name, :value, :checked, :partial, :granted_keys, keyword_init: true)
|
|
15
19
|
|
|
16
|
-
|
|
20
|
+
# The gating default is evaluated at CALL time, so every bare
|
|
21
|
+
# PermissionGrid.new (the edit view AND role_params on every role save)
|
|
22
|
+
# constructs a GatingReflection. That is fine only because its constructor
|
|
23
|
+
# is inert by contract — all reflection work happens inside #ungated?, and
|
|
24
|
+
# nothing here calls it during initialize or #expand (KTD-8; pinned by the
|
|
25
|
+
# spy test).
|
|
26
|
+
def initialize(catalog: CurrentScope.catalog, groups: CurrentScope.config.permission_grid_groups,
|
|
27
|
+
gating: GatingReflection.new)
|
|
17
28
|
@grouped = catalog.grouped # { "controller" => ["action", ...] }
|
|
18
29
|
@groups = groups || {}
|
|
30
|
+
@gating = gating
|
|
19
31
|
end
|
|
20
32
|
|
|
21
33
|
def controllers
|
|
22
34
|
@grouped.keys.sort
|
|
23
35
|
end
|
|
24
36
|
|
|
37
|
+
# Is this row's controller provably never gated? Advisory only — a pure
|
|
38
|
+
# delegation the view reads to annotate the row; no other grid method
|
|
39
|
+
# consults the reflection, so the answer cannot affect a cell or an
|
|
40
|
+
# expansion (pinned byte-identical in the tests).
|
|
41
|
+
def ungated?(controller)
|
|
42
|
+
@gating.ungated?(controller)
|
|
43
|
+
end
|
|
44
|
+
|
|
25
45
|
# Ordered columns: config groups that apply to at least one controller (in
|
|
26
46
|
# config order), then leftover actions not covered by any group (sorted).
|
|
27
47
|
def columns
|
|
@@ -61,14 +81,24 @@ module CurrentScope
|
|
|
61
81
|
end
|
|
62
82
|
|
|
63
83
|
# Expand submitted "controller:group" tokens into routed permission keys.
|
|
64
|
-
#
|
|
84
|
+
#
|
|
85
|
+
# A token the grid could not have produced (unknown group, unknown
|
|
86
|
+
# controller, nothing routed) passes through RAW so the model's catalog
|
|
87
|
+
# validation rejects it BY NAME — the same loud contract a hand-crafted
|
|
88
|
+
# permission_keys[] entry gets. One save, one error story: the grid's two
|
|
89
|
+
# submission channels must not differ on whether a crafted request is
|
|
90
|
+
# reported or silently swallowed. (The form's blank hidden padding is the
|
|
91
|
+
# one legitimate non-grid value; it alone drops out.)
|
|
65
92
|
def expand(tokens)
|
|
66
93
|
Array(tokens).flat_map do |token|
|
|
94
|
+
next [] if token.blank?
|
|
95
|
+
|
|
67
96
|
controller, label = token.to_s.split(":", 2)
|
|
68
97
|
actions = @groups[label]
|
|
69
|
-
|
|
98
|
+
routed = actions ? (actions & actions_for(controller)) : []
|
|
99
|
+
next [ token.to_s ] if routed.empty?
|
|
70
100
|
|
|
71
|
-
|
|
101
|
+
routed.map { |action| "#{controller}##{action}" }
|
|
72
102
|
end
|
|
73
103
|
end
|
|
74
104
|
|
|
@@ -13,14 +13,28 @@ module CurrentScope
|
|
|
13
13
|
def allowed_to?(action, record = nil, controller: nil)
|
|
14
14
|
controller ||= controller_path if respond_to?(:controller_path)
|
|
15
15
|
CurrentScope.allowed?(action, subject: current_scope_user, record: record,
|
|
16
|
-
controller_path: controller, actor: current_scope_actor
|
|
16
|
+
controller_path: controller, actor: current_scope_actor,
|
|
17
|
+
model: ambient_collection_model(action, controller))
|
|
17
18
|
end
|
|
18
19
|
|
|
19
20
|
# The list-side companion to allowed_to?: "which records of `model` may the
|
|
20
|
-
# effective subject act on?". Same grants
|
|
21
|
-
#
|
|
22
|
-
#
|
|
23
|
-
# the
|
|
21
|
+
# effective subject act on?". Same grants and keys as the gate, resolved
|
|
22
|
+
# fail-closed (nil subject / no grant → none) — but scope_for answers ROW
|
|
23
|
+
# MEMBERSHIP only, never action reachability. Gate checks that sit on top
|
|
24
|
+
# of the grant do not filter this list:
|
|
25
|
+
# - the separation-of-duties veto — for an SoD-listed action the list CAN
|
|
26
|
+
# include the subject's own initiated records, which the per-record
|
|
27
|
+
# gate then refuses;
|
|
28
|
+
# - the impersonation mutation gate — a REQUEST-level guard, not a
|
|
29
|
+
# per-record one: it blocks any non-GET/HEAD request while
|
|
30
|
+
# impersonating, collection actions included;
|
|
31
|
+
# - record-less gate paths (a hookless controller's NO_RECORD decision).
|
|
32
|
+
# So a listed row can still 403 when acted on. Per-row affordances for
|
|
33
|
+
# SoD-listed actions must check allowed_to?(action, record); mutation
|
|
34
|
+
# affordances while impersonating should key off impersonating?.
|
|
35
|
+
# Returns a chainable relation (.where/.order/.page on it). `permission`
|
|
36
|
+
# defaults to the model's index context and accepts a bare action or a
|
|
37
|
+
# full key.
|
|
24
38
|
#
|
|
25
39
|
# scope_for(Project) # projects#index — what a list shows
|
|
26
40
|
# scope_for(Report, permission: :approve)
|
|
@@ -37,6 +51,15 @@ module CurrentScope
|
|
|
37
51
|
)
|
|
38
52
|
end
|
|
39
53
|
|
|
54
|
+
# The type the controller handling THIS request declared for its collection
|
|
55
|
+
# actions (#50), so a bare allowed_to?(:index) in its own view binds the
|
|
56
|
+
# record-less gate the same way the gate did — otherwise the fix would hide
|
|
57
|
+
# a link the gate allows. Only for the request's OWN controller: a
|
|
58
|
+
# cross-controller question resolves a key about a different controller than
|
|
59
|
+
# the ambient type answers, so it gets nil and falls to the fail-closed
|
|
60
|
+
# default — the class form allowed_to?(:index, Report) is how you ask about
|
|
61
|
+
# another controller, and it binds from its argument (R5). (KTD-6)
|
|
62
|
+
#
|
|
40
63
|
def current_scope_user
|
|
41
64
|
CurrentScope::Current.user
|
|
42
65
|
end
|
|
@@ -48,10 +71,24 @@ module CurrentScope
|
|
|
48
71
|
end
|
|
49
72
|
|
|
50
73
|
# True only while a distinct real actor stands behind the effective
|
|
51
|
-
# subject (act-as). Views use it as the read-only-state signal.
|
|
74
|
+
# subject (act-as). Views use it as the read-only-state signal. Delegates
|
|
75
|
+
# to the one definition on Current, shared with the mutation guard.
|
|
52
76
|
def impersonating?
|
|
53
|
-
CurrentScope::Current.
|
|
54
|
-
|
|
77
|
+
CurrentScope::Current.impersonating?
|
|
78
|
+
end
|
|
79
|
+
|
|
80
|
+
private
|
|
81
|
+
|
|
82
|
+
# Internal binding for gate/view agreement (#50 KTD-6) — not host API.
|
|
83
|
+
# Private so it does not appear in controller action_methods. Matches on
|
|
84
|
+
# the KEY's controller, not just the controller: kwarg: a full
|
|
85
|
+
# "reports#index" key from a projects view must not borrow the projects
|
|
86
|
+
# ambient. A bare action uses the resolved controller. (#50 review, cubic)
|
|
87
|
+
def ambient_collection_model(action, controller)
|
|
88
|
+
key_controller = action.to_s.include?("#") ? action.to_s.split("#").first : controller
|
|
89
|
+
return nil unless key_controller && key_controller == CurrentScope::Current.collection_model_path
|
|
90
|
+
|
|
91
|
+
CurrentScope::Current.collection_model
|
|
55
92
|
end
|
|
56
93
|
end
|
|
57
94
|
end
|
|
@@ -10,7 +10,15 @@ module CurrentScope
|
|
|
10
10
|
# present and future.
|
|
11
11
|
# 3. org-wide role — the role's permission set includes this permission.
|
|
12
12
|
# 4. scoped role — a role held on THIS record grants the permission.
|
|
13
|
-
# 5.
|
|
13
|
+
# 5. scoped role, — the target is record-less (nil for a collection action,
|
|
14
|
+
# record-less a Class for a class-form check), so no specific record
|
|
15
|
+
# can be named. An action in config.collection_read_actions
|
|
16
|
+
# asks scope_for — the id-narrowed list query — so the gate
|
|
17
|
+
# opens exactly when the list would show records
|
|
18
|
+
# (full_access included, #65); any other action needs a
|
|
19
|
+
# scoped grant EXPLICITLY ticking the key. scope_for then
|
|
20
|
+
# narrows the list to those records.
|
|
21
|
+
# 6. default-deny — nothing granted means denied.
|
|
14
22
|
class Resolver
|
|
15
23
|
INITIATOR_METHOD = :current_scope_initiator
|
|
16
24
|
# Host-defined per-record opt-in for break-glass. Absent ⇒ never bypassed
|
|
@@ -21,8 +29,8 @@ module CurrentScope
|
|
|
21
29
|
# Public contract: boolean. `actor` is the REAL principal behind the
|
|
22
30
|
# request (defaults to the subject — no impersonation); it only widens the
|
|
23
31
|
# SoD veto under config.sod_identity == :either.
|
|
24
|
-
def allow?(subject:, permission:, record: nil, actor: nil)
|
|
25
|
-
decide(subject: subject, permission: permission, record: record, actor: actor).first
|
|
32
|
+
def allow?(subject:, permission:, record: nil, actor: nil, model: nil)
|
|
33
|
+
decide(subject: subject, permission: permission, record: record, actor: actor, model: model).first
|
|
26
34
|
end
|
|
27
35
|
|
|
28
36
|
# Internal decision: returns [allowed_bool, reason_or_nil]. The reason is a
|
|
@@ -30,7 +38,13 @@ module CurrentScope
|
|
|
30
38
|
# denial, and :sod_bypassed on the one AUDITED allow (break-glass). Ordinary
|
|
31
39
|
# allows carry nil. The resolver — shared across threads — holds no
|
|
32
40
|
# per-decision state; the reason rides in the return tuple, not on self.
|
|
33
|
-
|
|
41
|
+
#
|
|
42
|
+
# `model:` (#50) is the type the controller's current_scope_model hook
|
|
43
|
+
# declared for its collection actions, or nil when unknown. Only the
|
|
44
|
+
# record-less branch reads it, to bind that otherwise type-unbound grant
|
|
45
|
+
# check; every other branch ignores it. It stays a parameter, never state,
|
|
46
|
+
# per the purity rule above.
|
|
47
|
+
def decide(subject:, permission:, record: nil, actor: nil, model: nil)
|
|
34
48
|
return [ false, :no_grant ] if subject.nil?
|
|
35
49
|
|
|
36
50
|
case sod_decision(subject: subject, actor: actor, permission: permission, record: record)
|
|
@@ -42,6 +56,21 @@ module CurrentScope
|
|
|
42
56
|
return [ true, nil ] if role&.full_access?
|
|
43
57
|
return [ true, nil ] if role&.grants?(permission)
|
|
44
58
|
return [ true, nil ] if scoped_grant?(subject: subject, permission: permission, record: record)
|
|
59
|
+
return [ true, nil ] if record_less_scoped_grant?(subject: subject, permission: permission, record: record, model: model)
|
|
60
|
+
|
|
61
|
+
# A LABEL, not a decision (R7a): this deny would have been an ALLOW had
|
|
62
|
+
# the controller declared current_scope_model and the grant ticked the
|
|
63
|
+
# key of that type. Without the distinct reason it is indistinguishable
|
|
64
|
+
# from an ordinary :no_grant, and the dev nudge that explains it is
|
|
65
|
+
# dev/test-only — so the production host who most needs the cause
|
|
66
|
+
# (403s after an upgrade) would be the one who cannot see it. Two labels
|
|
67
|
+
# for the two ways the declaration can be wrong: absent
|
|
68
|
+
# (:model_undeclared) vs present-but-unusable (:model_invalid — a
|
|
69
|
+
# String, PORO, or abstract class the shape guard refused). Same cell,
|
|
70
|
+
# different fix, so the label says which.
|
|
71
|
+
if record_less_denied_for_unknown_type?(subject: subject, permission: permission, record: record, model: model)
|
|
72
|
+
return [ false, model.nil? ? :model_undeclared : :model_invalid ]
|
|
73
|
+
end
|
|
45
74
|
|
|
46
75
|
[ false, :no_grant ]
|
|
47
76
|
end
|
|
@@ -65,6 +94,10 @@ module CurrentScope
|
|
|
65
94
|
# host list can never drift from the per-record decision. Fail-closed (nil
|
|
66
95
|
# subject / no grant → none) and flat — no parent/child cascade. SoD does
|
|
67
96
|
# NOT apply: it vetoes record-targeted actions, not list membership.
|
|
97
|
+
#
|
|
98
|
+
# Since #65 the record-less gate asks this same query (.exists?) for
|
|
99
|
+
# actions in config.collection_read_actions, so gate and list cannot
|
|
100
|
+
# drift for listed reads — they are one query.
|
|
68
101
|
def scope_for(subject:, model:, permission:)
|
|
69
102
|
return model.none if subject.nil?
|
|
70
103
|
|
|
@@ -85,14 +118,110 @@ module CurrentScope
|
|
|
85
118
|
)
|
|
86
119
|
end
|
|
87
120
|
|
|
121
|
+
# Whether the separation-of-duties veto is in a position to decide about this
|
|
122
|
+
# pair at all: the action must be SoD-listed AND there must be an actual
|
|
123
|
+
# record instance to name an initiator from. The veto is defined in terms of
|
|
124
|
+
# who raised a record, so with no record it has nothing to measure and is
|
|
125
|
+
# skipped — that covers a collection action's nil, a class-form check like
|
|
126
|
+
# allowed_to?(:approve, Report), and equally a host hook that handed back
|
|
127
|
+
# something that isn't a record at all (`params[:id]`, a String).
|
|
128
|
+
#
|
|
129
|
+
# PUBLIC because "the veto did not run" and "the veto passed" are different
|
|
130
|
+
# facts, and a caller that acts on the difference must be able to ASK rather
|
|
131
|
+
# than infer it. It is not inferable from the decision: a skipped veto falls
|
|
132
|
+
# through to the ordinary grant check, so it surfaces as :no_grant or an
|
|
133
|
+
# ordinary allow — never as anything that says "SoD abstained". Report mode
|
|
134
|
+
# (#37) has to know, because :no_grant is the one reason it downgrades.
|
|
135
|
+
#
|
|
136
|
+
# This is the single definition of the condition, deliberately: sod_decision
|
|
137
|
+
# reads it too. Two copies of "did the veto run" is two copies that drift,
|
|
138
|
+
# and the copy that drifts is the one guarding the fraud control. (#59 review)
|
|
139
|
+
def sod_veto_applies?(permission:, record:)
|
|
140
|
+
sod_action?(permission) && record.respond_to?(:new_record?)
|
|
141
|
+
end
|
|
142
|
+
|
|
143
|
+
# The blind spot: an SoD-listed action whose veto could NOT run. The decision
|
|
144
|
+
# that comes back for one of these is :no_grant or an ordinary allow — the
|
|
145
|
+
# veto contributed nothing to it. Anyone treating that :no_grant as "SoD was
|
|
146
|
+
# fine with this" is reading a silence as an answer.
|
|
147
|
+
#
|
|
148
|
+
# The same hazard the record-less scoped branch already refuses (see
|
|
149
|
+
# sod_action? below) and that config.warn_on_nil_sod_record surfaces on the
|
|
150
|
+
# allow path: a host mis-gating a member SoD action. In :enforce this costs
|
|
151
|
+
# nothing — :no_grant is a 403 regardless, so the skipped veto never decides
|
|
152
|
+
# anything. Report mode is what makes it matter, because :no_grant is exactly
|
|
153
|
+
# what it downgrades. (#37, #59 review)
|
|
154
|
+
def sod_veto_skipped?(permission:, record:)
|
|
155
|
+
sod_action?(permission) && !sod_veto_applies?(permission: permission, record: record)
|
|
156
|
+
end
|
|
157
|
+
|
|
158
|
+
# Does this subject hold a scoped grant that satisfies `permission` on ANY
|
|
159
|
+
# record? Read-only, and deliberately unfiltered by resource — that absence
|
|
160
|
+
# IS the question: the grant exists, but the gate never got a record for it
|
|
161
|
+
# to apply to.
|
|
162
|
+
#
|
|
163
|
+
# DIAGNOSTICS ONLY (#41). It answers a COUNTERFACTUAL — "had the controller
|
|
164
|
+
# declared its record hook, would a scoped grant have allowed this?" — and
|
|
165
|
+
# must never decide anything. The record it would need in order to BE a
|
|
166
|
+
# decision is exactly what's missing; that's the bug it reports.
|
|
167
|
+
#
|
|
168
|
+
# Reads roles_granting (full_access ∪ ticking), and that is right BECAUSE
|
|
169
|
+
# the counterfactual binds to a record: with a real record, scoped_grant?
|
|
170
|
+
# honors a scoped full_access role, so an honest "would it have been
|
|
171
|
+
# allowed?" must honor it too. The same reuse in a branch that binds to NO
|
|
172
|
+
# record was #49's P0 escalation — one scoped grant passing every
|
|
173
|
+
# collection gate in the app. The binding is the entire difference, which is
|
|
174
|
+
# why this is a question and not a gate.
|
|
175
|
+
def scoped_grant_exists?(subject:, permission:)
|
|
176
|
+
return false if subject.nil?
|
|
177
|
+
|
|
178
|
+
ScopedRoleAssignment.where(subject: subject, role_id: roles_granting(permission)).exists?
|
|
179
|
+
end
|
|
180
|
+
|
|
88
181
|
private
|
|
89
182
|
|
|
90
183
|
# Role ids that satisfy `permission`: full_access (grants everything) or an
|
|
91
184
|
# explicit grant of the key. The one place "does this role grant it?" is
|
|
92
|
-
# expressed for scoped grants
|
|
185
|
+
# expressed for scoped grants. Including full_access is only safe while no
|
|
186
|
+
# caller turns an unbound match into a PERMIT. Four callers, each safe for
|
|
187
|
+
# its own reason: scoped_grant? binds `resource:` to the exact record;
|
|
188
|
+
# scope_for binds `resource_type:` and answers in record ids
|
|
189
|
+
# (.select(:resource_id)) for the caller to narrow, never a boolean permit
|
|
190
|
+
# — which is also why the record-less read gate (#65) is safe: it asks
|
|
191
|
+
# scope_for and takes .exists? of the id-narrowed relation, so its answer
|
|
192
|
+
# is still derived from the records the subject holds, not from the grant
|
|
193
|
+
# row alone; scoped_grant_exists? binds nothing and is therefore
|
|
194
|
+
# diagnostics-only — its own comment says why it must never decide;
|
|
195
|
+
# record_less_denied_for_unknown_type?'s listed-read arm (#65) is the same
|
|
196
|
+
# diagnostics-only shape — it labels a deny, it never decides. A new
|
|
197
|
+
# caller must fit one of those shapes or use roles_ticking. (#65 cites
|
|
198
|
+
# this comment as the safety condition; it is load-bearing, keep it true.)
|
|
93
199
|
def roles_granting(permission)
|
|
94
|
-
Role.where(full_access: true)
|
|
95
|
-
|
|
200
|
+
Role.where(full_access: true).or(Role.where(id: roles_ticking(permission)))
|
|
201
|
+
end
|
|
202
|
+
|
|
203
|
+
# Role ids that EXPLICITLY tick `permission` — full_access roles deliberately
|
|
204
|
+
# excluded, whether or not they also carry a RolePermission row. Only for the
|
|
205
|
+
# record-less branch, which binds the grant to no record at all: honoring
|
|
206
|
+
# full_access there would mean one scoped full_access grant on one record
|
|
207
|
+
# ("Owner of Report #7") opened EVERY record-less gate in the host app —
|
|
208
|
+
# every #index and #create on every controller — since a full_access role
|
|
209
|
+
# satisfies every key. That is the `resource:` bound scoped_grant? applies
|
|
210
|
+
# and the record-less branch cannot.
|
|
211
|
+
#
|
|
212
|
+
# The `where.not` is load-bearing, not belt-and-braces: a role can be
|
|
213
|
+
# full_access AND retain explicit rows (tick grid cells, then flip the
|
|
214
|
+
# full-access toggle), and matching on the leftover row alone would walk it
|
|
215
|
+
# straight back through the branch full_access is barred from.
|
|
216
|
+
#
|
|
217
|
+
# roles_granting's set is unchanged by this exclusion — it unions
|
|
218
|
+
# full_access back in, so full_access ∪ (ticking − full_access) == the same
|
|
219
|
+
# roles it always matched.
|
|
220
|
+
def roles_ticking(permission)
|
|
221
|
+
RolePermission
|
|
222
|
+
.where(permission_key: permission)
|
|
223
|
+
.where.not(role_id: Role.where(full_access: true).select(:id))
|
|
224
|
+
.select(:role_id)
|
|
96
225
|
end
|
|
97
226
|
|
|
98
227
|
# The separation-of-duties outcome for this decision: :none (no conflict, or
|
|
@@ -100,11 +229,7 @@ module CurrentScope
|
|
|
100
229
|
# the veto stands), or :bypass (a conflict exists but break-glass lifts it).
|
|
101
230
|
# Pure — reads only; the audit write for a :bypass happens at the Guard.
|
|
102
231
|
def sod_decision(subject:, actor:, permission:, record:)
|
|
103
|
-
|
|
104
|
-
return :none unless CurrentScope.config.sod_actions.include?(action)
|
|
105
|
-
# No veto without an actual record instance (collection actions get nil,
|
|
106
|
-
# class-form checks like allowed_to?(:approve, Report) get the class).
|
|
107
|
-
return :none unless record.respond_to?(:new_record?)
|
|
232
|
+
return :none unless sod_veto_applies?(permission: permission, record: record)
|
|
108
233
|
|
|
109
234
|
# SoD is a structural guarantee — "cannot determine the initiator" must
|
|
110
235
|
# never mean "permit". A record type where SoD genuinely doesn't apply
|
|
@@ -114,7 +239,7 @@ module CurrentScope
|
|
|
114
239
|
"#{record.class.name}##{INITIATOR_METHOD} is not defined, but " \
|
|
115
240
|
"\"#{permission}\" is a separation-of-duties action (config.sod_actions). " \
|
|
116
241
|
"Define #{INITIATOR_METHOD} on #{record.class.name} (return nil to exempt " \
|
|
117
|
-
"a record), or remove \"#{
|
|
242
|
+
"a record), or remove \"#{permission.split('#').last}\" from config.sod_actions."
|
|
118
243
|
end
|
|
119
244
|
|
|
120
245
|
initiator = record.send(INITIATOR_METHOD)
|
|
@@ -167,5 +292,184 @@ module CurrentScope
|
|
|
167
292
|
.where(subject: subject, resource: record, role_id: roles_granting(permission))
|
|
168
293
|
.exists?
|
|
169
294
|
end
|
|
295
|
+
|
|
296
|
+
# A record-less target is allowed in one of two ways (#19, #65):
|
|
297
|
+
#
|
|
298
|
+
# - an action in config.collection_read_actions asks scope_for — the
|
|
299
|
+
# id-narrowed query the list renders from — so the gate opens exactly
|
|
300
|
+
# when the subject's list would show records, full_access included.
|
|
301
|
+
# Gate and list agree by construction; an empty list (no grant, or a
|
|
302
|
+
# grant whose record was destroyed) is a deny, fail-closed.
|
|
303
|
+
# - any other action needs a scoped grant whose role EXPLICITLY ticks
|
|
304
|
+
# the key. scope_for then narrows the collection to the granted records.
|
|
305
|
+
#
|
|
306
|
+
# Without this, the two halves of the per-record feature contradict each
|
|
307
|
+
# other: the gate turns a scoped-only subject away from their index, and
|
|
308
|
+
# the org-wide grant that gets them past it makes scope_for return every
|
|
309
|
+
# record.
|
|
310
|
+
#
|
|
311
|
+
# "Record-less" is a CLOSED set of exactly two shapes, tested positively:
|
|
312
|
+
#
|
|
313
|
+
# nil — a collection action; the Guard's hook returns nil when there is
|
|
314
|
+
# no record (guard.rb), which is the documented contract.
|
|
315
|
+
# a Class — the class form, allowed_to?(:index, Report).
|
|
316
|
+
#
|
|
317
|
+
# Positive, never `unless record.respond_to?(:new_record?)`: a negative test
|
|
318
|
+
# admits an OPEN set, so a host whose current_scope_record wrongly returns
|
|
319
|
+
# params[:id] (a String) or any other non-record would land here and be
|
|
320
|
+
# ALLOWED on the strength of a grant held over some *other* record — a
|
|
321
|
+
# fail-open in a fail-closed engine, and a breach of this branch's own
|
|
322
|
+
# invariant that a grant on X must not act on Y. Anything that is not
|
|
323
|
+
# literally nil-or-a-Class is not a record-less target and gets no say here.
|
|
324
|
+
#
|
|
325
|
+
# Consequence, deliberate: an UNPERSISTED instance (Report.new) is not
|
|
326
|
+
# record-less by this test, and scoped_grant? needs persisted? — so it is
|
|
327
|
+
# denied, while the class form is allowed. That asymmetry is only reachable
|
|
328
|
+
# by gating a collection action with Model.new instead of the documented nil,
|
|
329
|
+
# and it fails CLOSED (a 403 the host sees immediately), which is the safe
|
|
330
|
+
# direction to be wrong in.
|
|
331
|
+
#
|
|
332
|
+
# Binds by TYPE (#50). The target names no record, but the controller can
|
|
333
|
+
# name the type its collection lists via current_scope_model (record when
|
|
334
|
+
# it is a Class — the allowed_to?(:index, Report) form — else the model:
|
|
335
|
+
# kwarg the Guard threads). resource_type filters the grant to that type,
|
|
336
|
+
# normalized through base_class exactly as scope_for does, so "a Report
|
|
337
|
+
# grant" cannot open a Documents gate. UNKNOWN type ⇒ this branch does not
|
|
338
|
+
# fire (fail-closed): before #50 an unbound grant of any type opened every
|
|
339
|
+
# record-less gate, and for a key with no list side (#create) that let a
|
|
340
|
+
# Report-scoped subject create Documents — a live escalation, not a
|
|
341
|
+
# cosmetic empty list. A loud, documented deny is the safe direction.
|
|
342
|
+
#
|
|
343
|
+
# full_access and the two arms (#65): the read arm honors a scoped
|
|
344
|
+
# full_access grant because scope_for's answer is DERIVED FROM RECORD IDS —
|
|
345
|
+
# which records the subject holds, joined against the live table — the one
|
|
346
|
+
# shape roles_granting's safety condition names as safe. The non-read arm
|
|
347
|
+
# answers with a boolean off a type-bound match, strictly weaker (the id is
|
|
348
|
+
# discarded), so full_access stays barred there: one scoped grant on one
|
|
349
|
+
# Report must not create Reports. "Owner of Report #7" means full access to
|
|
350
|
+
# Report #7 — and, since #65, to the lists that would show it. (#65 records
|
|
351
|
+
# in full why a type-bound boolean over roles_granting is never the answer;
|
|
352
|
+
# 029's R4 was withdrawn over exactly that.)
|
|
353
|
+
#
|
|
354
|
+
# TRUST NOTE: the declared type is trusted the way current_scope_record is.
|
|
355
|
+
# A wrong current_scope_model plus a scoped full_access grant of the
|
|
356
|
+
# declared type opens that controller's listed reads — review the
|
|
357
|
+
# declaration like the record hook. Before #65 that misconfiguration
|
|
358
|
+
# failed closed; the trade is deliberate and documented (README, #65 plan
|
|
359
|
+
# KTD-5).
|
|
360
|
+
#
|
|
361
|
+
# Deliberately NOT memoized, unlike org_role. The memo there caches one
|
|
362
|
+
# lookup keyed by subject, invalidated by RoleAssignment writes alone. This
|
|
363
|
+
# predicate's answer derives from three tables (scoped assignments, roles,
|
|
364
|
+
# role permissions) — four on the read arm, whose scope_for joins the model
|
|
365
|
+
# table itself — so a memo would need invalidation hooks on all of them,
|
|
366
|
+
# and a stale entry here is a stale ALLOW. Only record-less checks by
|
|
367
|
+
# scoped-only subjects reach this line (org and full_access short-circuit
|
|
368
|
+
# above), so the cost is one query — on the read arm an id-subquery EXISTS
|
|
369
|
+
# against the model table — on a handful of nav-level checks per page, not
|
|
370
|
+
# the per-row gate. Revisit if that ever shows up in a profile.
|
|
371
|
+
def record_less_scoped_grant?(subject:, permission:, record:, model: nil)
|
|
372
|
+
# R3a: the record-less shape test stays FIRST — a declared model never
|
|
373
|
+
# rescues a non-record-less target. A host whose current_scope_record
|
|
374
|
+
# wrongly returns params[:id] (a String) must still fail closed here, not
|
|
375
|
+
# be allowed off a grant held over some other record.
|
|
376
|
+
return false unless record.nil? || record.is_a?(Class)
|
|
377
|
+
return false if sod_action?(permission)
|
|
378
|
+
|
|
379
|
+
# The class form (allowed_to?(:index, Report)) carries the type as the
|
|
380
|
+
# record; otherwise it comes from the model: hook. Unknown ⇒ deny (R3).
|
|
381
|
+
type = record.is_a?(Class) ? record : model
|
|
382
|
+
return false if type.nil?
|
|
383
|
+
|
|
384
|
+
# Shape guard, mirroring R3a's on the record: a type that is not an
|
|
385
|
+
# ActiveRecord model class (a String from a mis-declared
|
|
386
|
+
# current_scope_model, a non-record-storing PORO passed to the class form
|
|
387
|
+
# — the gem's own Scopeable Gadget is one) cannot name a base_class to
|
|
388
|
+
# normalize by. Require an actual AR subclass, not just anything answering
|
|
389
|
+
# base_class — a class whose base_class returns nil would still crash on
|
|
390
|
+
# .name. Deny (fail-closed) rather than raise NoMethodError: a garbage
|
|
391
|
+
# type is not a grant, and denying also preserves the pre-#50 boolean the
|
|
392
|
+
# class form returned for a non-AR argument. This guard must stay ABOVE
|
|
393
|
+
# the read arm — scope_for calls type.base_class and type.where, so a
|
|
394
|
+
# non-AR type would 500 instead of denying. (#50 review, #65)
|
|
395
|
+
#
|
|
396
|
+
# ABSTRACT classes are excluded too (#65 review): ApplicationRecord
|
|
397
|
+
# passes `< ActiveRecord::Base` but has no table, so the read arm's
|
|
398
|
+
# scope_for would raise TableNotSpecified where the ticking arm's
|
|
399
|
+
# resource_type match simply found nothing. An abstract class stores no
|
|
400
|
+
# rows, so no scoped grant can name it — deny, don't 500.
|
|
401
|
+
return false unless collection_type?(type)
|
|
402
|
+
|
|
403
|
+
if collection_read_action?(permission)
|
|
404
|
+
scope_for(subject: subject, model: type, permission: permission).exists?
|
|
405
|
+
else
|
|
406
|
+
ScopedRoleAssignment
|
|
407
|
+
.where(subject: subject, resource_type: type.base_class.name, role_id: roles_ticking(permission))
|
|
408
|
+
.exists?
|
|
409
|
+
end
|
|
410
|
+
end
|
|
411
|
+
|
|
412
|
+
# A usable collection type: an actual concrete ActiveRecord subclass —
|
|
413
|
+
# the one shape whose base_class/where the two record-less arms may call.
|
|
414
|
+
# The full rationale lives on the shape guard in record_less_scoped_grant?
|
|
415
|
+
# above; this predicate exists so the diagnostic labeler below refuses the
|
|
416
|
+
# exact same shapes the gate refused, and cannot drift from it.
|
|
417
|
+
def collection_type?(type)
|
|
418
|
+
type.is_a?(Class) && type < ActiveRecord::Base && !type.abstract_class?
|
|
419
|
+
end
|
|
420
|
+
|
|
421
|
+
# Would a (correct) current_scope_model declaration have given this
|
|
422
|
+
# record-less deny a chance? True only for the exact cell the #50
|
|
423
|
+
# fail-closed default created: a DECLARED collection action (record nil —
|
|
424
|
+
# the class form always carries its type, so it never lands here), no
|
|
425
|
+
# USABLE declared model — absent, or present but refused by the shape
|
|
426
|
+
# guard (a String, PORO, or abstract class) — not an SoD action (those
|
|
427
|
+
# are refused whatever the type), and a scoped grant EXPLICITLY ticking
|
|
428
|
+
# the key exists. Pure — reads only; decide labels the deny
|
|
429
|
+
# :model_undeclared / :model_invalid off this, changing no allow/deny.
|
|
430
|
+
#
|
|
431
|
+
# The role set follows the arm the deny came from (#65): a listed read
|
|
432
|
+
# matches a full_access-INCLUSIVE set, because a declared type would honor
|
|
433
|
+
# full_access there through scope_for — the label stays honest by saying
|
|
434
|
+
# so. Off the read list the set stays roles_ticking: naming a full_access
|
|
435
|
+
# grant :model_undeclared on #create would send the host to a fix that
|
|
436
|
+
# fixes nothing. Diagnostics-only either way — this labels a deny, it
|
|
437
|
+
# never decides (the scoped_grant_exists? shape, named in roles_granting's
|
|
438
|
+
# safety comment) — and it runs without a model, so it cannot check record
|
|
439
|
+
# liveness: a grant on a destroyed record makes "declare the model and
|
|
440
|
+
# this allows" false, which is why the nudge says "may fix", never
|
|
441
|
+
# promises.
|
|
442
|
+
def record_less_denied_for_unknown_type?(subject:, permission:, record:, model:)
|
|
443
|
+
return false unless record.nil? && !collection_type?(model)
|
|
444
|
+
return false if sod_action?(permission)
|
|
445
|
+
|
|
446
|
+
roles = collection_read_action?(permission) ? roles_granting(permission) : roles_ticking(permission)
|
|
447
|
+
ScopedRoleAssignment.where(subject: subject, role_id: roles).exists?
|
|
448
|
+
end
|
|
449
|
+
|
|
450
|
+
# An SoD action is record-targeted BY DEFINITION — "the subject who
|
|
451
|
+
# initiated a record can never approve THAT record". So a record-less SoD
|
|
452
|
+
# check is a contradiction in terms: there is no record for the veto to
|
|
453
|
+
# measure, and `sod_decision` returns :none for exactly that reason. Opening
|
|
454
|
+
# such a gate off a scoped grant would let a host that mis-gates a member
|
|
455
|
+
# SoD action (current_scope_record returning nil on `reports#approve` —
|
|
456
|
+
# precisely what warn_on_nil_sod_record exists to catch) hand out the action
|
|
457
|
+
# with the four-eyes veto silently skipped. The veto is a structural
|
|
458
|
+
# guarantee, so it must not rest on an opt-in dev warning; deny instead.
|
|
459
|
+
#
|
|
460
|
+
# This does NOT close the older org-grant asymmetry — a nil record on an SoD
|
|
461
|
+
# action still passes for an org-wide grant, which is characterized and
|
|
462
|
+
# pinned in test/sod_nil_record_test.rb. It only stops the record-less scoped
|
|
463
|
+
# branch from widening that hole to scoped grants too.
|
|
464
|
+
def sod_action?(permission)
|
|
465
|
+
CurrentScope.config.sod_actions.include?(permission.split("#").last)
|
|
466
|
+
end
|
|
467
|
+
|
|
468
|
+
# An action whose record-less gate derives from the scoped list (#65).
|
|
469
|
+
# Matched like sod_action?: the action segment of the key against a config
|
|
470
|
+
# list (config.collection_read_actions normalizes to strings on write).
|
|
471
|
+
def collection_read_action?(permission)
|
|
472
|
+
CurrentScope.config.collection_read_actions.include?(permission.split("#").last)
|
|
473
|
+
end
|
|
170
474
|
end
|
|
171
475
|
end
|