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,3 +1,8 @@
|
|
|
1
|
+
# Enumerable#to_set (key_set, the routed-key cache) is stdlib `set`, which a
|
|
2
|
+
# full Rails boot happens to load — a host on active_support.bare would
|
|
3
|
+
# NoMethodError at catalog construction without the explicit require.
|
|
4
|
+
require "set"
|
|
5
|
+
|
|
1
6
|
module CurrentScope
|
|
2
7
|
# Derives the permission set from the host's routes: one permission per
|
|
3
8
|
# controller#action pair. There is no table to maintain — add a controller
|
|
@@ -13,21 +18,129 @@ module CurrentScope
|
|
|
13
18
|
.transform_values { |ks| ks.map { |k| k.split("#").last } }
|
|
14
19
|
end
|
|
15
20
|
|
|
21
|
+
# Hot: the Guard asks this on EVERY gated request, and a role save asks it
|
|
22
|
+
# once per staged key. Set lookup, not Array#include? — the array is sorted
|
|
23
|
+
# for display, and scanning it linearly made every request pay for the size
|
|
24
|
+
# of the host's route table. Memoized alongside `keys` and dropped with it
|
|
25
|
+
# on reset!.
|
|
16
26
|
def include?(key)
|
|
17
|
-
|
|
27
|
+
key_set.include?(key)
|
|
28
|
+
end
|
|
29
|
+
|
|
30
|
+
# The ONE parse of config.sod_bypass_permission's action segment — the grid
|
|
31
|
+
# view and the ungated task read it here rather than re-splitting the
|
|
32
|
+
# string themselves (a looser split("#").last silently turns a malformed
|
|
33
|
+
# "reports#" into "reports" and mislabels the break-glass cell; #79 review).
|
|
34
|
+
#
|
|
35
|
+
# `split("#", -1)` keeps the trailing empty field, so a malformed "reports#"
|
|
36
|
+
# yields "" and is caught here rather than silently becoming "reports" and
|
|
37
|
+
# injecting "reports#reports". Blank raises instead of skipping: the host
|
|
38
|
+
# turned break-glass ON, so a permission nobody can hold means the veto can
|
|
39
|
+
# never be lifted and the feature is inert — an undiagnosable deny, which is
|
|
40
|
+
# exactly what this engine promises not to do. (A boot-time check for this
|
|
41
|
+
# config belongs with #40.)
|
|
42
|
+
def bypass_action
|
|
43
|
+
segments = CurrentScope.config.sod_bypass_permission.to_s.split("#", -1)
|
|
44
|
+
# Exactly a bare action or one controller#action. More hashes would pass
|
|
45
|
+
# a last-segment check while the resolver reads the ORIGINAL full string —
|
|
46
|
+
# the catalog would inject a key nobody can be granted under, and the
|
|
47
|
+
# veto could never be lifted. (#79 review)
|
|
48
|
+
if segments.empty? || segments.size > 2 || segments.any?(&:blank?)
|
|
49
|
+
raise ConfigurationError,
|
|
50
|
+
"config.allow_sod_bypass is on, but config.sod_bypass_permission " \
|
|
51
|
+
"(#{CurrentScope.config.sod_bypass_permission.inspect}) is not a bare action or a " \
|
|
52
|
+
"single controller#action. Name the permission the record's initiator must hold " \
|
|
53
|
+
"to break glass (the default is \"bypass_sod\"), or set config.allow_sod_bypass = false."
|
|
54
|
+
end
|
|
55
|
+
|
|
56
|
+
segments.last
|
|
57
|
+
end
|
|
58
|
+
|
|
59
|
+
# Is this key derived from a real route (as opposed to the injected
|
|
60
|
+
# break-glass key)? The distinction matters to anything making an
|
|
61
|
+
# inertness claim: a ROUTED action named like the bypass permission is an
|
|
62
|
+
# ordinary action and gets no exemption, while the injected key is live on
|
|
63
|
+
# any row. (#79 review)
|
|
64
|
+
def routed?(key)
|
|
65
|
+
keys # derive memoizes @routed_key_set as a side effect
|
|
66
|
+
@routed_key_set.include?(key)
|
|
18
67
|
end
|
|
19
68
|
|
|
20
69
|
private
|
|
21
70
|
|
|
71
|
+
def key_set
|
|
72
|
+
@key_set ||= keys.to_set
|
|
73
|
+
end
|
|
74
|
+
|
|
22
75
|
def derive
|
|
23
|
-
Rails.application.routes.routes.filter_map { |route|
|
|
76
|
+
routed = Rails.application.routes.routes.filter_map { |route|
|
|
24
77
|
controller = route.defaults[:controller]
|
|
25
78
|
action = route.defaults[:action]
|
|
26
79
|
next unless controller && action
|
|
27
80
|
next if CurrentScope.config.excluded_controllers.any? { |re| controller.match?(re) }
|
|
28
81
|
|
|
29
82
|
"#{controller}##{action}"
|
|
30
|
-
}.uniq
|
|
83
|
+
}.uniq
|
|
84
|
+
|
|
85
|
+
@routed_key_set = routed.to_set
|
|
86
|
+
(routed + bypass_keys(routed)).uniq.sort
|
|
31
87
|
end
|
|
88
|
+
|
|
89
|
+
# Break-glass is the one permission that isn't an action you can route: it
|
|
90
|
+
# gates the SoD veto rather than a request. So a purely route-derived catalog
|
|
91
|
+
# can never contain it — which left the shipped "grantable, editable in the
|
|
92
|
+
# role grid" claim false, and break-glass reachable only through full_access
|
|
93
|
+
# (defeating the point of a *scoped* trusted-approver role) or a console
|
|
94
|
+
# insert. The catalog is the single definition of what is grantable — the
|
|
95
|
+
# grid reads `grouped`, the role setter and the Guard read `include?` — so
|
|
96
|
+
# injecting the virtual key here makes the cell render AND the save stick,
|
|
97
|
+
# with no special case in either. (#21)
|
|
98
|
+
#
|
|
99
|
+
# Emitted only where it could mean something: break-glass on, and a
|
|
100
|
+
# controller that actually routes an SoD action. Off by default, so the
|
|
101
|
+
# catalog is byte-for-byte the routed set unless a host opts in.
|
|
102
|
+
#
|
|
103
|
+
# Route- and config-derived only — deliberately no model introspection. The
|
|
104
|
+
# precise set would be "controllers whose SoD-gated model defines an
|
|
105
|
+
# initiator", but discovering that means loading application models, which
|
|
106
|
+
# is expensive, boot-order fragile, and against the catalog's whole design.
|
|
107
|
+
#
|
|
108
|
+
# The key is built from the controller's LAST path segment, because that is
|
|
109
|
+
# what the resolver will ask for: it derives the bypass key from the
|
|
110
|
+
# RECORD's route_key (permission_key → record.model_name.route_key), never
|
|
111
|
+
# from the controller path. Under Rails' resource conventions those agree —
|
|
112
|
+
# Admin::ReportsController's last segment "reports" IS Report's route_key —
|
|
113
|
+
# so keying off the whole path would inject "admin/reports#bypass_sod" while
|
|
114
|
+
# the resolver looks up "reports#bypass_sod", leaving break-glass ungrantable
|
|
115
|
+
# for every namespaced SoD controller and handing the admin a cell that
|
|
116
|
+
# silently does nothing. Namespaced admin controllers are common enough that
|
|
117
|
+
# this is the difference between the fix working and not.
|
|
118
|
+
#
|
|
119
|
+
# A namespace-only resource therefore gets its bypass cell on a "reports"
|
|
120
|
+
# row that no controller routes — the grid renders it aligned, blank
|
|
121
|
+
# everywhere else. Slightly odd to look at, and correct: it is the key the
|
|
122
|
+
# resolver actually reads.
|
|
123
|
+
#
|
|
124
|
+
# The irreducible limit: a controller named differently from the records it
|
|
125
|
+
# acts on (an `approvals` controller approving `Invoice`s) still injects
|
|
126
|
+
# `approvals#bypass_sod` while the live key is `invoices#bypass_sod`.
|
|
127
|
+
# Closing that needs to know the SoD-gated model, i.e. introspection.
|
|
128
|
+
# Tracked at OQ-2.
|
|
129
|
+
def bypass_keys(routed)
|
|
130
|
+
return [] unless CurrentScope.config.allow_sod_bypass
|
|
131
|
+
|
|
132
|
+
sod_actions = CurrentScope.config.sod_actions
|
|
133
|
+
return [] if sod_actions.empty?
|
|
134
|
+
|
|
135
|
+
routed.group_by { |key| key.split("#").first }
|
|
136
|
+
.filter_map { |controller, keys|
|
|
137
|
+
actions = keys.map { |k| k.split("#").last }
|
|
138
|
+
"#{controller.split('/').last}##{bypass_action}" if actions.intersect?(sod_actions)
|
|
139
|
+
}
|
|
140
|
+
end
|
|
141
|
+
|
|
142
|
+
# The action segment of config.sod_bypass_permission — tolerating either a
|
|
143
|
+
# bare action ("bypass_sod") or a full key ("reports#bypass_sod").
|
|
144
|
+
#
|
|
32
145
|
end
|
|
33
146
|
end
|
|
@@ -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,26 @@ 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
|
+
#
|
|
63
|
+
# The match keys on the KEY's controller, not just the controller: kwarg: a
|
|
64
|
+
# full "reports#index" key from a projects view names "reports" and must NOT
|
|
65
|
+
# borrow the projects ambient (a Project grant answering a reports key). A
|
|
66
|
+
# bare action uses the resolved controller. (#50 review, cubic)
|
|
67
|
+
def ambient_collection_model(action, controller)
|
|
68
|
+
key_controller = action.to_s.include?("#") ? action.to_s.split("#").first : controller
|
|
69
|
+
return nil unless key_controller && key_controller == CurrentScope::Current.collection_model_path
|
|
70
|
+
|
|
71
|
+
CurrentScope::Current.collection_model
|
|
72
|
+
end
|
|
73
|
+
|
|
40
74
|
def current_scope_user
|
|
41
75
|
CurrentScope::Current.user
|
|
42
76
|
end
|
|
@@ -48,10 +82,10 @@ module CurrentScope
|
|
|
48
82
|
end
|
|
49
83
|
|
|
50
84
|
# True only while a distinct real actor stands behind the effective
|
|
51
|
-
# subject (act-as). Views use it as the read-only-state signal.
|
|
85
|
+
# subject (act-as). Views use it as the read-only-state signal. Delegates
|
|
86
|
+
# to the one definition on Current, shared with the mutation guard.
|
|
52
87
|
def impersonating?
|
|
53
|
-
CurrentScope::Current.
|
|
54
|
-
CurrentScope::Current.actor != CurrentScope::Current.user
|
|
88
|
+
CurrentScope::Current.impersonating?
|
|
55
89
|
end
|
|
56
90
|
end
|
|
57
91
|
end
|