current_scope 0.2.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 +577 -0
- data/Rakefile +6 -0
- data/app/assets/javascripts/current_scope/application.js +225 -0
- data/app/assets/stylesheets/current_scope/application.css +707 -0
- data/app/controllers/current_scope/application_controller.rb +54 -0
- data/app/controllers/current_scope/events_controller.rb +14 -0
- data/app/controllers/current_scope/role_assignments_controller.rb +104 -0
- data/app/controllers/current_scope/roles_controller.rb +135 -0
- data/app/controllers/current_scope/scoped_role_assignments_controller.rb +159 -0
- data/app/controllers/current_scope/subjects_controller.rb +57 -0
- data/app/helpers/current_scope/application_helper.rb +72 -0
- data/app/models/current_scope/application_record.rb +5 -0
- data/app/models/current_scope/current.rb +50 -0
- data/app/models/current_scope/event.rb +117 -0
- data/app/models/current_scope/role.rb +55 -0
- data/app/models/current_scope/role_assignment.rb +21 -0
- data/app/models/current_scope/role_permission.rb +9 -0
- data/app/models/current_scope/scoped_role_assignment.rb +14 -0
- data/app/views/current_scope/events/index.html.erb +41 -0
- data/app/views/current_scope/roles/edit.html.erb +86 -0
- data/app/views/current_scope/roles/index.html.erb +41 -0
- data/app/views/current_scope/roles/members.html.erb +96 -0
- data/app/views/current_scope/roles/new.html.erb +19 -0
- data/app/views/current_scope/scoped_role_assignments/new.html.erb +135 -0
- data/app/views/current_scope/subjects/index.html.erb +100 -0
- data/app/views/layouts/current_scope/application.html.erb +53 -0
- data/config/routes.rb +14 -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/lib/current_scope/configuration.rb +181 -0
- data/lib/current_scope/context.rb +36 -0
- data/lib/current_scope/engine.rb +14 -0
- data/lib/current_scope/gating_tripwire.rb +53 -0
- data/lib/current_scope/guard.rb +97 -0
- data/lib/current_scope/mutation_guard.rb +55 -0
- data/lib/current_scope/permission_catalog.rb +33 -0
- data/lib/current_scope/permission_grid.rb +90 -0
- data/lib/current_scope/permissions.rb +57 -0
- data/lib/current_scope/resolver.rb +171 -0
- data/lib/current_scope/scopeable.rb +38 -0
- data/lib/current_scope/test_helpers.rb +53 -0
- data/lib/current_scope/version.rb +3 -0
- data/lib/current_scope.rb +168 -0
- data/lib/generators/current_scope/install/install_generator.rb +33 -0
- data/lib/generators/current_scope/install/templates/initializer.rb +77 -0
- data/lib/tasks/current_scope_tasks.rake +15 -0
- metadata +113 -0
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
module CurrentScope
|
|
2
|
+
# The read-only-while-impersonating gate. A SEPARATE before_action from the
|
|
3
|
+
# permission check, on purpose: the engine's management UI does
|
|
4
|
+
# skip_before_action :current_scope_check!, and a gate folded into that check
|
|
5
|
+
# would exempt the highest-value surface (grid/role/grant mutations). Guard
|
|
6
|
+
# includes this so the gate runs BEFORE the permission check; the engine
|
|
7
|
+
# ApplicationController includes it directly so skipping the permission check
|
|
8
|
+
# never skips the gate.
|
|
9
|
+
#
|
|
10
|
+
# An impersonated session is read-only by default: any non-GET/HEAD request is
|
|
11
|
+
# denied while a real actor stands behind a different effective subject. The
|
|
12
|
+
# endpoints that MUST run while impersonating — a host's stop-impersonation,
|
|
13
|
+
# sign-out, and sign-in actions — opt out with:
|
|
14
|
+
#
|
|
15
|
+
# skip_before_action :current_scope_mutation_guard!
|
|
16
|
+
#
|
|
17
|
+
# (Method spoofing is not a concern: Rack::MethodOverride only upgrades POST,
|
|
18
|
+
# and mutating actions are verb-pinned by routing.)
|
|
19
|
+
module MutationGuard
|
|
20
|
+
extend ActiveSupport::Concern
|
|
21
|
+
|
|
22
|
+
included do
|
|
23
|
+
before_action :current_scope_mutation_guard!
|
|
24
|
+
rescue_from CurrentScope::AccessDenied, with: :current_scope_denied
|
|
25
|
+
end
|
|
26
|
+
|
|
27
|
+
private
|
|
28
|
+
|
|
29
|
+
def current_scope_mutation_guard!
|
|
30
|
+
return if request.get? || request.head?
|
|
31
|
+
return if CurrentScope.config.allow_mutations_while_impersonating
|
|
32
|
+
return unless current_scope_impersonating?
|
|
33
|
+
|
|
34
|
+
raise CurrentScope::AccessDenied.new(
|
|
35
|
+
"#{controller_path}##{action_name}", reason: :impersonation_gate
|
|
36
|
+
)
|
|
37
|
+
end
|
|
38
|
+
|
|
39
|
+
# A real actor stands behind a distinct effective subject (act-as). Read
|
|
40
|
+
# from the ambient context directly, so the gate holds even where the
|
|
41
|
+
# Permissions helpers are not mixed in.
|
|
42
|
+
def current_scope_impersonating?
|
|
43
|
+
user = CurrentScope::Current.user
|
|
44
|
+
user.present? && CurrentScope::Current.actor != user
|
|
45
|
+
end
|
|
46
|
+
|
|
47
|
+
# Denials render forbidden and surface the machine-readable reason on the
|
|
48
|
+
# response so refusals are diagnosable by clients and tests.
|
|
49
|
+
def current_scope_denied(exception = nil)
|
|
50
|
+
reason = exception.respond_to?(:reason) ? exception.reason : nil
|
|
51
|
+
response.headers["X-Current-Scope-Reason"] = reason.to_s if reason
|
|
52
|
+
head :forbidden
|
|
53
|
+
end
|
|
54
|
+
end
|
|
55
|
+
end
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
module CurrentScope
|
|
2
|
+
# Derives the permission set from the host's routes: one permission per
|
|
3
|
+
# controller#action pair. There is no table to maintain — add a controller
|
|
4
|
+
# and its actions appear in the grid on next boot/reload.
|
|
5
|
+
class PermissionCatalog
|
|
6
|
+
def keys
|
|
7
|
+
@keys ||= derive
|
|
8
|
+
end
|
|
9
|
+
|
|
10
|
+
# { "reports" => ["approve", "create", ...], ... } for the role-editor grid.
|
|
11
|
+
def grouped
|
|
12
|
+
keys.group_by { |key| key.split("#").first }
|
|
13
|
+
.transform_values { |ks| ks.map { |k| k.split("#").last } }
|
|
14
|
+
end
|
|
15
|
+
|
|
16
|
+
def include?(key)
|
|
17
|
+
keys.include?(key)
|
|
18
|
+
end
|
|
19
|
+
|
|
20
|
+
private
|
|
21
|
+
|
|
22
|
+
def derive
|
|
23
|
+
Rails.application.routes.routes.filter_map { |route|
|
|
24
|
+
controller = route.defaults[:controller]
|
|
25
|
+
action = route.defaults[:action]
|
|
26
|
+
next unless controller && action
|
|
27
|
+
next if CurrentScope.config.excluded_controllers.any? { |re| controller.match?(re) }
|
|
28
|
+
|
|
29
|
+
"#{controller}##{action}"
|
|
30
|
+
}.uniq.sort
|
|
31
|
+
end
|
|
32
|
+
end
|
|
33
|
+
end
|
|
@@ -0,0 +1,90 @@
|
|
|
1
|
+
module CurrentScope
|
|
2
|
+
# Presents the route-derived permission catalog as an ALIGNED matrix for the
|
|
3
|
+
# role editor: fixed columns, one row per controller, blank cells where a
|
|
4
|
+
# controller doesn't route a column's actions (never a shifted cell).
|
|
5
|
+
#
|
|
6
|
+
# By default the columns are CRUD groups (config.permission_grid_groups):
|
|
7
|
+
# ticking one grants every routed action in the group, so the RESTful form
|
|
8
|
+
# actions fold into their mutation (new→create, edit→update) and index+show
|
|
9
|
+
# read as one. Actions outside any group (e.g. "approve") get their own
|
|
10
|
+
# column. With groups set to nil/{} every raw action becomes its own column —
|
|
11
|
+
# still aligned.
|
|
12
|
+
class PermissionGrid
|
|
13
|
+
Column = Struct.new(:label, :actions, :group, keyword_init: true)
|
|
14
|
+
Cell = Struct.new(:blank, :group, :name, :value, :checked, :partial, :granted_keys, keyword_init: true)
|
|
15
|
+
|
|
16
|
+
def initialize(catalog: CurrentScope.catalog, groups: CurrentScope.config.permission_grid_groups)
|
|
17
|
+
@grouped = catalog.grouped # { "controller" => ["action", ...] }
|
|
18
|
+
@groups = groups || {}
|
|
19
|
+
end
|
|
20
|
+
|
|
21
|
+
def controllers
|
|
22
|
+
@grouped.keys.sort
|
|
23
|
+
end
|
|
24
|
+
|
|
25
|
+
# Ordered columns: config groups that apply to at least one controller (in
|
|
26
|
+
# config order), then leftover actions not covered by any group (sorted).
|
|
27
|
+
def columns
|
|
28
|
+
grouped = @groups.filter_map do |label, actions|
|
|
29
|
+
Column.new(label: label, actions: actions, group: true) if any_controller_has?(actions)
|
|
30
|
+
end
|
|
31
|
+
grouped + leftover_actions.map { |action| Column.new(label: action, actions: [ action ], group: false) }
|
|
32
|
+
end
|
|
33
|
+
|
|
34
|
+
# One cell for (controller, column) against a role's granted key set.
|
|
35
|
+
# Blank when the controller routes none of the column's actions. Otherwise a
|
|
36
|
+
# checkbox.
|
|
37
|
+
#
|
|
38
|
+
# A GROUP cell is only `checked` when EVERY routed action is granted — a
|
|
39
|
+
# partial group is rendered unchecked+indeterminate and its existing keys are
|
|
40
|
+
# preserved verbatim via hidden inputs (see the edit view). This is the
|
|
41
|
+
# escalation guard: a checked group token expands to the whole group on save,
|
|
42
|
+
# so treating "some granted" as checked would silently promote a partial
|
|
43
|
+
# grant to a full one just by re-saving the role. `granted_keys` carries the
|
|
44
|
+
# exact subset to round-trip for a partial cell.
|
|
45
|
+
def cell(controller, column, granted)
|
|
46
|
+
routed = column.actions & actions_for(controller)
|
|
47
|
+
return Cell.new(blank: true) if routed.empty?
|
|
48
|
+
|
|
49
|
+
keys = routed.map { |action| "#{controller}##{action}" }
|
|
50
|
+
present_keys = keys.select { |key| granted.include?(key) }
|
|
51
|
+
partial = present_keys.any? && present_keys.size < keys.size
|
|
52
|
+
Cell.new(
|
|
53
|
+
blank: false,
|
|
54
|
+
group: column.group,
|
|
55
|
+
name: column.group ? "role[permission_groups][]" : "role[permission_keys][]",
|
|
56
|
+
value: column.group ? "#{controller}:#{column.label}" : keys.first,
|
|
57
|
+
checked: column.group ? (present_keys.any? && !partial) : present_keys.any?,
|
|
58
|
+
partial: partial,
|
|
59
|
+
granted_keys: partial ? present_keys : []
|
|
60
|
+
)
|
|
61
|
+
end
|
|
62
|
+
|
|
63
|
+
# Expand submitted "controller:group" tokens into routed permission keys.
|
|
64
|
+
# Unknown groups/controllers and unrouted actions drop out.
|
|
65
|
+
def expand(tokens)
|
|
66
|
+
Array(tokens).flat_map do |token|
|
|
67
|
+
controller, label = token.to_s.split(":", 2)
|
|
68
|
+
actions = @groups[label]
|
|
69
|
+
next [] if actions.nil?
|
|
70
|
+
|
|
71
|
+
(actions & actions_for(controller)).map { |action| "#{controller}##{action}" }
|
|
72
|
+
end
|
|
73
|
+
end
|
|
74
|
+
|
|
75
|
+
def actions_for(controller)
|
|
76
|
+
@grouped[controller] || []
|
|
77
|
+
end
|
|
78
|
+
|
|
79
|
+
private
|
|
80
|
+
|
|
81
|
+
def any_controller_has?(actions)
|
|
82
|
+
@grouped.values.any? { |routed| routed.intersect?(actions) }
|
|
83
|
+
end
|
|
84
|
+
|
|
85
|
+
def leftover_actions
|
|
86
|
+
grouped = @groups.values.flatten.uniq
|
|
87
|
+
(@grouped.values.flatten.uniq - grouped).sort
|
|
88
|
+
end
|
|
89
|
+
end
|
|
90
|
+
end
|
|
@@ -0,0 +1,57 @@
|
|
|
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
|
+
end
|
|
18
|
+
|
|
19
|
+
# The list-side companion to allowed_to?: "which records of `model` may the
|
|
20
|
+
# effective subject act on?". Same grants, keys, and fail-closed rules as
|
|
21
|
+
# the gate, so a list can't drift from the per-record decision. Returns a
|
|
22
|
+
# chainable relation (.where/.order/.page on it). `permission` defaults to
|
|
23
|
+
# the model's index context and accepts a bare action or a full key.
|
|
24
|
+
#
|
|
25
|
+
# scope_for(Project) # projects#index — what a list shows
|
|
26
|
+
# scope_for(Report, permission: :approve)
|
|
27
|
+
# scope_for(Report, permission: "admin/reports#approve")
|
|
28
|
+
def scope_for(model, permission: nil)
|
|
29
|
+
# Derive the key exactly like allowed_to? — including controller_path, so a
|
|
30
|
+
# namespaced controller's list resolves to the same key as its gate
|
|
31
|
+
# (admin/reports#index, not reports#index) and the two never drift.
|
|
32
|
+
controller = controller_path if respond_to?(:controller_path)
|
|
33
|
+
CurrentScope.scope_for(
|
|
34
|
+
subject: current_scope_user,
|
|
35
|
+
model: model,
|
|
36
|
+
permission: CurrentScope.permission_key(permission || :index, record: model, controller_path: controller)
|
|
37
|
+
)
|
|
38
|
+
end
|
|
39
|
+
|
|
40
|
+
def current_scope_user
|
|
41
|
+
CurrentScope::Current.user
|
|
42
|
+
end
|
|
43
|
+
|
|
44
|
+
# The REAL actor behind the request (never nil when a subject is set — it
|
|
45
|
+
# falls back to the subject). Read this for attribution, not Current.
|
|
46
|
+
def current_scope_actor
|
|
47
|
+
CurrentScope::Current.actor
|
|
48
|
+
end
|
|
49
|
+
|
|
50
|
+
# True only while a distinct real actor stands behind the effective
|
|
51
|
+
# subject (act-as). Views use it as the read-only-state signal.
|
|
52
|
+
def impersonating?
|
|
53
|
+
CurrentScope::Current.user.present? &&
|
|
54
|
+
CurrentScope::Current.actor != CurrentScope::Current.user
|
|
55
|
+
end
|
|
56
|
+
end
|
|
57
|
+
end
|
|
@@ -0,0 +1,171 @@
|
|
|
1
|
+
module CurrentScope
|
|
2
|
+
# The decision point. Every allow/deny question in the system routes through
|
|
3
|
+
# here, in a fixed order:
|
|
4
|
+
#
|
|
5
|
+
# 1. SoD veto — the record's initiator can never perform an SoD action
|
|
6
|
+
# on it. Reads two identities: the effective subject and
|
|
7
|
+
# (under sod_identity :either) the real actor behind an
|
|
8
|
+
# impersonated session. Overrides everything, incl. full_access.
|
|
9
|
+
# 2. full_access — the subject's org-wide role grants all permissions,
|
|
10
|
+
# present and future.
|
|
11
|
+
# 3. org-wide role — the role's permission set includes this permission.
|
|
12
|
+
# 4. scoped role — a role held on THIS record grants the permission.
|
|
13
|
+
# 5. default-deny — nothing granted means denied.
|
|
14
|
+
class Resolver
|
|
15
|
+
INITIATOR_METHOD = :current_scope_initiator
|
|
16
|
+
# Host-defined per-record opt-in for break-glass. Absent ⇒ never bypassed
|
|
17
|
+
# (fail-closed, no raise — unlike a missing initiator, absence here is
|
|
18
|
+
# unambiguous).
|
|
19
|
+
BYPASS_METHOD = :current_scope_sod_bypassed?
|
|
20
|
+
|
|
21
|
+
# Public contract: boolean. `actor` is the REAL principal behind the
|
|
22
|
+
# request (defaults to the subject — no impersonation); it only widens the
|
|
23
|
+
# 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
|
|
26
|
+
end
|
|
27
|
+
|
|
28
|
+
# Internal decision: returns [allowed_bool, reason_or_nil]. The reason is a
|
|
29
|
+
# machine-readable cause the Guard surfaces: :sod_veto / :no_grant on a
|
|
30
|
+
# denial, and :sod_bypassed on the one AUDITED allow (break-glass). Ordinary
|
|
31
|
+
# allows carry nil. The resolver — shared across threads — holds no
|
|
32
|
+
# per-decision state; the reason rides in the return tuple, not on self.
|
|
33
|
+
def decide(subject:, permission:, record: nil, actor: nil)
|
|
34
|
+
return [ false, :no_grant ] if subject.nil?
|
|
35
|
+
|
|
36
|
+
case sod_decision(subject: subject, actor: actor, permission: permission, record: record)
|
|
37
|
+
when :veto then return [ false, :sod_veto ]
|
|
38
|
+
when :bypass then return [ true, :sod_bypassed ] # break-glass: privileged, audited override
|
|
39
|
+
end
|
|
40
|
+
|
|
41
|
+
role = org_role(subject)
|
|
42
|
+
return [ true, nil ] if role&.full_access?
|
|
43
|
+
return [ true, nil ] if role&.grants?(permission)
|
|
44
|
+
return [ true, nil ] if scoped_grant?(subject: subject, permission: permission, record: record)
|
|
45
|
+
|
|
46
|
+
[ false, :no_grant ]
|
|
47
|
+
end
|
|
48
|
+
|
|
49
|
+
# The subject's one org-wide role. Memoized per request (via Current) so the
|
|
50
|
+
# many gate checks a single request makes don't each re-query — the decision
|
|
51
|
+
# is identical, only the lookup is cached, keeping the resolver a pure
|
|
52
|
+
# decision function over its inputs.
|
|
53
|
+
def org_role(subject)
|
|
54
|
+
CurrentScope::Current.memoized_org_role(subject) do
|
|
55
|
+
RoleAssignment.find_by(subject: subject)&.role
|
|
56
|
+
end
|
|
57
|
+
end
|
|
58
|
+
|
|
59
|
+
def full_access?(subject)
|
|
60
|
+
!!(subject && org_role(subject)&.full_access?)
|
|
61
|
+
end
|
|
62
|
+
|
|
63
|
+
# The list-side complement to allow?: "which records of `model` may this
|
|
64
|
+
# subject act on?". Reads the SAME org + scoped grants the gate reads, so a
|
|
65
|
+
# host list can never drift from the per-record decision. Fail-closed (nil
|
|
66
|
+
# subject / no grant → none) and flat — no parent/child cascade. SoD does
|
|
67
|
+
# NOT apply: it vetoes record-targeted actions, not list membership.
|
|
68
|
+
def scope_for(subject:, model:, permission:)
|
|
69
|
+
return model.none if subject.nil?
|
|
70
|
+
|
|
71
|
+
role = org_role(subject)
|
|
72
|
+
return model.all if role&.full_access? || role&.grants?(permission)
|
|
73
|
+
|
|
74
|
+
# Records on which the subject holds a scoped role that grants the key.
|
|
75
|
+
# Query the polymorphic base_class (what scoped grants store), not the
|
|
76
|
+
# passed model's name — otherwise scope_for(STISubclass) returns nothing
|
|
77
|
+
# while the per-record gate (also keyed on base_class) would allow it. The
|
|
78
|
+
# `model.where` still applies STI's own type predicate, so a subclass query
|
|
79
|
+
# can't over-list sibling-subclass rows. An empty subquery yields an empty
|
|
80
|
+
# (still chainable) relation.
|
|
81
|
+
model.where(
|
|
82
|
+
id: ScopedRoleAssignment
|
|
83
|
+
.where(subject: subject, resource_type: model.base_class.name, role_id: roles_granting(permission))
|
|
84
|
+
.select(:resource_id)
|
|
85
|
+
)
|
|
86
|
+
end
|
|
87
|
+
|
|
88
|
+
private
|
|
89
|
+
|
|
90
|
+
# Role ids that satisfy `permission`: full_access (grants everything) or an
|
|
91
|
+
# explicit grant of the key. The one place "does this role grant it?" is
|
|
92
|
+
# expressed for scoped grants — shared by the gate and scope_for.
|
|
93
|
+
def roles_granting(permission)
|
|
94
|
+
Role.where(full_access: true)
|
|
95
|
+
.or(Role.where(id: RolePermission.where(permission_key: permission).select(:role_id)))
|
|
96
|
+
end
|
|
97
|
+
|
|
98
|
+
# The separation-of-duties outcome for this decision: :none (no conflict, or
|
|
99
|
+
# not an SoD action), :veto (the initiator is acting on their own record and
|
|
100
|
+
# the veto stands), or :bypass (a conflict exists but break-glass lifts it).
|
|
101
|
+
# Pure — reads only; the audit write for a :bypass happens at the Guard.
|
|
102
|
+
def sod_decision(subject:, actor:, permission:, record:)
|
|
103
|
+
action = permission.split("#").last
|
|
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?)
|
|
108
|
+
|
|
109
|
+
# SoD is a structural guarantee — "cannot determine the initiator" must
|
|
110
|
+
# never mean "permit". A record type where SoD genuinely doesn't apply
|
|
111
|
+
# declares the hook returning nil.
|
|
112
|
+
unless record.respond_to?(INITIATOR_METHOD, true)
|
|
113
|
+
raise ConfigurationError,
|
|
114
|
+
"#{record.class.name}##{INITIATOR_METHOD} is not defined, but " \
|
|
115
|
+
"\"#{permission}\" is a separation-of-duties action (config.sod_actions). " \
|
|
116
|
+
"Define #{INITIATOR_METHOD} on #{record.class.name} (return nil to exempt " \
|
|
117
|
+
"a record), or remove \"#{action}\" from config.sod_actions."
|
|
118
|
+
end
|
|
119
|
+
|
|
120
|
+
initiator = record.send(INITIATOR_METHOD)
|
|
121
|
+
return :none if initiator.blank?
|
|
122
|
+
|
|
123
|
+
# The subject can never approve their own record. Under :either, neither
|
|
124
|
+
# can a real actor who initiated it while impersonating a different
|
|
125
|
+
# subject — impersonation must not become a self-approval loophole. Not
|
|
126
|
+
# impersonating (actor == subject) collapses both checks to the same test.
|
|
127
|
+
actor ||= subject
|
|
128
|
+
conflict = initiator == subject ||
|
|
129
|
+
(CurrentScope.config.sod_identity == :either && actor != subject && initiator == actor)
|
|
130
|
+
return :none unless conflict
|
|
131
|
+
|
|
132
|
+
sod_bypassed?(record: record, initiator: initiator) ? :bypass : :veto
|
|
133
|
+
end
|
|
134
|
+
|
|
135
|
+
# Break-glass: does an audited, privileged override lift the veto for this
|
|
136
|
+
# record? All three must hold, live: the config switch is on, the record's
|
|
137
|
+
# host hook opts in, and the INITIATOR (the identity the veto fired on —
|
|
138
|
+
# KTD-2, so impersonation can't launder it) holds the bypass permission.
|
|
139
|
+
def sod_bypassed?(record:, initiator:)
|
|
140
|
+
return false unless CurrentScope.config.allow_sod_bypass
|
|
141
|
+
|
|
142
|
+
# Re-entrancy is bounded ONLY because the bypass permission isn't itself an
|
|
143
|
+
# SoD action (KTD-5) — the inner allowed? below returns at the SoD step
|
|
144
|
+
# without recursing. Enforce that invariant loudly rather than trusting the
|
|
145
|
+
# host to honor the doc comment: a bypass action in sod_actions would
|
|
146
|
+
# recurse to a SystemStackError.
|
|
147
|
+
bypass_action = CurrentScope.config.sod_bypass_permission.to_s.split("#").last
|
|
148
|
+
if CurrentScope.config.sod_actions.include?(bypass_action)
|
|
149
|
+
raise ConfigurationError,
|
|
150
|
+
"config.sod_bypass_permission (#{CurrentScope.config.sod_bypass_permission.inspect}) is the " \
|
|
151
|
+
"action #{bypass_action.inspect}, which is also in config.sod_actions. The bypass permission " \
|
|
152
|
+
"must not be an SoD action — it would recurse. Remove #{bypass_action.inspect} from sod_actions."
|
|
153
|
+
end
|
|
154
|
+
|
|
155
|
+
# Absent hook ⇒ this type never breaks glass (fail-closed, no raise).
|
|
156
|
+
return false unless record.respond_to?(BYPASS_METHOD, true) && record.send(BYPASS_METHOD)
|
|
157
|
+
|
|
158
|
+
CurrentScope.allowed?(CurrentScope.config.sod_bypass_permission, subject: initiator, record: record)
|
|
159
|
+
end
|
|
160
|
+
|
|
161
|
+
def scoped_grant?(subject:, permission:, record:)
|
|
162
|
+
# `record` may be a class (allowed_to?(:create, Report)) — classes can't
|
|
163
|
+
# hold scoped grants, only persisted records can.
|
|
164
|
+
return false unless record.respond_to?(:new_record?) && record.persisted?
|
|
165
|
+
|
|
166
|
+
ScopedRoleAssignment
|
|
167
|
+
.where(subject: subject, resource: record, role_id: roles_granting(permission))
|
|
168
|
+
.exists?
|
|
169
|
+
end
|
|
170
|
+
end
|
|
171
|
+
end
|
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
module CurrentScope
|
|
2
|
+
# Include CurrentScope::Scopeable to make a model pickable in the scoped-role
|
|
3
|
+
# assignment UI's type dropdown. BROWSE-ONLY — it does NOT gate access: any
|
|
4
|
+
# model is still a valid GlobalID scoped-role target whether or not it opts in.
|
|
5
|
+
#
|
|
6
|
+
# The `included` hook self-registers the class by NAME (stored as a string and
|
|
7
|
+
# resolved lazily) so dev-mode class reloading never pins a stale constant;
|
|
8
|
+
# the engine rebuilds the registry on every to_prepare.
|
|
9
|
+
module Scopeable
|
|
10
|
+
extend ActiveSupport::Concern
|
|
11
|
+
|
|
12
|
+
included do
|
|
13
|
+
CurrentScope.register_scopeable(name) if name
|
|
14
|
+
end
|
|
15
|
+
|
|
16
|
+
# Default label for the picker. Defined as an ordinary instance method so a
|
|
17
|
+
# host that provides its own current_scope_label simply overrides it — the
|
|
18
|
+
# host class sits ahead of this module in the ancestor chain.
|
|
19
|
+
def current_scope_label
|
|
20
|
+
"#{model_name.human} ##{id}"
|
|
21
|
+
end
|
|
22
|
+
|
|
23
|
+
# OPTIONAL indexed search for the scoped-role picker. current_scope_label is
|
|
24
|
+
# a Ruby method with no backing column, so by default the picker scans the
|
|
25
|
+
# first 500 rows and filters the label in Ruby. Define this class method to
|
|
26
|
+
# search via indexed SQL instead — it receives the query term and must
|
|
27
|
+
# return an ActiveRecord::Relation:
|
|
28
|
+
#
|
|
29
|
+
# class Project < ApplicationRecord
|
|
30
|
+
# include CurrentScope::Scopeable
|
|
31
|
+
# def self.current_scope_searchable_scope(term)
|
|
32
|
+
# where("name ILIKE ?", "%#{term}%")
|
|
33
|
+
# end
|
|
34
|
+
# end
|
|
35
|
+
#
|
|
36
|
+
# No default is provided — the host knows which column is indexed.
|
|
37
|
+
end
|
|
38
|
+
end
|
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
module CurrentScope
|
|
2
|
+
# Test support for host apps:
|
|
3
|
+
#
|
|
4
|
+
# include CurrentScope::TestHelpers
|
|
5
|
+
#
|
|
6
|
+
# with_current_user(users(:alice)) do
|
|
7
|
+
# assert component_allows_approve?
|
|
8
|
+
# end
|
|
9
|
+
#
|
|
10
|
+
# with_current_user(users(:bob), actor: users(:admin)) do # act-as
|
|
11
|
+
# assert impersonating?
|
|
12
|
+
# end
|
|
13
|
+
#
|
|
14
|
+
# CurrentAttributes resets between examples, so nothing set here can leak.
|
|
15
|
+
module TestHelpers
|
|
16
|
+
# Snapshot/restore the RAW attributes rather than using Current.set: the
|
|
17
|
+
# actor reader falls back to user, and Object#with (which set uses) would
|
|
18
|
+
# snapshot that fallback and restore a stale actor. Saving the underlying
|
|
19
|
+
# hash restores the true prior state.
|
|
20
|
+
def with_current_user(user, actor: nil)
|
|
21
|
+
# dup: on Rails 8.0 `attributes` returns the LIVE hash, so without a copy
|
|
22
|
+
# the assignments below would mutate our own snapshot.
|
|
23
|
+
previous = CurrentScope::Current.attributes.dup
|
|
24
|
+
CurrentScope::Current.user = user
|
|
25
|
+
CurrentScope::Current.actor = actor
|
|
26
|
+
yield
|
|
27
|
+
ensure
|
|
28
|
+
# Version-robust restore: `attributes = previous` clears absent keys on
|
|
29
|
+
# Rails 8.1 but NOT on 8.0 (leaving the block's user set), so reset first,
|
|
30
|
+
# then re-apply the snapshot. Works identically on the whole >= 8.0 floor.
|
|
31
|
+
CurrentScope::Current.reset
|
|
32
|
+
previous.each { |name, value| CurrentScope::Current.public_send(:"#{name}=", value) }
|
|
33
|
+
end
|
|
34
|
+
|
|
35
|
+
# Seed a real org-wide grant for request/system specs. Unlike
|
|
36
|
+
# with_current_user (which only sets Current.user in-process, and is
|
|
37
|
+
# overwritten by Context's before_action on a real request), this persists a
|
|
38
|
+
# RoleAssignment row that survives the request cycle, so a host can test its
|
|
39
|
+
# own controllers behind the gate. It does NOT authenticate — the host still
|
|
40
|
+
# signs the subject in through its own auth. Bang-suffixed like the engine's
|
|
41
|
+
# other DB-mutating helpers (seed_defaults!, Event.record!). Returns the
|
|
42
|
+
# assignment.
|
|
43
|
+
def grant_role!(subject, role:)
|
|
44
|
+
CurrentScope::RoleAssignment.create!(subject: subject, role: role)
|
|
45
|
+
end
|
|
46
|
+
|
|
47
|
+
# The scoped-grant companion: seed a role held on ONE specific record.
|
|
48
|
+
# Returns the scoped assignment.
|
|
49
|
+
def grant_scoped_role!(subject, role:, record:)
|
|
50
|
+
CurrentScope::ScopedRoleAssignment.create!(subject: subject, role: role, resource: record)
|
|
51
|
+
end
|
|
52
|
+
end
|
|
53
|
+
end
|
|
@@ -0,0 +1,168 @@
|
|
|
1
|
+
require "current_scope/version"
|
|
2
|
+
require "current_scope/configuration"
|
|
3
|
+
require "current_scope/permission_catalog"
|
|
4
|
+
require "current_scope/permission_grid"
|
|
5
|
+
require "current_scope/resolver"
|
|
6
|
+
require "current_scope/permissions"
|
|
7
|
+
require "current_scope/context"
|
|
8
|
+
require "current_scope/scopeable"
|
|
9
|
+
require "current_scope/mutation_guard"
|
|
10
|
+
require "current_scope/guard"
|
|
11
|
+
require "current_scope/gating_tripwire"
|
|
12
|
+
require "current_scope/engine"
|
|
13
|
+
|
|
14
|
+
module CurrentScope
|
|
15
|
+
# Raised when the resolver denies an action gated by Guard (or when the
|
|
16
|
+
# management UI is accessed without a full-access role). Carries an optional
|
|
17
|
+
# machine-readable reason (:sod_veto, :no_grant, :impersonation_gate).
|
|
18
|
+
class AccessDenied < StandardError
|
|
19
|
+
attr_reader :reason
|
|
20
|
+
|
|
21
|
+
def initialize(message = nil, reason: nil)
|
|
22
|
+
super(message)
|
|
23
|
+
@reason = reason
|
|
24
|
+
end
|
|
25
|
+
end
|
|
26
|
+
|
|
27
|
+
# Raised when the host is wired up wrong (missing hook, bad config). Always
|
|
28
|
+
# raised loudly — an authorization library must never turn a configuration
|
|
29
|
+
# mistake into a silent allow or an undiagnosable deny.
|
|
30
|
+
class ConfigurationError < StandardError; end
|
|
31
|
+
|
|
32
|
+
class << self
|
|
33
|
+
def config
|
|
34
|
+
@config ||= Configuration.new
|
|
35
|
+
end
|
|
36
|
+
|
|
37
|
+
def configure
|
|
38
|
+
yield config
|
|
39
|
+
end
|
|
40
|
+
|
|
41
|
+
def resolver
|
|
42
|
+
@resolver ||= Resolver.new
|
|
43
|
+
end
|
|
44
|
+
|
|
45
|
+
def catalog
|
|
46
|
+
@catalog ||= PermissionCatalog.new
|
|
47
|
+
end
|
|
48
|
+
|
|
49
|
+
def reset_catalog!
|
|
50
|
+
@catalog = nil
|
|
51
|
+
end
|
|
52
|
+
|
|
53
|
+
# Models that opted into the scoped-role picker via CurrentScope::Scopeable.
|
|
54
|
+
# Stored as class-name strings and resolved lazily so dev-mode reloading
|
|
55
|
+
# never pins a stale constant. Rebuilt from scratch on every engine
|
|
56
|
+
# to_prepare (see reset_scopeable_registry!).
|
|
57
|
+
def scopeable_registry
|
|
58
|
+
@scopeable_registry ||= Set.new
|
|
59
|
+
end
|
|
60
|
+
|
|
61
|
+
def register_scopeable(model_name)
|
|
62
|
+
scopeable_registry << model_name.to_s
|
|
63
|
+
end
|
|
64
|
+
|
|
65
|
+
def scopeable_resources
|
|
66
|
+
scopeable_registry.map(&:constantize).sort_by(&:name)
|
|
67
|
+
end
|
|
68
|
+
|
|
69
|
+
def reset_scopeable_registry!
|
|
70
|
+
@scopeable_registry = Set.new
|
|
71
|
+
end
|
|
72
|
+
|
|
73
|
+
# The single entry point behind every allowed_to? call.
|
|
74
|
+
# `action` is either a full permission key ("admin/reports#approve") or a
|
|
75
|
+
# bare action name resolved against `record`'s route key, falling back to
|
|
76
|
+
# `controller_path`.
|
|
77
|
+
def allowed?(action, subject:, record: nil, controller_path: nil, actor: nil)
|
|
78
|
+
resolver.allow?(
|
|
79
|
+
subject: subject,
|
|
80
|
+
permission: permission_key(action, record: record, controller_path: controller_path),
|
|
81
|
+
record: record,
|
|
82
|
+
actor: actor
|
|
83
|
+
)
|
|
84
|
+
end
|
|
85
|
+
|
|
86
|
+
# The list-side companion to allowed?. Returns a chainable relation of the
|
|
87
|
+
# records of `model` the subject may act on under `permission` — same
|
|
88
|
+
# grants, same fail-closed rules as the per-record gate. `permission` is a
|
|
89
|
+
# resolved key ("projects#index"); the mixin derives the default.
|
|
90
|
+
def scope_for(subject:, model:, permission:)
|
|
91
|
+
resolver.scope_for(subject: subject, model: model, permission: permission)
|
|
92
|
+
end
|
|
93
|
+
|
|
94
|
+
def permission_key(action, record: nil, controller_path: nil)
|
|
95
|
+
action = action.to_s
|
|
96
|
+
return action if action.include?("#")
|
|
97
|
+
|
|
98
|
+
if record.respond_to?(:model_name)
|
|
99
|
+
route_key = record.model_name.route_key
|
|
100
|
+
# When the current controller handles this record type (possibly under
|
|
101
|
+
# a namespace — admin/reports for a Report), its path is the key the
|
|
102
|
+
# Guard enforces, so prefer it: the view must agree with the gate.
|
|
103
|
+
return "#{controller_path}##{action}" if controller_path&.split("/")&.last == route_key
|
|
104
|
+
|
|
105
|
+
return "#{route_key}##{action}"
|
|
106
|
+
end
|
|
107
|
+
return "#{controller_path}##{action}" if controller_path
|
|
108
|
+
|
|
109
|
+
raise ArgumentError,
|
|
110
|
+
"cannot derive a permission key for #{action.inspect} — pass a record, " \
|
|
111
|
+
"a full \"controller#action\" string, or call from a controller/view"
|
|
112
|
+
end
|
|
113
|
+
|
|
114
|
+
# Impersonation boundary events. The impersonated identity is an EXPLICIT
|
|
115
|
+
# argument (not read from the ambient pair): at act-as START the ambient
|
|
116
|
+
# actor still equals the effective user — Current re-resolves next request —
|
|
117
|
+
# so an ambient-only recorder would lose who was impersonated. Call these
|
|
118
|
+
# from the host's start/stop-impersonation endpoints.
|
|
119
|
+
def record_impersonation_started!(subject)
|
|
120
|
+
require_actor_method!
|
|
121
|
+
Event.record!(event: "impersonation.started", target: subject)
|
|
122
|
+
end
|
|
123
|
+
|
|
124
|
+
def record_impersonation_stopped!(subject)
|
|
125
|
+
require_actor_method!
|
|
126
|
+
Event.record!(event: "impersonation.stopped", target: subject)
|
|
127
|
+
end
|
|
128
|
+
|
|
129
|
+
# Creates the two baseline roles every install needs: an Owner with
|
|
130
|
+
# full_access (present and future permissions) and a Member baseline.
|
|
131
|
+
# Call from db/seeds.rb.
|
|
132
|
+
def seed_defaults!
|
|
133
|
+
Role.find_or_create_by!(name: "Owner") { |r| r.full_access = true }
|
|
134
|
+
Role.find_or_create_by!(name: "Member")
|
|
135
|
+
end
|
|
136
|
+
|
|
137
|
+
# Bootstrap the first admin: assign a role (default: the full_access Owner)
|
|
138
|
+
# to `subject` as its one org-wide role. Idempotent — re-running sets the
|
|
139
|
+
# same subject's org role to `role` rather than creating a duplicate (which
|
|
140
|
+
# the one-role-per-subject uniqueness would reject anyway). Backs the
|
|
141
|
+
# `current_scope:grant` rake task, so a fresh install doesn't need a console.
|
|
142
|
+
def grant!(subject, role: nil)
|
|
143
|
+
seed_defaults!
|
|
144
|
+
role ||= Role.find_by!(name: "Owner")
|
|
145
|
+
RoleAssignment.find_or_initialize_by(subject: subject).tap { |a| a.update!(role: role) }
|
|
146
|
+
end
|
|
147
|
+
|
|
148
|
+
private
|
|
149
|
+
|
|
150
|
+
# A2: the boundary events are the one place a host declares it is actually
|
|
151
|
+
# impersonating. If actor_method is unset there, the entire act-as security
|
|
152
|
+
# model is silently inert — so fail LOUD instead of recording an
|
|
153
|
+
# impersonation with no real actor behind it. (The permission path can't
|
|
154
|
+
# detect this: with actor_method nil, actor falls back to user, so
|
|
155
|
+
# impersonating? is always false and a per-request check would nag every
|
|
156
|
+
# RBAC-only host. This seam only fires when the host declares intent.)
|
|
157
|
+
def require_actor_method!
|
|
158
|
+
return unless config.actor_method.nil?
|
|
159
|
+
|
|
160
|
+
raise ConfigurationError,
|
|
161
|
+
"impersonation boundary event recorded while config.actor_method is unset. " \
|
|
162
|
+
"Act-as security is inert without it: the read-only-while-impersonating " \
|
|
163
|
+
"MutationGuard never engages, the SoD :either veto can't fire, and audit rows " \
|
|
164
|
+
"are attributed to the impersonated subject instead of the real actor. Set " \
|
|
165
|
+
"config.actor_method to the controller method that returns the real actor."
|
|
166
|
+
end
|
|
167
|
+
end
|
|
168
|
+
end
|