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.
Files changed (50) hide show
  1. checksums.yaml +7 -0
  2. data/MIT-LICENSE +20 -0
  3. data/README.md +577 -0
  4. data/Rakefile +6 -0
  5. data/app/assets/javascripts/current_scope/application.js +225 -0
  6. data/app/assets/stylesheets/current_scope/application.css +707 -0
  7. data/app/controllers/current_scope/application_controller.rb +54 -0
  8. data/app/controllers/current_scope/events_controller.rb +14 -0
  9. data/app/controllers/current_scope/role_assignments_controller.rb +104 -0
  10. data/app/controllers/current_scope/roles_controller.rb +135 -0
  11. data/app/controllers/current_scope/scoped_role_assignments_controller.rb +159 -0
  12. data/app/controllers/current_scope/subjects_controller.rb +57 -0
  13. data/app/helpers/current_scope/application_helper.rb +72 -0
  14. data/app/models/current_scope/application_record.rb +5 -0
  15. data/app/models/current_scope/current.rb +50 -0
  16. data/app/models/current_scope/event.rb +117 -0
  17. data/app/models/current_scope/role.rb +55 -0
  18. data/app/models/current_scope/role_assignment.rb +21 -0
  19. data/app/models/current_scope/role_permission.rb +9 -0
  20. data/app/models/current_scope/scoped_role_assignment.rb +14 -0
  21. data/app/views/current_scope/events/index.html.erb +41 -0
  22. data/app/views/current_scope/roles/edit.html.erb +86 -0
  23. data/app/views/current_scope/roles/index.html.erb +41 -0
  24. data/app/views/current_scope/roles/members.html.erb +96 -0
  25. data/app/views/current_scope/roles/new.html.erb +19 -0
  26. data/app/views/current_scope/scoped_role_assignments/new.html.erb +135 -0
  27. data/app/views/current_scope/subjects/index.html.erb +100 -0
  28. data/app/views/layouts/current_scope/application.html.erb +53 -0
  29. data/config/routes.rb +14 -0
  30. data/db/migrate/20260710000001_create_current_scope_tables.rb +31 -0
  31. data/db/migrate/20260710000002_create_current_scope_events.rb +32 -0
  32. data/db/migrate/20260714000001_add_description_to_current_scope_roles.rb +5 -0
  33. data/lib/current_scope/configuration.rb +181 -0
  34. data/lib/current_scope/context.rb +36 -0
  35. data/lib/current_scope/engine.rb +14 -0
  36. data/lib/current_scope/gating_tripwire.rb +53 -0
  37. data/lib/current_scope/guard.rb +97 -0
  38. data/lib/current_scope/mutation_guard.rb +55 -0
  39. data/lib/current_scope/permission_catalog.rb +33 -0
  40. data/lib/current_scope/permission_grid.rb +90 -0
  41. data/lib/current_scope/permissions.rb +57 -0
  42. data/lib/current_scope/resolver.rb +171 -0
  43. data/lib/current_scope/scopeable.rb +38 -0
  44. data/lib/current_scope/test_helpers.rb +53 -0
  45. data/lib/current_scope/version.rb +3 -0
  46. data/lib/current_scope.rb +168 -0
  47. data/lib/generators/current_scope/install/install_generator.rb +33 -0
  48. data/lib/generators/current_scope/install/templates/initializer.rb +77 -0
  49. data/lib/tasks/current_scope_tasks.rake +15 -0
  50. 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,5 @@
1
+ class AddDescriptionToCurrentScopeRoles < ActiveRecord::Migration[7.1]
2
+ def change
3
+ add_column :current_scope_roles, :description, :text
4
+ end
5
+ 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