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,132 @@
|
|
|
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
|
+
|
|
5
|
+
module CurrentScope
|
|
6
|
+
# Presents the route-derived permission catalog as an ALIGNED matrix for the
|
|
7
|
+
# role editor: fixed columns, one row per controller, blank cells where a
|
|
8
|
+
# controller doesn't route a column's actions (never a shifted cell).
|
|
9
|
+
#
|
|
10
|
+
# By default the columns are CRUD groups (config.permission_grid_groups):
|
|
11
|
+
# ticking one grants every routed action in the group, so the RESTful form
|
|
12
|
+
# actions fold into their mutation (new→create, edit→update) and index+show
|
|
13
|
+
# read as one. Actions outside any group (e.g. "approve") get their own
|
|
14
|
+
# column. With groups set to nil/{} every raw action becomes its own column —
|
|
15
|
+
# still aligned.
|
|
16
|
+
class PermissionGrid
|
|
17
|
+
Column = Struct.new(:label, :actions, :group, keyword_init: true)
|
|
18
|
+
Cell = Struct.new(:blank, :group, :name, :value, :checked, :partial, :granted_keys, keyword_init: true)
|
|
19
|
+
|
|
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)
|
|
28
|
+
@grouped = catalog.grouped # { "controller" => ["action", ...] }
|
|
29
|
+
@groups = groups || {}
|
|
30
|
+
@gating = gating
|
|
31
|
+
end
|
|
32
|
+
|
|
33
|
+
def controllers
|
|
34
|
+
@grouped.keys.sort
|
|
35
|
+
end
|
|
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
|
+
|
|
45
|
+
# Is this row's controller class missing (stale/typo route)? Advisory only
|
|
46
|
+
# — badge the row; do not drop the catalog key (the catalog is a route
|
|
47
|
+
# mirror). (#43)
|
|
48
|
+
def missing_controller?(controller)
|
|
49
|
+
@gating.missing_controller?(controller)
|
|
50
|
+
end
|
|
51
|
+
|
|
52
|
+
# Declared skip reason from current_scope_skip_gate!, or nil (#76).
|
|
53
|
+
def declared_skip_reason(controller)
|
|
54
|
+
@gating.declared_skip_reason(controller)
|
|
55
|
+
end
|
|
56
|
+
|
|
57
|
+
# Ordered columns: config groups that apply to at least one controller (in
|
|
58
|
+
# config order), then leftover actions not covered by any group (sorted).
|
|
59
|
+
def columns
|
|
60
|
+
grouped = @groups.filter_map do |label, actions|
|
|
61
|
+
Column.new(label: label, actions: actions, group: true) if any_controller_has?(actions)
|
|
62
|
+
end
|
|
63
|
+
grouped + leftover_actions.map { |action| Column.new(label: action, actions: [ action ], group: false) }
|
|
64
|
+
end
|
|
65
|
+
|
|
66
|
+
# One cell for (controller, column) against a role's granted key set.
|
|
67
|
+
# Blank when the controller routes none of the column's actions. Otherwise a
|
|
68
|
+
# checkbox.
|
|
69
|
+
#
|
|
70
|
+
# A GROUP cell is only `checked` when EVERY routed action is granted — a
|
|
71
|
+
# partial group is rendered unchecked+indeterminate and its existing keys are
|
|
72
|
+
# preserved verbatim via hidden inputs (see the edit view). This is the
|
|
73
|
+
# escalation guard: a checked group token expands to the whole group on save,
|
|
74
|
+
# so treating "some granted" as checked would silently promote a partial
|
|
75
|
+
# grant to a full one just by re-saving the role. `granted_keys` carries the
|
|
76
|
+
# exact subset to round-trip for a partial cell.
|
|
77
|
+
def cell(controller, column, granted)
|
|
78
|
+
routed = column.actions & actions_for(controller)
|
|
79
|
+
return Cell.new(blank: true) if routed.empty?
|
|
80
|
+
|
|
81
|
+
keys = routed.map { |action| "#{controller}##{action}" }
|
|
82
|
+
present_keys = keys.select { |key| granted.include?(key) }
|
|
83
|
+
partial = present_keys.any? && present_keys.size < keys.size
|
|
84
|
+
Cell.new(
|
|
85
|
+
blank: false,
|
|
86
|
+
group: column.group,
|
|
87
|
+
name: column.group ? "role[permission_groups][]" : "role[permission_keys][]",
|
|
88
|
+
value: column.group ? "#{controller}:#{column.label}" : keys.first,
|
|
89
|
+
checked: column.group ? (present_keys.any? && !partial) : present_keys.any?,
|
|
90
|
+
partial: partial,
|
|
91
|
+
granted_keys: partial ? present_keys : []
|
|
92
|
+
)
|
|
93
|
+
end
|
|
94
|
+
|
|
95
|
+
# Expand submitted "controller:group" tokens into routed permission keys.
|
|
96
|
+
#
|
|
97
|
+
# A token the grid could not have produced (unknown group, unknown
|
|
98
|
+
# controller, nothing routed) passes through RAW so the model's catalog
|
|
99
|
+
# validation rejects it BY NAME — the same loud contract a hand-crafted
|
|
100
|
+
# permission_keys[] entry gets. One save, one error story: the grid's two
|
|
101
|
+
# submission channels must not differ on whether a crafted request is
|
|
102
|
+
# reported or silently swallowed. (The form's blank hidden padding is the
|
|
103
|
+
# one legitimate non-grid value; it alone drops out.)
|
|
104
|
+
def expand(tokens)
|
|
105
|
+
Array(tokens).flat_map do |token|
|
|
106
|
+
next [] if token.blank?
|
|
107
|
+
|
|
108
|
+
controller, label = token.to_s.split(":", 2)
|
|
109
|
+
actions = @groups[label]
|
|
110
|
+
routed = actions ? (actions & actions_for(controller)) : []
|
|
111
|
+
next [ token.to_s ] if routed.empty?
|
|
112
|
+
|
|
113
|
+
routed.map { |action| "#{controller}##{action}" }
|
|
114
|
+
end
|
|
115
|
+
end
|
|
116
|
+
|
|
117
|
+
def actions_for(controller)
|
|
118
|
+
@grouped[controller] || []
|
|
119
|
+
end
|
|
120
|
+
|
|
121
|
+
private
|
|
122
|
+
|
|
123
|
+
def any_controller_has?(actions)
|
|
124
|
+
@grouped.values.any? { |routed| routed.intersect?(actions) }
|
|
125
|
+
end
|
|
126
|
+
|
|
127
|
+
def leftover_actions
|
|
128
|
+
grouped = @groups.values.flatten.uniq
|
|
129
|
+
(@grouped.values.flatten.uniq - grouped).sort
|
|
130
|
+
end
|
|
131
|
+
end
|
|
132
|
+
end
|
|
@@ -0,0 +1,94 @@
|
|
|
1
|
+
module CurrentScope
|
|
2
|
+
# The portable authorization mixin. Works anywhere — controllers, views,
|
|
3
|
+
# components, POROs — because the subject comes from the ambient
|
|
4
|
+
# CurrentScope::Current context rather than being threaded through calls.
|
|
5
|
+
# Everything delegates to the one resolver, so a view can never disagree
|
|
6
|
+
# with the controller gate.
|
|
7
|
+
#
|
|
8
|
+
# allowed_to?(:approve, report) # key derived from the record
|
|
9
|
+
# allowed_to?(:create, Report) # class works for collection actions
|
|
10
|
+
# allowed_to?("admin/reports#approve") # explicit full key
|
|
11
|
+
# allowed_to?(:index, controller: "reports")
|
|
12
|
+
module Permissions
|
|
13
|
+
def allowed_to?(action, record = nil, controller: nil)
|
|
14
|
+
controller ||= controller_path if respond_to?(:controller_path)
|
|
15
|
+
CurrentScope.allowed?(action, subject: current_scope_user, record: record,
|
|
16
|
+
controller_path: controller, actor: current_scope_actor,
|
|
17
|
+
model: ambient_collection_model(action, controller))
|
|
18
|
+
end
|
|
19
|
+
|
|
20
|
+
# The list-side companion to allowed_to?: "which records of `model` may 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.
|
|
38
|
+
#
|
|
39
|
+
# scope_for(Project) # projects#index — what a list shows
|
|
40
|
+
# scope_for(Report, permission: :approve)
|
|
41
|
+
# scope_for(Report, permission: "admin/reports#approve")
|
|
42
|
+
def scope_for(model, permission: nil)
|
|
43
|
+
# Derive the key exactly like allowed_to? — including controller_path, so a
|
|
44
|
+
# namespaced controller's list resolves to the same key as its gate
|
|
45
|
+
# (admin/reports#index, not reports#index) and the two never drift.
|
|
46
|
+
controller = controller_path if respond_to?(:controller_path)
|
|
47
|
+
CurrentScope.scope_for(
|
|
48
|
+
subject: current_scope_user,
|
|
49
|
+
model: model,
|
|
50
|
+
permission: CurrentScope.permission_key(permission || :index, record: model, controller_path: controller)
|
|
51
|
+
)
|
|
52
|
+
end
|
|
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
|
+
def current_scope_user
|
|
64
|
+
CurrentScope::Current.user
|
|
65
|
+
end
|
|
66
|
+
|
|
67
|
+
# The REAL actor behind the request (never nil when a subject is set — it
|
|
68
|
+
# falls back to the subject). Read this for attribution, not Current.
|
|
69
|
+
def current_scope_actor
|
|
70
|
+
CurrentScope::Current.actor
|
|
71
|
+
end
|
|
72
|
+
|
|
73
|
+
# True only while a distinct real actor stands behind the effective
|
|
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.
|
|
76
|
+
def impersonating?
|
|
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
|
|
92
|
+
end
|
|
93
|
+
end
|
|
94
|
+
end
|