current_scope 0.2.0 → 0.3.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (35) hide show
  1. checksums.yaml +4 -4
  2. data/README.md +317 -18
  3. data/app/assets/javascripts/current_scope/application.js +4 -0
  4. data/app/assets/stylesheets/current_scope/application.css +99 -0
  5. data/app/controllers/current_scope/application_controller.rb +39 -1
  6. data/app/controllers/current_scope/role_assignments_controller.rb +14 -14
  7. data/app/controllers/current_scope/roles_controller.rb +6 -2
  8. data/app/helpers/current_scope/application_helper.rb +168 -12
  9. data/app/models/current_scope/current.rb +24 -0
  10. data/app/models/current_scope/event.rb +10 -6
  11. data/app/models/current_scope/role.rb +61 -10
  12. data/app/views/current_scope/roles/edit.html.erb +92 -4
  13. data/app/views/current_scope/roles/members.html.erb +3 -3
  14. data/app/views/current_scope/roles/new.html.erb +1 -1
  15. data/app/views/current_scope/scoped_role_assignments/new.html.erb +12 -3
  16. data/app/views/current_scope/shared/access_denied.html.erb +30 -0
  17. data/app/views/current_scope/subjects/index.html.erb +17 -5
  18. data/app/views/layouts/current_scope/application.html.erb +4 -1
  19. data/config/routes.rb +3 -4
  20. data/lib/current_scope/configuration.rb +378 -16
  21. data/lib/current_scope/engine.rb +7 -0
  22. data/lib/current_scope/gating_reflection.rb +62 -0
  23. data/lib/current_scope/gating_tripwire.rb +36 -5
  24. data/lib/current_scope/guard.rb +406 -8
  25. data/lib/current_scope/mutation_guard.rb +30 -5
  26. data/lib/current_scope/permission_catalog.rb +116 -3
  27. data/lib/current_scope/permission_grid.rb +34 -4
  28. data/lib/current_scope/permissions.rb +42 -8
  29. data/lib/current_scope/resolver.rb +317 -13
  30. data/lib/current_scope/version.rb +1 -1
  31. data/lib/current_scope.rb +113 -5
  32. data/lib/generators/current_scope/install/install_generator.rb +64 -0
  33. data/lib/generators/current_scope/install/templates/initializer.rb +94 -5
  34. data/lib/tasks/current_scope_tasks.rake +153 -0
  35. metadata +6 -2
@@ -1,4 +1,39 @@
1
1
  CurrentScope.configure do |config|
2
+ # --- Retrofitting an existing app? Start here ---------------------------
3
+ #
4
+ # This engine is fail-closed: once the gate is mounted, anything not granted
5
+ # is denied. In an app that already has users and traffic, that means your
6
+ # controller suite goes RED and your users get 403s the moment you mount it —
7
+ # not because anything is misconfigured, but because no grants exist yet.
8
+ #
9
+ # So don't cut over blind. Run in report mode first:
10
+ #
11
+ # config.enforcement = :report
12
+ #
13
+ # The gate then LOGS what it would have denied and lets the request through.
14
+ # Exercise the app (or run your suite), then read the gaps back out — each row
15
+ # names a subject and the permission they were missing:
16
+ #
17
+ # bin/rails current_scope:report
18
+ #
19
+ # That list IS your grant-seeding work. Seed the roles it names, watch the
20
+ # would_deny rows stop appearing, then flip to :enforce. Reversible at every
21
+ # step — it's one line back.
22
+ #
23
+ # Report mode is an ADOPTION ramp, not a way to run in production. It relaxes
24
+ # exactly one thing: "nobody has granted this yet". A separation-of-duties
25
+ # veto still refuses, and the management console is never opened by it.
26
+ #
27
+ # config.enforcement = :enforce # :enforce (default) | :report
28
+ # ------------------------------------------------------------------------
29
+
30
+ # What the opt-in GatingTripwire mixin does when it catches an action that
31
+ # completed WITHOUT running the gate. :raise fails loudly (CI goes red);
32
+ # :warn logs once per controller#action and lets the response through, so a
33
+ # real app can inventory its ungated surface without 500ing. There is no
34
+ # :off — not including CurrentScope::GatingTripwire is off.
35
+ # config.gating_tripwire = Rails.env.local? ? :raise : :warn
36
+
2
37
  # Controller method that returns the authenticated subject.
3
38
  # config.user_method = :current_user
4
39
 
@@ -17,6 +52,26 @@ CurrentScope.configure do |config|
17
52
  # config.permission_grid_groups = { "read" => %w[index show], "create" => %w[new create],
18
53
  # "update" => %w[edit update], "destroy" => %w[destroy] }
19
54
 
55
+ # Action names whose record-less gate derives its answer from the scoped
56
+ # list: for these, "may they open this list?" is answered by the same
57
+ # id-narrowed query scope_for renders from, so a scoped full_access role
58
+ # ("Owner of Report #7") opens exactly the collections that would show it
59
+ # records — gate and list agree by construction. Matched on the action
60
+ # segment of the key, like sod_actions. Default ["index"]; set [] to
61
+ # restore the pre-#65 behavior (explicit ticks still open type-bound
62
+ # record-less gates; scoped full_access opens none). A full key
63
+ # ("reports#index") raises — the list is action-segment matched, app-wide —
64
+ # and a canonical mutating name (create/update/destroy) warns at assignment.
65
+ #
66
+ # LIST-NARROWING READS ONLY: the safety of honoring full_access here comes
67
+ # from the answer being derived from record ids, so it is only sound for
68
+ # actions with a list side. Never name a mutating action ("create",
69
+ # "destroy_all") — that would hand a scoped full_access holder the action
70
+ # on every record of the type off a grant on one record. Custom read
71
+ # actions (export, search) are the intended additions. The declared
72
+ # current_scope_model is trusted like current_scope_record: review both.
73
+ # config.collection_read_actions = %w[index]
74
+
20
75
  # --- Impersonation (act-as) ---------------------------------------------
21
76
  # These three knobs layer, in this order:
22
77
  #
@@ -61,11 +116,45 @@ CurrentScope.configure do |config|
61
116
  # audit-mandatory app never commits an unaudited change.
62
117
  # config.audit = true
63
118
 
64
- # Dev/test aid: log a nudge when an SoD action is ALLOWED but was gated with a
65
- # nil record i.e. the SoD veto was silently skipped because
66
- # current_scope_record returned nil on a member action. Off by default; never
67
- # changes behavior.
68
- # config.warn_on_nil_sod_record = false
119
+ # --- Dev diagnostics -----------------------------------------------------
120
+ # Four failure modes this engine has that are SILENT, and silent in the bad
121
+ # direction the thing going wrong looks exactly like the thing going right.
122
+ # All four are LOG-ONLY (no decision, exception, header, or audit row changes)
123
+ # and all four default ON in development and test, OFF in production.
124
+ #
125
+ # They are listed here rather than left to the docs on purpose: a named flag in
126
+ # your initializer is how you learn the failure mode exists at all.
127
+
128
+ # The SoD veto was SKIPPED because the gate had no record — an SoD member
129
+ # action whose current_scope_record returned nil (or was never declared). The
130
+ # request was ALLOWED, and a skipped veto looks identical to a veto that
131
+ # passed. The gem's #1 foot-gun.
132
+ # config.warn_on_nil_sod_record = Rails.env.local?
133
+
134
+ # Denied "no_grant", but the subject holds a scoped grant that WOULD have
135
+ # applied — and the controller declares no current_scope_record, so the gate
136
+ # had no record to apply it to. A member action that forgot its hook: it fails
137
+ # closed (correctly), but the 403 is indistinguishable from "never granted", so
138
+ # you go and stare at the grants, which are fine.
139
+ # config.warn_on_inert_scoped_grant = Rails.env.local?
140
+
141
+ # Short-form allowed_to?(:show, record) derived a DIFFERENT key than the gate
142
+ # on the current controller enforces (the namespaced/custom-named controller
143
+ # foot-gun): a link that 403s, or a hidden one that would have worked. A hint,
144
+ # not an accusation — asking about another resource derives a different key too,
145
+ # and that's correct — so it warns once per site and names both readings.
146
+ # config.warn_on_cross_controller_derivation = Rails.env.local?
147
+
148
+ # Denied "model_undeclared": a collection action declared with
149
+ # `current_scope_record = nil` on a controller that names no
150
+ # current_scope_model, while the subject holds a scoped grant ticking the
151
+ # key. The gate had no type to bind that grant to, so it failed closed —
152
+ # correctly, but the fix is one line: `def current_scope_model = TheType`.
153
+ # The same flag covers "model_invalid" — a declared hook returning
154
+ # something other than a concrete AR class ("Report" for Report); that
155
+ # nudge names the value the hook returned.
156
+ # config.warn_on_undeclared_collection_model = Rails.env.local?
157
+ # ------------------------------------------------------------------------
69
158
 
70
159
  # Controller paths (regexps) excluded from the permission grid. Excluded
71
160
  # controllers can't be granted, so they must also skip the gate with
@@ -12,4 +12,157 @@ namespace :current_scope do
12
12
  CurrentScope.grant!(subject)
13
13
  puts "Granted the full-access Owner role to #{klass}##{subject.id}."
14
14
  end
15
+
16
+ desc "Summarize would-be denials recorded in report mode into a starter role grid. " \
17
+ "Usage: bin/rails current_scope:report"
18
+ task report: :environment do
19
+ # The subject's current org-wide role, when resolvable — the grid reads
20
+ # differently if someone already holds a role that just doesn't tick these
21
+ # keys. Best-effort: a rollout aid must not abort everyone else's summary
22
+ # because one subject's record was deleted or its class no longer loads.
23
+ # A lambda, not a def — a rake file's `def` lands on Object.
24
+ org_role_suffix = lambda do |subject_gid|
25
+ subject = GlobalID::Locator.locate(subject_gid)
26
+ role = subject && CurrentScope::RoleAssignment.find_by(subject: subject)&.role
27
+ role ? " — currently #{role.name}" : ""
28
+ rescue StandardError
29
+ ""
30
+ end
31
+
32
+ begin
33
+ rows = CurrentScope::Event.where(event: "access.would_deny")
34
+ .pluck(:subject, :target_label, :details)
35
+ rescue ActiveRecord::StatementInvalid => e
36
+ # Report mode without the migration records nothing (the ledger degrades and
37
+ # warns once). Reaching for this summary is exactly how a host discovers
38
+ # that, so it must name the fix rather than raise a stack trace at them.
39
+ raise unless e.message.match?(/current_scope_events/i)
40
+
41
+ abort "The current_scope_events table doesn't exist, so nothing was recorded.\n" \
42
+ "Run: bin/rails current_scope:install:migrations && bin/rails db:migrate"
43
+ end
44
+
45
+ if rows.empty?
46
+ # "No output" is indistinguishable from "the task is broken", and the two
47
+ # likeliest causes are both SILENT: report mode never on, or audit off.
48
+ # Name them — this is the first thing a host runs, and an unexplained blank
49
+ # is how they conclude the feature doesn't work.
50
+ puts "No would-be denials recorded."
51
+ puts
52
+ puts " config.enforcement is #{CurrentScope.config.enforcement.inspect} " \
53
+ "(needs :report to record any)"
54
+ puts " config.audit is #{CurrentScope.config.audit.inspect} " \
55
+ "(needs true or :strict — the ledger is where these rows live)"
56
+ puts
57
+ puts "With both on, exercise the app or run your suite, then re-run this."
58
+ next
59
+ end
60
+
61
+ # ponytail: group in Ruby, not SQL. `details` is a JSON column and querying
62
+ # into it is adapter-specific; this is a rollout aid run by hand over a
63
+ # transitional table, so portability beats a smarter query.
64
+ grouped = rows.group_by { |subject, _label, _details| subject }
65
+
66
+ puts "Would-be denials — grant these to stop them (most-denied first):"
67
+ puts
68
+
69
+ grouped.each do |subject_gid, subject_rows|
70
+ label = subject_rows.first[1].presence || subject_gid
71
+ puts " #{label}#{org_role_suffix.call(subject_gid)}"
72
+
73
+ subject_rows
74
+ .group_by { |_s, _l, details| details.is_a?(Hash) ? details["permission"] : nil }
75
+ .transform_values(&:count)
76
+ .sort_by { |permission, count| [ -count, permission.to_s ] }
77
+ .each { |permission, count| puts " #{count.to_s.rjust(5)}x #{permission || '(unknown)'}" }
78
+
79
+ puts
80
+ end
81
+
82
+ puts "Total: #{rows.count} would-be denials across #{grouped.size} subject(s)."
83
+ end
84
+
85
+ desc "Inventory the routed controllers that provably never run the gate — the static " \
86
+ "half of the ungated-surface audit (config.gating_tripwire = :warn is the runtime half). " \
87
+ "Usage: bin/rails current_scope:ungated"
88
+ task ungated: :environment do
89
+ # One reflection for the whole walk — its request object memoizes (KTD-8).
90
+ # A broken controller body's NameError propagates on purpose (KTD-2): a
91
+ # rescue here would report a broken controller as gated.
92
+ gating = CurrentScope::GatingReflection.new
93
+ catalog = CurrentScope.catalog
94
+ grouped = catalog.grouped
95
+
96
+ # The catalog injects the break-glass key onto any row routing an SoD
97
+ # action, and that grant is LIVE even on an ungated controller — honored by
98
+ # whatever gated controller decides SoD on the record (the grid's own
99
+ # KTD-9 exemption). Printing it under "grants nothing" would tell an
100
+ # operator the most sensitive grant in the grid is inert. Strip it from
101
+ # the listing and say so once. Only the INJECTED key is stripped —
102
+ # catalog.routed? keeps a real routed action that merely shares the bypass
103
+ # name in the audit, because omitting it would hide a real fail-open route.
104
+ # The catalog also owns the permission parse (split("#", -1) + shape
105
+ # checks) — a loose split here would accept a malformed value. (#79 review)
106
+ bypass_action = CurrentScope.config.allow_sod_bypass ? catalog.bypass_action : nil
107
+ stripped_bypass = false
108
+
109
+ # Build the printable rows BEFORE deciding emptiness: a synthetic
110
+ # bypass-only row (a namespace-only SoD resource) reflects as "ungated"
111
+ # while routing nothing, and a header over an empty body reads as a broken
112
+ # task. Rows first, then branch on what there is to say.
113
+ rows = grouped.keys.sort.filter_map { |controller|
114
+ next unless gating.ungated?(controller)
115
+
116
+ actions = grouped[controller].sort
117
+ if bypass_action && actions.include?(bypass_action) && !catalog.routed?("#{controller}##{bypass_action}")
118
+ actions -= [ bypass_action ]
119
+ stripped_bypass = true
120
+ end
121
+ next if actions.empty? # nothing routed here — nothing to audit
122
+
123
+ [ controller, actions ]
124
+ }
125
+
126
+ if grouped.empty?
127
+ # A vacuous all-clear is worse than a blank: with nothing routed there
128
+ # was nothing to inspect, and "every routed controller has the callback"
129
+ # is technically true of an empty set and completely misleading.
130
+ puts "No routed controllers found in the permission catalog — nothing was " \
131
+ "inspected. Check your routes and config.excluded_controllers."
132
+ elsif rows.empty?
133
+ # An unexplained blank reads as "the task is broken" — and a bare blank
134
+ # would also overclaim. Claim only what the reflection proved: nothing
135
+ # was PROVEN ungated. A route whose controller doesn't resolve is
136
+ # unclassified, not vouched for (#43 owns that badge) — "every controller
137
+ # has the callback" would vouch for rows nobody inspected.
138
+ puts "No controller was proven ungated. (A routed path whose controller " \
139
+ "does not resolve is unclassified, not verified — see issue #43.)"
140
+ else
141
+ puts "Provably ungated — current_scope_check! is absent from these controllers' " \
142
+ "callback chains, so the gate never runs there:"
143
+ puts
144
+ rows.each { |controller, actions| puts " #{controller} (#{actions.join(', ')})" }
145
+ puts
146
+ puts "Ticking these in the role grid grants nothing until the gate runs. " \
147
+ "If a controller inherited a skip, re-assert before_action " \
148
+ ":current_scope_check! on it; if it never had the gate, include " \
149
+ "CurrentScope::Guard."
150
+ if stripped_bypass
151
+ puts
152
+ puts "(#{bypass_action} omitted from the listing — break-glass stays LIVE " \
153
+ "even on an ungated controller; see the role grid's exempt note.)"
154
+ end
155
+ end
156
+
157
+ # The limit of the proof, stated even when nothing is listed (KTD-3): a
158
+ # conditional skip (skip_before_action only:/except:) leaves the callback
159
+ # PRESENT wearing a condition — unprovable by reflection, so never shown
160
+ # here even though some of its actions really run open. The runtime half
161
+ # catches those.
162
+ puts
163
+ puts "Limit: this lists only what the callback chain PROVES. A conditional skip " \
164
+ "(skip_before_action only:/except:) does not appear here — set " \
165
+ "config.gating_tripwire = :warn and include CurrentScope::GatingTripwire " \
166
+ "to inventory those at runtime."
167
+ end
15
168
  end
metadata CHANGED
@@ -1,7 +1,7 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: current_scope
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.2.0
4
+ version: 0.3.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - David Teren
@@ -32,7 +32,9 @@ dependencies:
32
32
  description: 'A mountable Rails engine for authorization: permissions auto-derived
33
33
  from controller actions, roles as editable data, per-record scoped roles, a separation-of-duties
34
34
  veto, and an ambient authorization context that makes allowed_to? work identically
35
- in controllers, views, and components.'
35
+ in controllers, views, and components. NOT PRODUCTION-READY: pre-1.0 with known
36
+ issues under active work — good for experimentation and spikes, not yet for real
37
+ users. See the README and the issue tracker.'
36
38
  email:
37
39
  - dteren@gmail.com
38
40
  executables: []
@@ -64,6 +66,7 @@ files:
64
66
  - app/views/current_scope/roles/members.html.erb
65
67
  - app/views/current_scope/roles/new.html.erb
66
68
  - app/views/current_scope/scoped_role_assignments/new.html.erb
69
+ - app/views/current_scope/shared/access_denied.html.erb
67
70
  - app/views/current_scope/subjects/index.html.erb
68
71
  - app/views/layouts/current_scope/application.html.erb
69
72
  - config/routes.rb
@@ -74,6 +77,7 @@ files:
74
77
  - lib/current_scope/configuration.rb
75
78
  - lib/current_scope/context.rb
76
79
  - lib/current_scope/engine.rb
80
+ - lib/current_scope/gating_reflection.rb
77
81
  - lib/current_scope/gating_tripwire.rb
78
82
  - lib/current_scope/guard.rb
79
83
  - lib/current_scope/mutation_guard.rb