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,100 @@
|
|
|
1
|
+
<h1>Subjects</h1>
|
|
2
|
+
<p class="cs-lede">Everyone who can hold a role, with their one org-wide role and
|
|
3
|
+
any per-record scoped roles. Filter to find people; select several to grant a
|
|
4
|
+
scoped role in one go.</p>
|
|
5
|
+
|
|
6
|
+
<div class="cs-toolbar">
|
|
7
|
+
<%= form_with url: subjects_path, method: :get, class: "cs-search", role: "search" do %>
|
|
8
|
+
<input type="search" name="q" value="<%= @query %>" class="cs-filter" data-cs-filter
|
|
9
|
+
placeholder="Search everyone by email or name…" aria-label="Search subjects">
|
|
10
|
+
<%= submit_tag "Search", name: nil, class: "cs-btn" %>
|
|
11
|
+
<% if @query.present? %><%= link_to "Clear", subjects_path, class: "cs-btn" %><% end %>
|
|
12
|
+
<% end %>
|
|
13
|
+
<span class="cs-spacer"></span>
|
|
14
|
+
</div>
|
|
15
|
+
<% if @query.present? %>
|
|
16
|
+
<p class="cs-hint">Showing matches for “<%= @query %>” across all subjects. Narrow the list below per page; selection applies to the current page.</p>
|
|
17
|
+
<% elsif @page > 1 || @has_next_page %>
|
|
18
|
+
<p class="cs-hint">Type to filter this page, or Search to find anyone by email or name across every subject.</p>
|
|
19
|
+
<% end %>
|
|
20
|
+
|
|
21
|
+
<div class="cs-bulk" data-cs-bulk hidden>
|
|
22
|
+
<strong><span data-cs-bulk-count>0</span> selected</strong>
|
|
23
|
+
<span class="cs-spacer"></span>
|
|
24
|
+
<%# Org-wide role for the selection. JS injects the checked subjects as
|
|
25
|
+
subject_gids[] on submit; blank role clears it for all. %>
|
|
26
|
+
<%= form_with url: role_assignment_path, method: :post, class: "cs-bulk-org",
|
|
27
|
+
data: { cs_bulk_org: true } do %>
|
|
28
|
+
<%= select_tag :role_id, options_from_collection_for_select(@roles, :id, :name),
|
|
29
|
+
include_blank: "org role…", aria: { label: "Org-wide role for selected" } %>
|
|
30
|
+
<%= submit_tag "Set for selected", name: nil, class: "cs-btn" %>
|
|
31
|
+
<% end %>
|
|
32
|
+
<%= link_to "Grant scoped role…", "#", class: "cs-btn cs-btn-primary",
|
|
33
|
+
data: { cs_bulk_scoped: true, cs_bulk_url: new_scoped_role_assignment_path } %>
|
|
34
|
+
<button type="button" class="cs-btn" data-cs-bulk-clear>Clear</button>
|
|
35
|
+
</div>
|
|
36
|
+
|
|
37
|
+
<div class="cs-card cs-card--flush">
|
|
38
|
+
<table class="cs-table" data-cs-filter-list>
|
|
39
|
+
<thead>
|
|
40
|
+
<tr>
|
|
41
|
+
<th class="cs-select-cell">
|
|
42
|
+
<input type="checkbox" data-cs-select-all aria-label="Select all subjects">
|
|
43
|
+
</th>
|
|
44
|
+
<th>Subject</th><th>Org-wide role</th><th>Scoped roles</th>
|
|
45
|
+
</tr>
|
|
46
|
+
</thead>
|
|
47
|
+
<tbody>
|
|
48
|
+
<% @subjects.each do |subject| %>
|
|
49
|
+
<% key = [ subject.class.name, subject.id ] %>
|
|
50
|
+
<% label = current_scope_subject_label(subject) %>
|
|
51
|
+
<%# Filter against a precise text set — subject label, current org role, and
|
|
52
|
+
scoped roles/records — NOT the row's textContent: every row embeds a
|
|
53
|
+
<select> listing all role names, which would match every query. %>
|
|
54
|
+
<% org_role_name = @roles.find { |r| r.id == @assignments[key]&.role_id }&.name %>
|
|
55
|
+
<% filter_text = [ label, org_role_name,
|
|
56
|
+
*(@scoped[key] || []).map { |sra| "#{sra.role.name} #{current_scope_label(sra.resource)}" } ].compact.join(" ") %>
|
|
57
|
+
<tr data-cs-row data-cs-filter-text="<%= filter_text %>">
|
|
58
|
+
<td class="cs-select-cell">
|
|
59
|
+
<input type="checkbox" data-cs-select value="<%= subject.to_gid %>"
|
|
60
|
+
aria-label="Select <%= label %>">
|
|
61
|
+
</td>
|
|
62
|
+
<td><%= label %></td>
|
|
63
|
+
<td>
|
|
64
|
+
<%= form_with url: role_assignment_path, method: :post do %>
|
|
65
|
+
<%= hidden_field_tag :subject_gid, subject.to_gid %>
|
|
66
|
+
<%= select_tag :role_id,
|
|
67
|
+
options_from_collection_for_select(@roles, :id, :name, @assignments[key]&.role_id),
|
|
68
|
+
include_blank: "— none —" %>
|
|
69
|
+
<%= submit_tag "Set", name: nil %>
|
|
70
|
+
<% end %>
|
|
71
|
+
</td>
|
|
72
|
+
<td>
|
|
73
|
+
<% (@scoped[key] || []).each do |sra| %>
|
|
74
|
+
<span class="cs-chip">
|
|
75
|
+
<span class="cs-chip-label"><%= sra.role.name %> of <%= current_scope_label(sra.resource) %></span>
|
|
76
|
+
<% revoke_prompt = "Revoke #{sra.role.name} on #{current_scope_label(sra.resource)}?" %>
|
|
77
|
+
<%= button_to "×", scoped_role_assignment_path(sra), method: :delete,
|
|
78
|
+
class: "cs-chip-remove",
|
|
79
|
+
title: "Revoke this scoped role",
|
|
80
|
+
form: { data: { cs_confirm: revoke_prompt } },
|
|
81
|
+
aria: { label: "Revoke #{sra.role.name} on #{current_scope_label(sra.resource)}" },
|
|
82
|
+
data: { turbo_confirm: revoke_prompt } %>
|
|
83
|
+
</span>
|
|
84
|
+
<% end %>
|
|
85
|
+
<%= link_to "+ scoped role", new_scoped_role_assignment_path(subject_gid: subject.to_gid),
|
|
86
|
+
class: "cs-add-scoped" %>
|
|
87
|
+
</td>
|
|
88
|
+
</tr>
|
|
89
|
+
<% end %>
|
|
90
|
+
</tbody>
|
|
91
|
+
</table>
|
|
92
|
+
</div>
|
|
93
|
+
|
|
94
|
+
<p class="cs-filter-empty" data-cs-filter-empty hidden>No subjects on this page match your filter.</p>
|
|
95
|
+
|
|
96
|
+
<nav class="cs-pagination" aria-label="Subjects pagination">
|
|
97
|
+
<% if @page > 1 %><%= link_to "← Previous", subjects_path(page: @page - 1, q: @query.presence) %><% end %>
|
|
98
|
+
<span>Page <%= @page %></span>
|
|
99
|
+
<% if @has_next_page %><%= link_to "Next →", subjects_path(page: @page + 1, q: @query.presence) %><% end %>
|
|
100
|
+
</nav>
|
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
<% cs_theme = %w[light dark].include?(cookies[:current_scope_theme]) ? cookies[:current_scope_theme] : nil %>
|
|
2
|
+
<% active = controller_name %>
|
|
3
|
+
<!DOCTYPE html>
|
|
4
|
+
<html lang="en"<%= " data-cs-theme=\"#{cs_theme}\"".html_safe if cs_theme %>>
|
|
5
|
+
<head>
|
|
6
|
+
<meta charset="utf-8">
|
|
7
|
+
<meta name="viewport" content="width=device-width, initial-scale=1">
|
|
8
|
+
<title>CurrentScope</title>
|
|
9
|
+
<%= csrf_meta_tags %>
|
|
10
|
+
<%= csp_meta_tag %>
|
|
11
|
+
|
|
12
|
+
<%= yield :head %>
|
|
13
|
+
|
|
14
|
+
<%= stylesheet_link_tag "current_scope/application", media: "all" %>
|
|
15
|
+
<%= javascript_include_tag "current_scope/application", defer: true %>
|
|
16
|
+
</head>
|
|
17
|
+
<body>
|
|
18
|
+
<div class="cs-shell">
|
|
19
|
+
<aside class="cs-sidebar">
|
|
20
|
+
<div class="cs-brand"><span class="cs-brand-mark" aria-hidden="true"></span> CurrentScope</div>
|
|
21
|
+
<nav class="cs-nav" aria-label="Management">
|
|
22
|
+
<%= link_to "Roles", current_scope.roles_path,
|
|
23
|
+
"aria-current": ("page" if active == "roles") %>
|
|
24
|
+
<%= link_to "Subjects", current_scope.subjects_path,
|
|
25
|
+
"aria-current": ("page" if %w[subjects scoped_role_assignments].include?(active)) %>
|
|
26
|
+
<%= link_to "Events", current_scope.events_path,
|
|
27
|
+
"aria-current": ("page" if active == "events") %>
|
|
28
|
+
</nav>
|
|
29
|
+
<div class="cs-sidebar-foot">Authorization console</div>
|
|
30
|
+
</aside>
|
|
31
|
+
|
|
32
|
+
<div class="cs-main">
|
|
33
|
+
<div class="cs-topbar">
|
|
34
|
+
<span class="cs-topbar-title">Authorization</span>
|
|
35
|
+
<span class="cs-topbar-spacer"></span>
|
|
36
|
+
<span class="cs-badge">full access</span>
|
|
37
|
+
<button type="button" class="cs-theme-toggle" data-cs-theme-toggle
|
|
38
|
+
aria-label="Toggle dark mode" aria-pressed="<%= cs_theme == "dark" %>">
|
|
39
|
+
<span class="cs-icon-light" aria-hidden="true">☾</span>
|
|
40
|
+
<span class="cs-icon-dark" aria-hidden="true">☀</span>
|
|
41
|
+
</button>
|
|
42
|
+
</div>
|
|
43
|
+
|
|
44
|
+
<% if notice.present? %><p class="cs-flash cs-flash--notice" role="status"><%= notice %></p><% end %>
|
|
45
|
+
<% if alert.present? %><p class="cs-flash cs-flash--alert" role="alert"><%= alert %></p><% end %>
|
|
46
|
+
|
|
47
|
+
<main class="cs-content">
|
|
48
|
+
<%= yield %>
|
|
49
|
+
</main>
|
|
50
|
+
</div>
|
|
51
|
+
</div>
|
|
52
|
+
</body>
|
|
53
|
+
</html>
|
data/config/routes.rb
ADDED
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
CurrentScope::Engine.routes.draw do
|
|
2
|
+
root to: "roles#index"
|
|
3
|
+
|
|
4
|
+
resources :roles, except: :show do
|
|
5
|
+
member { get :members }
|
|
6
|
+
end
|
|
7
|
+
resources :subjects, only: :index
|
|
8
|
+
resources :events, only: :index
|
|
9
|
+
resource :role_assignment, only: :create
|
|
10
|
+
# Remove one org-wide assignment by id (members page: clean up an orphan whose
|
|
11
|
+
# subject was deleted, which the subject-keyed clear on create can't target).
|
|
12
|
+
delete "role_assignments/:id" => "role_assignments#destroy", as: :remove_role_assignment
|
|
13
|
+
resources :scoped_role_assignments, only: [ :new, :create, :destroy ]
|
|
14
|
+
end
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
class CreateCurrentScopeTables < ActiveRecord::Migration[7.1]
|
|
2
|
+
def change
|
|
3
|
+
create_table :current_scope_roles do |t|
|
|
4
|
+
t.string :name, null: false, index: { unique: true }
|
|
5
|
+
t.boolean :full_access, null: false, default: false
|
|
6
|
+
t.timestamps
|
|
7
|
+
end
|
|
8
|
+
|
|
9
|
+
create_table :current_scope_role_permissions do |t|
|
|
10
|
+
t.references :role, null: false, foreign_key: { to_table: :current_scope_roles }
|
|
11
|
+
t.string :permission_key, null: false
|
|
12
|
+
t.index [ :role_id, :permission_key ], unique: true
|
|
13
|
+
end
|
|
14
|
+
|
|
15
|
+
create_table :current_scope_role_assignments do |t|
|
|
16
|
+
t.references :subject, polymorphic: true, null: false,
|
|
17
|
+
index: { unique: true, name: "index_current_scope_one_role_per_subject" }
|
|
18
|
+
t.references :role, null: false, foreign_key: { to_table: :current_scope_roles }
|
|
19
|
+
t.timestamps
|
|
20
|
+
end
|
|
21
|
+
|
|
22
|
+
create_table :current_scope_scoped_role_assignments do |t|
|
|
23
|
+
t.references :subject, polymorphic: true, null: false
|
|
24
|
+
t.references :role, null: false, foreign_key: { to_table: :current_scope_roles }
|
|
25
|
+
t.references :resource, polymorphic: true, null: false
|
|
26
|
+
t.timestamps
|
|
27
|
+
t.index [ :subject_type, :subject_id, :resource_type, :resource_id, :role_id ],
|
|
28
|
+
unique: true, name: "index_current_scope_unique_scoped_assignment"
|
|
29
|
+
end
|
|
30
|
+
end
|
|
31
|
+
end
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
class CreateCurrentScopeEvents < ActiveRecord::Migration[7.1]
|
|
2
|
+
# Append-only authorization event ledger. This migration ships to arbitrary
|
|
3
|
+
# hosts and is NEVER renamed after release, so the schema shape is FROZEN.
|
|
4
|
+
#
|
|
5
|
+
# NORMATIVE target mapping (mirrored in the Event model header):
|
|
6
|
+
# - assignment events (org_role.*, scoped_role.*): target = the GRANTEE
|
|
7
|
+
# (the subject being granted). The role/resource ride in `details`.
|
|
8
|
+
# - role.* events: target = the role itself.
|
|
9
|
+
def change
|
|
10
|
+
create_table :current_scope_events do |t|
|
|
11
|
+
t.string :event, null: false # namespaced past-tense name (role.created, permission.granted, ...)
|
|
12
|
+
t.string :actor, null: false # GlobalID of the REAL actor (never nil: record! raises first)
|
|
13
|
+
t.string :subject, null: false # GlobalID of the effective subject; ALWAYS set (== actor unless impersonating)
|
|
14
|
+
t.string :target, null: false # GlobalID of the thing acted on (see normative mapping above)
|
|
15
|
+
t.string :target_label, null: false # denormalized human label so history survives target deletion
|
|
16
|
+
# json (NOT jsonb): an opaque, append-only payload the engine never
|
|
17
|
+
# queries, so it needs no index/operator support — and plain json is the
|
|
18
|
+
# portable type across SQLite / PostgreSQL / MySQL.
|
|
19
|
+
t.json :details
|
|
20
|
+
t.string :request_id # correlation-only, not evidence (client-suppliable via X-Request-Id)
|
|
21
|
+
# created_at only — declared explicitly, NOT via t.timestamps. There is
|
|
22
|
+
# deliberately no updated_at: its absence documents append-only intent.
|
|
23
|
+
t.datetime :created_at, null: false
|
|
24
|
+
|
|
25
|
+
# Append-only + host-side retention => unbounded growth, so unindexed
|
|
26
|
+
# scans are forever. Newest-first reads use order(id: :desc) (append-only
|
|
27
|
+
# => id order == commit order), so no created_at index is needed.
|
|
28
|
+
t.index :target
|
|
29
|
+
t.index :actor
|
|
30
|
+
end
|
|
31
|
+
end
|
|
32
|
+
end
|
|
@@ -0,0 +1,181 @@
|
|
|
1
|
+
module CurrentScope
|
|
2
|
+
class Configuration
|
|
3
|
+
# Host controller method that returns the authenticated subject.
|
|
4
|
+
attr_accessor :user_method
|
|
5
|
+
|
|
6
|
+
# Host controller method returning the REAL actor while impersonating
|
|
7
|
+
# (the pretender's true_user). nil means actor == user (no impersonation),
|
|
8
|
+
# so the actor is never resolved and falls back to the subject.
|
|
9
|
+
attr_accessor :actor_method
|
|
10
|
+
|
|
11
|
+
# Action names subject to the separation-of-duties veto: whoever initiated
|
|
12
|
+
# a record can never perform these actions on it. Not editable in the UI
|
|
13
|
+
# by design — SoD is a structural guarantee, not a preference. Records hit
|
|
14
|
+
# by these actions must define current_scope_initiator (return nil to
|
|
15
|
+
# exempt a record type).
|
|
16
|
+
#
|
|
17
|
+
# EMPTY BY DEFAULT — SoD is opt-in. The engine's baseline is scoped RBAC;
|
|
18
|
+
# many hosts want nothing to do with four-eyes. Enable it by listing the
|
|
19
|
+
# actions to gate, e.g. `config.sod_actions = %w[approve]`.
|
|
20
|
+
attr_accessor :sod_actions
|
|
21
|
+
|
|
22
|
+
# Which identities the separation-of-duties veto weighs:
|
|
23
|
+
# :either (default) — veto if the effective subject OR (while
|
|
24
|
+
# impersonating) the REAL actor initiated the record,
|
|
25
|
+
# so impersonation can never approve the actor's own
|
|
26
|
+
# record.
|
|
27
|
+
# :subject — veto only on the effective subject.
|
|
28
|
+
# The two are identical when not impersonating (actor == subject).
|
|
29
|
+
attr_accessor :sod_identity
|
|
30
|
+
|
|
31
|
+
# When false (the default), an impersonated session is read-only: any
|
|
32
|
+
# non-GET/HEAD request is denied while a real actor acts as a different
|
|
33
|
+
# subject — INCLUDING the engine's own management UI. The host's
|
|
34
|
+
# stop-impersonation, sign-out, and sign-in endpoints must opt out with
|
|
35
|
+
# skip_before_action :current_scope_mutation_guard!, or impersonation can
|
|
36
|
+
# never end. The gate runs BEFORE the permission check, so sod_identity is
|
|
37
|
+
# only observable once mutations are allowed (or on a GET-listed sod_action).
|
|
38
|
+
#
|
|
39
|
+
# Setting this true is refused in production unless the env opt-in below is
|
|
40
|
+
# set — letting a real actor write as someone else is a privilege-escalation
|
|
41
|
+
# and audit-integrity risk, so a prod deploy must acknowledge it explicitly.
|
|
42
|
+
# See the custom writer.
|
|
43
|
+
attr_reader :allow_mutations_while_impersonating
|
|
44
|
+
|
|
45
|
+
# Env var that opts a PRODUCTION deploy into impersonated writes. Any value
|
|
46
|
+
# (even "false" or "0" — presence is what counts) lifts the production
|
|
47
|
+
# refusal; unset in production means allow_mutations_while_impersonating=true
|
|
48
|
+
# raises at boot. development/test/staging never consult it.
|
|
49
|
+
PROD_MUTATIONS_ENV = "CURRENT_SCOPE_ALLOW_PROD_IMPERSONATION_MUTATIONS"
|
|
50
|
+
|
|
51
|
+
# Regexps matched against controller paths to keep infrastructure
|
|
52
|
+
# controllers out of the permission grid. An excluded controller cannot be
|
|
53
|
+
# granted, so it must also skip the gate
|
|
54
|
+
# (skip_before_action :current_scope_check!) — Guard raises otherwise.
|
|
55
|
+
attr_accessor :excluded_controllers
|
|
56
|
+
|
|
57
|
+
# Class the management UI's controllers inherit from, so they pick up the
|
|
58
|
+
# host's authentication and layout.
|
|
59
|
+
attr_accessor :parent_controller
|
|
60
|
+
|
|
61
|
+
# Host class acting as the subject, used by the management UI to list
|
|
62
|
+
# assignable subjects.
|
|
63
|
+
attr_accessor :subject_class
|
|
64
|
+
|
|
65
|
+
# How a subject is identified in the management UI (subjects table, picker,
|
|
66
|
+
# bulk bar). A subject id is meaningless with UUID keys, so pick something
|
|
67
|
+
# human. Accepts:
|
|
68
|
+
# - a Symbol — a method on the subject, e.g. :email or :name
|
|
69
|
+
# - a Proc — subject -> String, e.g. ->(u) { "#{u.first_name} #{u.last_name}" }
|
|
70
|
+
# - nil (default) — best-effort, people-first: email, else name, else
|
|
71
|
+
# first+last, else the generic current_scope_label / "Class #id".
|
|
72
|
+
attr_accessor :subject_label
|
|
73
|
+
|
|
74
|
+
# Break-glass override for the SoD veto. Default false — OFF preserves v0.1
|
|
75
|
+
# exactly: the separation-of-duties veto is absolute and this hook is never
|
|
76
|
+
# consulted. When true, the veto is lifted for a record ONLY when all three
|
|
77
|
+
# hold, re-checked live at decision time: this flag is on, the record's
|
|
78
|
+
# `current_scope_sod_bypassed?` hook returns true, AND the record's initiator
|
|
79
|
+
# holds the bypass permission (sod_bypass_permission). Every lifted veto is
|
|
80
|
+
# recorded by the engine (`sod.bypassed`) and surfaced on
|
|
81
|
+
# X-Current-Scope-Reason.
|
|
82
|
+
#
|
|
83
|
+
# HONEST FRAMING: this converts SoD from a *structural* guarantee into an
|
|
84
|
+
# *audited policy override* — it's break-glass, not SoD. Its legitimacy rests
|
|
85
|
+
# on being default-off, privilege-gated, and always audited. Unlike
|
|
86
|
+
# allow_mutations_while_impersonating there is NO production env-gate: the
|
|
87
|
+
# feature is per-record, privilege-scoped, and audited-by-construction, so
|
|
88
|
+
# production is its intended home.
|
|
89
|
+
attr_accessor :allow_sod_bypass
|
|
90
|
+
|
|
91
|
+
# The grantable permission the record's initiator must hold for a break-glass
|
|
92
|
+
# bypass to lift the veto (default "bypass_sod"). Resolved against the
|
|
93
|
+
# record's route key like any permission, so it's editable in the role grid —
|
|
94
|
+
# never a hardcoded role. Must NOT be listed in sod_actions (it isn't an SoD
|
|
95
|
+
# action; keeping it out also bounds the bypass re-entrancy).
|
|
96
|
+
attr_accessor :sod_bypass_permission
|
|
97
|
+
|
|
98
|
+
# Tri-state: false | true (default) | :strict — controls
|
|
99
|
+
# CurrentScope::Event.record!.
|
|
100
|
+
# false — record! is a silent no-op; hosts that don't want the ledger
|
|
101
|
+
# set this and skip the events migration.
|
|
102
|
+
# true — append a row for every event; if the current_scope_events
|
|
103
|
+
# table hasn't been migrated yet, degrade gracefully (skip +
|
|
104
|
+
# warn once), so an existing host never breaks on first mutation.
|
|
105
|
+
# :strict — an audit-mandatory host: a missing events table RAISES instead
|
|
106
|
+
# of degrading, so a mutation-wrapping transaction rolls back
|
|
107
|
+
# rather than committing an unaudited grant. (Impersonation-
|
|
108
|
+
# boundary events have no mutation to roll back — a raise there
|
|
109
|
+
# is a loud 500 on a mis-migrated host.)
|
|
110
|
+
# Read as `== :strict`, never `== true` — don't flatten the tri-state.
|
|
111
|
+
attr_accessor :audit
|
|
112
|
+
|
|
113
|
+
# A5 (opt-in, dev/test aid): when true, the gate logs a nudge if an SoD
|
|
114
|
+
# action is gated with a nil record and the request is allowed — i.e. the
|
|
115
|
+
# SoD veto was silently skipped because current_scope_record returned nil on
|
|
116
|
+
# a member action. Off by default; prod behavior never changes. Emitted from
|
|
117
|
+
# the Guard seam (not the shared resolver), so it doesn't fire on advisory
|
|
118
|
+
# allowed_to?/scope_for calls.
|
|
119
|
+
attr_accessor :warn_on_nil_sod_record
|
|
120
|
+
|
|
121
|
+
# How the role-editor grid folds RESTful actions into columns. An ordered
|
|
122
|
+
# Hash of { column_label => [action names] }: ticking a group column grants
|
|
123
|
+
# every routed action in it. The default collapses the seven RESTful verbs
|
|
124
|
+
# into CRUD — new/create and edit/update pair up (the "new"/"edit" actions
|
|
125
|
+
# just render the form for their mutation), index+show read as one. Actions
|
|
126
|
+
# not in any group (e.g. "approve") get their own column. Set to nil (or {})
|
|
127
|
+
# to show every raw action as its own column instead. Either way the grid
|
|
128
|
+
# renders ALIGNED columns — a controller that doesn't route a column's
|
|
129
|
+
# actions shows a blank cell, never a shifted one.
|
|
130
|
+
attr_accessor :permission_grid_groups
|
|
131
|
+
|
|
132
|
+
def initialize
|
|
133
|
+
@user_method = :current_user
|
|
134
|
+
@actor_method = nil
|
|
135
|
+
@sod_actions = []
|
|
136
|
+
@sod_identity = :either
|
|
137
|
+
@allow_sod_bypass = false
|
|
138
|
+
@sod_bypass_permission = "bypass_sod"
|
|
139
|
+
@allow_mutations_while_impersonating = false
|
|
140
|
+
@excluded_controllers = [
|
|
141
|
+
%r{\Arails/}, %r{\Aactive_storage/}, %r{\Aaction_mailbox/},
|
|
142
|
+
%r{\Aturbo/}, %r{\Acurrent_scope/}
|
|
143
|
+
]
|
|
144
|
+
@parent_controller = "::ApplicationController"
|
|
145
|
+
@subject_class = "User"
|
|
146
|
+
@audit = true
|
|
147
|
+
@warn_on_nil_sod_record = false
|
|
148
|
+
@permission_grid_groups = {
|
|
149
|
+
"read" => %w[index show],
|
|
150
|
+
"create" => %w[new create],
|
|
151
|
+
"update" => %w[edit update],
|
|
152
|
+
"destroy" => %w[destroy]
|
|
153
|
+
}
|
|
154
|
+
end
|
|
155
|
+
|
|
156
|
+
# Guarded writer: enabling impersonated writes is fine in
|
|
157
|
+
# development/test/staging, but refused in production unless PROD_MUTATIONS_ENV
|
|
158
|
+
# is set — so an unsafe flag fails the deploy loudly at boot instead of
|
|
159
|
+
# silently letting a real actor mutate data as someone else. Assigning false
|
|
160
|
+
# (or leaving the default) is always allowed.
|
|
161
|
+
def allow_mutations_while_impersonating=(value)
|
|
162
|
+
if value && production? && !ENV.key?(PROD_MUTATIONS_ENV)
|
|
163
|
+
raise ConfigurationError,
|
|
164
|
+
"config.allow_mutations_while_impersonating = true is refused in " \
|
|
165
|
+
"production: letting a real actor write as the subject they " \
|
|
166
|
+
"impersonate is a privilege-escalation and audit-integrity risk. " \
|
|
167
|
+
"Keep it false so impersonated sessions stay read-only in " \
|
|
168
|
+
"production, or — only if writes under act-as are genuinely " \
|
|
169
|
+
"required (e.g. a live public showcase) — set ENV[\"#{PROD_MUTATIONS_ENV}\"] " \
|
|
170
|
+
"to opt in explicitly. development, test, and staging are unaffected."
|
|
171
|
+
end
|
|
172
|
+
@allow_mutations_while_impersonating = value
|
|
173
|
+
end
|
|
174
|
+
|
|
175
|
+
private
|
|
176
|
+
|
|
177
|
+
def production?
|
|
178
|
+
defined?(Rails) && Rails.respond_to?(:env) && Rails.env.production?
|
|
179
|
+
end
|
|
180
|
+
end
|
|
181
|
+
end
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
module CurrentScope
|
|
2
|
+
# Include in ApplicationController (before Guard). Populates the ambient
|
|
3
|
+
# context from the host's authentication once per request and exposes
|
|
4
|
+
# allowed_to? to actions and views. CurrentAttributes resets itself around
|
|
5
|
+
# every request and job, so nothing leaks between executions.
|
|
6
|
+
module Context
|
|
7
|
+
extend ActiveSupport::Concern
|
|
8
|
+
include Permissions
|
|
9
|
+
|
|
10
|
+
included do
|
|
11
|
+
before_action :set_current_scope_user
|
|
12
|
+
if respond_to?(:helper_method)
|
|
13
|
+
helper_method :allowed_to?, :scope_for, :current_scope_user, :current_scope_actor, :impersonating?
|
|
14
|
+
end
|
|
15
|
+
end
|
|
16
|
+
|
|
17
|
+
private
|
|
18
|
+
|
|
19
|
+
def set_current_scope_user
|
|
20
|
+
CurrentScope::Current.user = resolve_current_scope_subject(CurrentScope.config.user_method)
|
|
21
|
+
|
|
22
|
+
# nil actor_method means no impersonation: leave actor unset so Current
|
|
23
|
+
# falls back to the subject. Only resolve when the host opts in.
|
|
24
|
+
actor_method = CurrentScope.config.actor_method
|
|
25
|
+
CurrentScope::Current.actor = resolve_current_scope_subject(actor_method) if actor_method
|
|
26
|
+
end
|
|
27
|
+
|
|
28
|
+
def resolve_current_scope_subject(method)
|
|
29
|
+
return send(method) if respond_to?(method, true)
|
|
30
|
+
|
|
31
|
+
raise CurrentScope::ConfigurationError,
|
|
32
|
+
"#{self.class.name} does not respond to ##{method}. Define it, or point the " \
|
|
33
|
+
"matching CurrentScope.config.*_method at your method."
|
|
34
|
+
end
|
|
35
|
+
end
|
|
36
|
+
end
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
module CurrentScope
|
|
2
|
+
class Engine < ::Rails::Engine
|
|
3
|
+
isolate_namespace CurrentScope
|
|
4
|
+
|
|
5
|
+
# Routes (and therefore the derived permission catalog) can change on
|
|
6
|
+
# every code reload in development, and reloaded host models must re-register
|
|
7
|
+
# as scopeable rather than pile up stale/duplicate entries. Both reset here,
|
|
8
|
+
# ahead of eager-load, so the registry rebuilds cleanly.
|
|
9
|
+
config.to_prepare do
|
|
10
|
+
CurrentScope.reset_catalog!
|
|
11
|
+
CurrentScope.reset_scopeable_registry!
|
|
12
|
+
end
|
|
13
|
+
end
|
|
14
|
+
end
|
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
module CurrentScope
|
|
2
|
+
# A4: an opt-in dev/test tripwire that catches an action which completed
|
|
3
|
+
# WITHOUT being gated by Guard's current_scope_check!. Include it on a base
|
|
4
|
+
# controller you want verified — INCLUDING one that never includes Guard (an
|
|
5
|
+
# API base, a hand-rolled ActionController::Base), which is exactly the
|
|
6
|
+
# ungated case Guard's own after_action could never see.
|
|
7
|
+
#
|
|
8
|
+
# class ApiController < ActionController::Base
|
|
9
|
+
# include CurrentScope::GatingTripwire
|
|
10
|
+
# current_scope_skip_tripwire! only: :health # mark genuinely-public actions
|
|
11
|
+
# end
|
|
12
|
+
#
|
|
13
|
+
# It owns its skip API on purpose: it can NOT reuse
|
|
14
|
+
# `skip_before_action :current_scope_check!`, because on a controller that
|
|
15
|
+
# never included Guard that callback is undefined and skip_before_action
|
|
16
|
+
# raises ArgumentError at class load — self-defeating on the very controllers
|
|
17
|
+
# the tripwire targets. `current_scope_skip_tripwire!` skips the tripwire's own
|
|
18
|
+
# after_action instead.
|
|
19
|
+
#
|
|
20
|
+
# Known blind spot: an after_action does not run when a before_action halts the
|
|
21
|
+
# chain (render/redirect) — so an action that renders straight from a
|
|
22
|
+
# before_action escapes the tripwire. It is a strong dev/test aid, not total
|
|
23
|
+
# coverage (Grape/Rack endpoints are out of reach for the same reason).
|
|
24
|
+
module GatingTripwire
|
|
25
|
+
extend ActiveSupport::Concern
|
|
26
|
+
|
|
27
|
+
included do
|
|
28
|
+
after_action :current_scope_verify_gated!
|
|
29
|
+
end
|
|
30
|
+
|
|
31
|
+
class_methods do
|
|
32
|
+
# The tripwire's OWN exempt marker. Accepts the usual callback filters
|
|
33
|
+
# (only:/except:) and simply skips the tripwire after_action for them.
|
|
34
|
+
def current_scope_skip_tripwire!(**options)
|
|
35
|
+
skip_after_action :current_scope_verify_gated!, **options
|
|
36
|
+
end
|
|
37
|
+
end
|
|
38
|
+
|
|
39
|
+
private
|
|
40
|
+
|
|
41
|
+
def current_scope_verify_gated!
|
|
42
|
+
# Guard sets @current_scope_checked when current_scope_check! runs. A
|
|
43
|
+
# controller carrying only this mixin (no Guard) never sets it.
|
|
44
|
+
return if instance_variable_defined?(:@current_scope_checked) && @current_scope_checked
|
|
45
|
+
|
|
46
|
+
raise CurrentScope::ConfigurationError,
|
|
47
|
+
"\"#{controller_path}##{action_name}\" completed without running current_scope_check! — " \
|
|
48
|
+
"this controller is not gated by CurrentScope::Guard. Include CurrentScope::Guard on its " \
|
|
49
|
+
"base controller, or mark this action public with " \
|
|
50
|
+
"`current_scope_skip_tripwire! only: :#{action_name}`."
|
|
51
|
+
end
|
|
52
|
+
end
|
|
53
|
+
end
|
|
@@ -0,0 +1,97 @@
|
|
|
1
|
+
module CurrentScope
|
|
2
|
+
# The enforcement point. Include after Context to gate every action behind
|
|
3
|
+
# its own permission: the current controller#action IS the permission key,
|
|
4
|
+
# so new controllers are gated (fail-closed) the moment they exist.
|
|
5
|
+
#
|
|
6
|
+
# Member actions that need record-level decisions (scoped roles, SoD)
|
|
7
|
+
# declare a private current_scope_record method returning the record. Three
|
|
8
|
+
# rules for the hook:
|
|
9
|
+
# - it runs for EVERY gated action, collection actions included — return
|
|
10
|
+
# nil when there is no record
|
|
11
|
+
# - it runs BEFORE the controller's own before_actions, so it must load
|
|
12
|
+
# the record itself (memoize so set_* callbacks reuse it)
|
|
13
|
+
# - key off request.path_parameters, NEVER params: a query-string ?id=
|
|
14
|
+
# must not let a scoped role on one record unlock a collection action
|
|
15
|
+
#
|
|
16
|
+
# def current_scope_record
|
|
17
|
+
# set_report if request.path_parameters[:id]
|
|
18
|
+
# end
|
|
19
|
+
#
|
|
20
|
+
# Skip the gate for public endpoints with skip_before_action :current_scope_check!.
|
|
21
|
+
# MutationGuard (included here) adds the read-only-while-impersonating gate as
|
|
22
|
+
# its OWN before_action, so it runs first and survives that skip.
|
|
23
|
+
module Guard
|
|
24
|
+
extend ActiveSupport::Concern
|
|
25
|
+
include MutationGuard
|
|
26
|
+
|
|
27
|
+
included do
|
|
28
|
+
before_action :current_scope_check!
|
|
29
|
+
end
|
|
30
|
+
|
|
31
|
+
private
|
|
32
|
+
|
|
33
|
+
def current_scope_check!
|
|
34
|
+
# Record that the gate ran, so an optional GatingTripwire (A4) can tell a
|
|
35
|
+
# gated action from one on a controller that never included Guard.
|
|
36
|
+
@current_scope_checked = true
|
|
37
|
+
permission = "#{controller_path}##{action_name}"
|
|
38
|
+
|
|
39
|
+
# An excluded controller can never be granted in the grid, so gating it
|
|
40
|
+
# would lock it to full_access forever — a misconfiguration, not a deny.
|
|
41
|
+
unless CurrentScope.catalog.include?(permission)
|
|
42
|
+
raise CurrentScope::ConfigurationError,
|
|
43
|
+
"\"#{permission}\" is not in the permission catalog (excluded_controllers " \
|
|
44
|
+
"or not routed). Either stop excluding it, or skip the gate here with " \
|
|
45
|
+
"skip_before_action :current_scope_check!."
|
|
46
|
+
end
|
|
47
|
+
|
|
48
|
+
record = respond_to?(:current_scope_record, true) ? send(:current_scope_record) : nil
|
|
49
|
+
|
|
50
|
+
# The real actor (Current.actor) enters here explicitly — the resolver
|
|
51
|
+
# never reads Current itself (PDP purity). It only matters under SoD
|
|
52
|
+
# :either while impersonating; otherwise actor == subject.
|
|
53
|
+
allowed, reason = CurrentScope.resolver.decide(
|
|
54
|
+
subject: CurrentScope::Current.user, permission: permission,
|
|
55
|
+
record: record, actor: CurrentScope::Current.actor
|
|
56
|
+
)
|
|
57
|
+
raise CurrentScope::AccessDenied.new(permission, reason: reason) unless allowed
|
|
58
|
+
|
|
59
|
+
record_sod_bypass(permission, record) if reason == :sod_bypassed
|
|
60
|
+
nudge_on_nil_sod_record(permission, record)
|
|
61
|
+
end
|
|
62
|
+
|
|
63
|
+
# Break-glass audit (KTD-1): the resolver stays pure and only reports
|
|
64
|
+
# :sod_bypassed; the Guard — which runs once per REAL gated action, never on
|
|
65
|
+
# advisory allowed_to?/scope_for — records the override exactly once and
|
|
66
|
+
# surfaces it on the response. Recorded for ANY verb: the guarantee is
|
|
67
|
+
# "every bypass is audited", so if a host ever routes an SoD action to GET,
|
|
68
|
+
# the bypass still leaves its trail rather than slipping through unlogged.
|
|
69
|
+
# Event.record! is a no-op when config.audit is false, so an audit-off host
|
|
70
|
+
# still permits, records nothing — consistent with the rest of the ledger.
|
|
71
|
+
def record_sod_bypass(permission, record)
|
|
72
|
+
initiator = record.send(CurrentScope::Resolver::INITIATOR_METHOD)
|
|
73
|
+
CurrentScope::Event.record!(
|
|
74
|
+
event: "sod.bypassed", target: record,
|
|
75
|
+
details: { permission: permission, initiator: initiator&.to_gid&.to_s }
|
|
76
|
+
)
|
|
77
|
+
response.set_header("X-Current-Scope-Reason", "sod_bypassed")
|
|
78
|
+
end
|
|
79
|
+
|
|
80
|
+
# A5 dev/test aid (opt-in): the request was ALLOWED, but if it's an SoD
|
|
81
|
+
# action gated with a nil record, the SoD veto was silently skipped — a sign
|
|
82
|
+
# current_scope_record returned nil on a member action. Lives here (the gate
|
|
83
|
+
# seam), not in the shared resolver, so it never fires on advisory
|
|
84
|
+
# allowed_to?/scope_for calls. Prod behavior is unchanged either way.
|
|
85
|
+
def nudge_on_nil_sod_record(permission, record)
|
|
86
|
+
return unless CurrentScope.config.warn_on_nil_sod_record
|
|
87
|
+
return unless record.nil?
|
|
88
|
+
return unless CurrentScope.config.sod_actions.include?(permission.split("#").last)
|
|
89
|
+
|
|
90
|
+
Rails.logger&.warn(
|
|
91
|
+
"[CurrentScope] \"#{permission}\" is a separation-of-duties action but was gated with a " \
|
|
92
|
+
"nil record, so the SoD veto was skipped. If this is a member action, current_scope_record " \
|
|
93
|
+
"must return the record; if it's a collection action, this is expected."
|
|
94
|
+
)
|
|
95
|
+
end
|
|
96
|
+
end
|
|
97
|
+
end
|