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,3 +1,8 @@
1
+ # Enumerable#to_set (key_set, the routed-key cache) is stdlib `set`, which a
2
+ # full Rails boot happens to load — a host on active_support.bare would
3
+ # NoMethodError at catalog construction without the explicit require.
4
+ require "set"
5
+
1
6
  module CurrentScope
2
7
  # Derives the permission set from the host's routes: one permission per
3
8
  # controller#action pair. There is no table to maintain — add a controller
@@ -13,21 +18,129 @@ module CurrentScope
13
18
  .transform_values { |ks| ks.map { |k| k.split("#").last } }
14
19
  end
15
20
 
21
+ # Hot: the Guard asks this on EVERY gated request, and a role save asks it
22
+ # once per staged key. Set lookup, not Array#include? — the array is sorted
23
+ # for display, and scanning it linearly made every request pay for the size
24
+ # of the host's route table. Memoized alongside `keys` and dropped with it
25
+ # on reset!.
16
26
  def include?(key)
17
- keys.include?(key)
27
+ key_set.include?(key)
28
+ end
29
+
30
+ # The ONE parse of config.sod_bypass_permission's action segment — the grid
31
+ # view and the ungated task read it here rather than re-splitting the
32
+ # string themselves (a looser split("#").last silently turns a malformed
33
+ # "reports#" into "reports" and mislabels the break-glass cell; #79 review).
34
+ #
35
+ # `split("#", -1)` keeps the trailing empty field, so a malformed "reports#"
36
+ # yields "" and is caught here rather than silently becoming "reports" and
37
+ # injecting "reports#reports". Blank raises instead of skipping: the host
38
+ # turned break-glass ON, so a permission nobody can hold means the veto can
39
+ # never be lifted and the feature is inert — an undiagnosable deny, which is
40
+ # exactly what this engine promises not to do. (A boot-time check for this
41
+ # config belongs with #40.)
42
+ def bypass_action
43
+ segments = CurrentScope.config.sod_bypass_permission.to_s.split("#", -1)
44
+ # Exactly a bare action or one controller#action. More hashes would pass
45
+ # a last-segment check while the resolver reads the ORIGINAL full string —
46
+ # the catalog would inject a key nobody can be granted under, and the
47
+ # veto could never be lifted. (#79 review)
48
+ if segments.empty? || segments.size > 2 || segments.any?(&:blank?)
49
+ raise ConfigurationError,
50
+ "config.allow_sod_bypass is on, but config.sod_bypass_permission " \
51
+ "(#{CurrentScope.config.sod_bypass_permission.inspect}) is not a bare action or a " \
52
+ "single controller#action. Name the permission the record's initiator must hold " \
53
+ "to break glass (the default is \"bypass_sod\"), or set config.allow_sod_bypass = false."
54
+ end
55
+
56
+ segments.last
57
+ end
58
+
59
+ # Is this key derived from a real route (as opposed to the injected
60
+ # break-glass key)? The distinction matters to anything making an
61
+ # inertness claim: a ROUTED action named like the bypass permission is an
62
+ # ordinary action and gets no exemption, while the injected key is live on
63
+ # any row. (#79 review)
64
+ def routed?(key)
65
+ keys # derive memoizes @routed_key_set as a side effect
66
+ @routed_key_set.include?(key)
18
67
  end
19
68
 
20
69
  private
21
70
 
71
+ def key_set
72
+ @key_set ||= keys.to_set
73
+ end
74
+
22
75
  def derive
23
- Rails.application.routes.routes.filter_map { |route|
76
+ routed = Rails.application.routes.routes.filter_map { |route|
24
77
  controller = route.defaults[:controller]
25
78
  action = route.defaults[:action]
26
79
  next unless controller && action
27
80
  next if CurrentScope.config.excluded_controllers.any? { |re| controller.match?(re) }
28
81
 
29
82
  "#{controller}##{action}"
30
- }.uniq.sort
83
+ }.uniq
84
+
85
+ @routed_key_set = routed.to_set
86
+ (routed + bypass_keys(routed)).uniq.sort
31
87
  end
88
+
89
+ # Break-glass is the one permission that isn't an action you can route: it
90
+ # gates the SoD veto rather than a request. So a purely route-derived catalog
91
+ # can never contain it — which left the shipped "grantable, editable in the
92
+ # role grid" claim false, and break-glass reachable only through full_access
93
+ # (defeating the point of a *scoped* trusted-approver role) or a console
94
+ # insert. The catalog is the single definition of what is grantable — the
95
+ # grid reads `grouped`, the role setter and the Guard read `include?` — so
96
+ # injecting the virtual key here makes the cell render AND the save stick,
97
+ # with no special case in either. (#21)
98
+ #
99
+ # Emitted only where it could mean something: break-glass on, and a
100
+ # controller that actually routes an SoD action. Off by default, so the
101
+ # catalog is byte-for-byte the routed set unless a host opts in.
102
+ #
103
+ # Route- and config-derived only — deliberately no model introspection. The
104
+ # precise set would be "controllers whose SoD-gated model defines an
105
+ # initiator", but discovering that means loading application models, which
106
+ # is expensive, boot-order fragile, and against the catalog's whole design.
107
+ #
108
+ # The key is built from the controller's LAST path segment, because that is
109
+ # what the resolver will ask for: it derives the bypass key from the
110
+ # RECORD's route_key (permission_key → record.model_name.route_key), never
111
+ # from the controller path. Under Rails' resource conventions those agree —
112
+ # Admin::ReportsController's last segment "reports" IS Report's route_key —
113
+ # so keying off the whole path would inject "admin/reports#bypass_sod" while
114
+ # the resolver looks up "reports#bypass_sod", leaving break-glass ungrantable
115
+ # for every namespaced SoD controller and handing the admin a cell that
116
+ # silently does nothing. Namespaced admin controllers are common enough that
117
+ # this is the difference between the fix working and not.
118
+ #
119
+ # A namespace-only resource therefore gets its bypass cell on a "reports"
120
+ # row that no controller routes — the grid renders it aligned, blank
121
+ # everywhere else. Slightly odd to look at, and correct: it is the key the
122
+ # resolver actually reads.
123
+ #
124
+ # The irreducible limit: a controller named differently from the records it
125
+ # acts on (an `approvals` controller approving `Invoice`s) still injects
126
+ # `approvals#bypass_sod` while the live key is `invoices#bypass_sod`.
127
+ # Closing that needs to know the SoD-gated model, i.e. introspection.
128
+ # Tracked at OQ-2.
129
+ def bypass_keys(routed)
130
+ return [] unless CurrentScope.config.allow_sod_bypass
131
+
132
+ sod_actions = CurrentScope.config.sod_actions
133
+ return [] if sod_actions.empty?
134
+
135
+ routed.group_by { |key| key.split("#").first }
136
+ .filter_map { |controller, keys|
137
+ actions = keys.map { |k| k.split("#").last }
138
+ "#{controller.split('/').last}##{bypass_action}" if actions.intersect?(sod_actions)
139
+ }
140
+ end
141
+
142
+ # The action segment of config.sod_bypass_permission — tolerating either a
143
+ # bare action ("bypass_sod") or a full key ("reports#bypass_sod").
144
+ #
32
145
  end
33
146
  end
@@ -1,3 +1,7 @@
1
+ # The gating: default below constructs one at call time — a caller requiring
2
+ # this file directly (not via the current_scope entrypoint) must not NameError.
3
+ require "current_scope/gating_reflection"
4
+
1
5
  module CurrentScope
2
6
  # Presents the route-derived permission catalog as an ALIGNED matrix for the
3
7
  # role editor: fixed columns, one row per controller, blank cells where a
@@ -13,15 +17,31 @@ module CurrentScope
13
17
  Column = Struct.new(:label, :actions, :group, keyword_init: true)
14
18
  Cell = Struct.new(:blank, :group, :name, :value, :checked, :partial, :granted_keys, keyword_init: true)
15
19
 
16
- def initialize(catalog: CurrentScope.catalog, groups: CurrentScope.config.permission_grid_groups)
20
+ # The gating default is evaluated at CALL time, so every bare
21
+ # PermissionGrid.new (the edit view AND role_params on every role save)
22
+ # constructs a GatingReflection. That is fine only because its constructor
23
+ # is inert by contract — all reflection work happens inside #ungated?, and
24
+ # nothing here calls it during initialize or #expand (KTD-8; pinned by the
25
+ # spy test).
26
+ def initialize(catalog: CurrentScope.catalog, groups: CurrentScope.config.permission_grid_groups,
27
+ gating: GatingReflection.new)
17
28
  @grouped = catalog.grouped # { "controller" => ["action", ...] }
18
29
  @groups = groups || {}
30
+ @gating = gating
19
31
  end
20
32
 
21
33
  def controllers
22
34
  @grouped.keys.sort
23
35
  end
24
36
 
37
+ # Is this row's controller provably never gated? Advisory only — a pure
38
+ # delegation the view reads to annotate the row; no other grid method
39
+ # consults the reflection, so the answer cannot affect a cell or an
40
+ # expansion (pinned byte-identical in the tests).
41
+ def ungated?(controller)
42
+ @gating.ungated?(controller)
43
+ end
44
+
25
45
  # Ordered columns: config groups that apply to at least one controller (in
26
46
  # config order), then leftover actions not covered by any group (sorted).
27
47
  def columns
@@ -61,14 +81,24 @@ module CurrentScope
61
81
  end
62
82
 
63
83
  # Expand submitted "controller:group" tokens into routed permission keys.
64
- # Unknown groups/controllers and unrouted actions drop out.
84
+ #
85
+ # A token the grid could not have produced (unknown group, unknown
86
+ # controller, nothing routed) passes through RAW so the model's catalog
87
+ # validation rejects it BY NAME — the same loud contract a hand-crafted
88
+ # permission_keys[] entry gets. One save, one error story: the grid's two
89
+ # submission channels must not differ on whether a crafted request is
90
+ # reported or silently swallowed. (The form's blank hidden padding is the
91
+ # one legitimate non-grid value; it alone drops out.)
65
92
  def expand(tokens)
66
93
  Array(tokens).flat_map do |token|
94
+ next [] if token.blank?
95
+
67
96
  controller, label = token.to_s.split(":", 2)
68
97
  actions = @groups[label]
69
- next [] if actions.nil?
98
+ routed = actions ? (actions & actions_for(controller)) : []
99
+ next [ token.to_s ] if routed.empty?
70
100
 
71
- (actions & actions_for(controller)).map { |action| "#{controller}##{action}" }
101
+ routed.map { |action| "#{controller}##{action}" }
72
102
  end
73
103
  end
74
104
 
@@ -13,14 +13,28 @@ module CurrentScope
13
13
  def allowed_to?(action, record = nil, controller: nil)
14
14
  controller ||= controller_path if respond_to?(:controller_path)
15
15
  CurrentScope.allowed?(action, subject: current_scope_user, record: record,
16
- controller_path: controller, actor: current_scope_actor)
16
+ controller_path: controller, actor: current_scope_actor,
17
+ model: ambient_collection_model(action, controller))
17
18
  end
18
19
 
19
20
  # 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.
21
+ # effective subject act on?". Same grants and keys as the gate, resolved
22
+ # fail-closed (nil subject / no grant none) but scope_for answers ROW
23
+ # MEMBERSHIP only, never action reachability. Gate checks that sit on top
24
+ # of the grant do not filter this list:
25
+ # - the separation-of-duties veto — for an SoD-listed action the list CAN
26
+ # include the subject's own initiated records, which the per-record
27
+ # gate then refuses;
28
+ # - the impersonation mutation gate — a REQUEST-level guard, not a
29
+ # per-record one: it blocks any non-GET/HEAD request while
30
+ # impersonating, collection actions included;
31
+ # - record-less gate paths (a hookless controller's NO_RECORD decision).
32
+ # So a listed row can still 403 when acted on. Per-row affordances for
33
+ # SoD-listed actions must check allowed_to?(action, record); mutation
34
+ # affordances while impersonating should key off impersonating?.
35
+ # Returns a chainable relation (.where/.order/.page on it). `permission`
36
+ # defaults to the model's index context and accepts a bare action or a
37
+ # full key.
24
38
  #
25
39
  # scope_for(Project) # projects#index — what a list shows
26
40
  # scope_for(Report, permission: :approve)
@@ -37,6 +51,26 @@ module CurrentScope
37
51
  )
38
52
  end
39
53
 
54
+ # The type the controller handling THIS request declared for its collection
55
+ # actions (#50), so a bare allowed_to?(:index) in its own view binds the
56
+ # record-less gate the same way the gate did — otherwise the fix would hide
57
+ # a link the gate allows. Only for the request's OWN controller: a
58
+ # cross-controller question resolves a key about a different controller than
59
+ # the ambient type answers, so it gets nil and falls to the fail-closed
60
+ # default — the class form allowed_to?(:index, Report) is how you ask about
61
+ # another controller, and it binds from its argument (R5). (KTD-6)
62
+ #
63
+ # The match keys on the KEY's controller, not just the controller: kwarg: a
64
+ # full "reports#index" key from a projects view names "reports" and must NOT
65
+ # borrow the projects ambient (a Project grant answering a reports key). A
66
+ # bare action uses the resolved controller. (#50 review, cubic)
67
+ def ambient_collection_model(action, controller)
68
+ key_controller = action.to_s.include?("#") ? action.to_s.split("#").first : controller
69
+ return nil unless key_controller && key_controller == CurrentScope::Current.collection_model_path
70
+
71
+ CurrentScope::Current.collection_model
72
+ end
73
+
40
74
  def current_scope_user
41
75
  CurrentScope::Current.user
42
76
  end
@@ -48,10 +82,10 @@ module CurrentScope
48
82
  end
49
83
 
50
84
  # True only while a distinct real actor stands behind the effective
51
- # subject (act-as). Views use it as the read-only-state signal.
85
+ # subject (act-as). Views use it as the read-only-state signal. Delegates
86
+ # to the one definition on Current, shared with the mutation guard.
52
87
  def impersonating?
53
- CurrentScope::Current.user.present? &&
54
- CurrentScope::Current.actor != CurrentScope::Current.user
88
+ CurrentScope::Current.impersonating?
55
89
  end
56
90
  end
57
91
  end