current_scope 0.5.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 (58) hide show
  1. checksums.yaml +7 -0
  2. data/MIT-LICENSE +20 -0
  3. data/README.md +377 -0
  4. data/Rakefile +6 -0
  5. data/app/assets/javascripts/current_scope/application.js +229 -0
  6. data/app/assets/stylesheets/current_scope/application.css +917 -0
  7. data/app/controllers/current_scope/application_controller.rb +96 -0
  8. data/app/controllers/current_scope/events_controller.rb +14 -0
  9. data/app/controllers/current_scope/role_assignments_controller.rb +190 -0
  10. data/app/controllers/current_scope/roles_controller.rb +256 -0
  11. data/app/controllers/current_scope/scoped_role_assignments_controller.rb +165 -0
  12. data/app/controllers/current_scope/subjects_controller.rb +75 -0
  13. data/app/helpers/current_scope/application_helper.rb +261 -0
  14. data/app/models/concerns/current_scope/storable_keys.rb +101 -0
  15. data/app/models/current_scope/application_record.rb +5 -0
  16. data/app/models/current_scope/current.rb +74 -0
  17. data/app/models/current_scope/event.rb +136 -0
  18. data/app/models/current_scope/role.rb +106 -0
  19. data/app/models/current_scope/role_assignment.rb +42 -0
  20. data/app/models/current_scope/role_permission.rb +9 -0
  21. data/app/models/current_scope/scoped_role_assignment.rb +98 -0
  22. data/app/views/current_scope/events/index.html.erb +41 -0
  23. data/app/views/current_scope/roles/edit.html.erb +229 -0
  24. data/app/views/current_scope/roles/index.html.erb +55 -0
  25. data/app/views/current_scope/roles/members.html.erb +114 -0
  26. data/app/views/current_scope/roles/new.html.erb +19 -0
  27. data/app/views/current_scope/scoped_role_assignments/new.html.erb +144 -0
  28. data/app/views/current_scope/shared/access_denied.html.erb +30 -0
  29. data/app/views/current_scope/subjects/index.html.erb +124 -0
  30. data/app/views/layouts/current_scope/application.html.erb +56 -0
  31. data/config/routes.rb +13 -0
  32. data/db/migrate/20260710000001_create_current_scope_tables.rb +31 -0
  33. data/db/migrate/20260710000002_create_current_scope_events.rb +32 -0
  34. data/db/migrate/20260714000001_add_description_to_current_scope_roles.rb +5 -0
  35. data/db/migrate/20260805000001_widen_current_scope_polymorphic_ids.rb +165 -0
  36. data/lib/current_scope/configuration.rb +613 -0
  37. data/lib/current_scope/context.rb +41 -0
  38. data/lib/current_scope/engine.rb +202 -0
  39. data/lib/current_scope/gating_reflection.rb +113 -0
  40. data/lib/current_scope/gating_tripwire.rb +84 -0
  41. data/lib/current_scope/grant_diagnosis.rb +216 -0
  42. data/lib/current_scope/guard.rb +750 -0
  43. data/lib/current_scope/mutation_guard.rb +90 -0
  44. data/lib/current_scope/parent_chain.rb +397 -0
  45. data/lib/current_scope/permission_catalog.rb +146 -0
  46. data/lib/current_scope/permission_grid.rb +132 -0
  47. data/lib/current_scope/permissions.rb +94 -0
  48. data/lib/current_scope/resolver.rb +669 -0
  49. data/lib/current_scope/schema_guard.rb +223 -0
  50. data/lib/current_scope/scopeable.rb +38 -0
  51. data/lib/current_scope/sod_preflight.rb +380 -0
  52. data/lib/current_scope/test_helpers.rb +53 -0
  53. data/lib/current_scope/version.rb +3 -0
  54. data/lib/current_scope.rb +432 -0
  55. data/lib/generators/current_scope/install/install_generator.rb +114 -0
  56. data/lib/generators/current_scope/install/templates/initializer.rb +175 -0
  57. data/lib/tasks/current_scope_tasks.rake +454 -0
  58. metadata +123 -0
@@ -0,0 +1,132 @@
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
+
5
+ module CurrentScope
6
+ # Presents the route-derived permission catalog as an ALIGNED matrix for the
7
+ # role editor: fixed columns, one row per controller, blank cells where a
8
+ # controller doesn't route a column's actions (never a shifted cell).
9
+ #
10
+ # By default the columns are CRUD groups (config.permission_grid_groups):
11
+ # ticking one grants every routed action in the group, so the RESTful form
12
+ # actions fold into their mutation (new→create, edit→update) and index+show
13
+ # read as one. Actions outside any group (e.g. "approve") get their own
14
+ # column. With groups set to nil/{} every raw action becomes its own column —
15
+ # still aligned.
16
+ class PermissionGrid
17
+ Column = Struct.new(:label, :actions, :group, keyword_init: true)
18
+ Cell = Struct.new(:blank, :group, :name, :value, :checked, :partial, :granted_keys, keyword_init: true)
19
+
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)
28
+ @grouped = catalog.grouped # { "controller" => ["action", ...] }
29
+ @groups = groups || {}
30
+ @gating = gating
31
+ end
32
+
33
+ def controllers
34
+ @grouped.keys.sort
35
+ end
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
+
45
+ # Is this row's controller class missing (stale/typo route)? Advisory only
46
+ # — badge the row; do not drop the catalog key (the catalog is a route
47
+ # mirror). (#43)
48
+ def missing_controller?(controller)
49
+ @gating.missing_controller?(controller)
50
+ end
51
+
52
+ # Declared skip reason from current_scope_skip_gate!, or nil (#76).
53
+ def declared_skip_reason(controller)
54
+ @gating.declared_skip_reason(controller)
55
+ end
56
+
57
+ # Ordered columns: config groups that apply to at least one controller (in
58
+ # config order), then leftover actions not covered by any group (sorted).
59
+ def columns
60
+ grouped = @groups.filter_map do |label, actions|
61
+ Column.new(label: label, actions: actions, group: true) if any_controller_has?(actions)
62
+ end
63
+ grouped + leftover_actions.map { |action| Column.new(label: action, actions: [ action ], group: false) }
64
+ end
65
+
66
+ # One cell for (controller, column) against a role's granted key set.
67
+ # Blank when the controller routes none of the column's actions. Otherwise a
68
+ # checkbox.
69
+ #
70
+ # A GROUP cell is only `checked` when EVERY routed action is granted — a
71
+ # partial group is rendered unchecked+indeterminate and its existing keys are
72
+ # preserved verbatim via hidden inputs (see the edit view). This is the
73
+ # escalation guard: a checked group token expands to the whole group on save,
74
+ # so treating "some granted" as checked would silently promote a partial
75
+ # grant to a full one just by re-saving the role. `granted_keys` carries the
76
+ # exact subset to round-trip for a partial cell.
77
+ def cell(controller, column, granted)
78
+ routed = column.actions & actions_for(controller)
79
+ return Cell.new(blank: true) if routed.empty?
80
+
81
+ keys = routed.map { |action| "#{controller}##{action}" }
82
+ present_keys = keys.select { |key| granted.include?(key) }
83
+ partial = present_keys.any? && present_keys.size < keys.size
84
+ Cell.new(
85
+ blank: false,
86
+ group: column.group,
87
+ name: column.group ? "role[permission_groups][]" : "role[permission_keys][]",
88
+ value: column.group ? "#{controller}:#{column.label}" : keys.first,
89
+ checked: column.group ? (present_keys.any? && !partial) : present_keys.any?,
90
+ partial: partial,
91
+ granted_keys: partial ? present_keys : []
92
+ )
93
+ end
94
+
95
+ # Expand submitted "controller:group" tokens into routed permission keys.
96
+ #
97
+ # A token the grid could not have produced (unknown group, unknown
98
+ # controller, nothing routed) passes through RAW so the model's catalog
99
+ # validation rejects it BY NAME — the same loud contract a hand-crafted
100
+ # permission_keys[] entry gets. One save, one error story: the grid's two
101
+ # submission channels must not differ on whether a crafted request is
102
+ # reported or silently swallowed. (The form's blank hidden padding is the
103
+ # one legitimate non-grid value; it alone drops out.)
104
+ def expand(tokens)
105
+ Array(tokens).flat_map do |token|
106
+ next [] if token.blank?
107
+
108
+ controller, label = token.to_s.split(":", 2)
109
+ actions = @groups[label]
110
+ routed = actions ? (actions & actions_for(controller)) : []
111
+ next [ token.to_s ] if routed.empty?
112
+
113
+ routed.map { |action| "#{controller}##{action}" }
114
+ end
115
+ end
116
+
117
+ def actions_for(controller)
118
+ @grouped[controller] || []
119
+ end
120
+
121
+ private
122
+
123
+ def any_controller_has?(actions)
124
+ @grouped.values.any? { |routed| routed.intersect?(actions) }
125
+ end
126
+
127
+ def leftover_actions
128
+ grouped = @groups.values.flatten.uniq
129
+ (@grouped.values.flatten.uniq - grouped).sort
130
+ end
131
+ end
132
+ end
@@ -0,0 +1,94 @@
1
+ module CurrentScope
2
+ # The portable authorization mixin. Works anywhere — controllers, views,
3
+ # components, POROs — because the subject comes from the ambient
4
+ # CurrentScope::Current context rather than being threaded through calls.
5
+ # Everything delegates to the one resolver, so a view can never disagree
6
+ # with the controller gate.
7
+ #
8
+ # allowed_to?(:approve, report) # key derived from the record
9
+ # allowed_to?(:create, Report) # class works for collection actions
10
+ # allowed_to?("admin/reports#approve") # explicit full key
11
+ # allowed_to?(:index, controller: "reports")
12
+ module Permissions
13
+ def allowed_to?(action, record = nil, controller: nil)
14
+ controller ||= controller_path if respond_to?(:controller_path)
15
+ CurrentScope.allowed?(action, subject: current_scope_user, record: record,
16
+ controller_path: controller, actor: current_scope_actor,
17
+ model: ambient_collection_model(action, controller))
18
+ end
19
+
20
+ # The list-side companion to allowed_to?: "which records of `model` may the
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.
38
+ #
39
+ # scope_for(Project) # projects#index — what a list shows
40
+ # scope_for(Report, permission: :approve)
41
+ # scope_for(Report, permission: "admin/reports#approve")
42
+ def scope_for(model, permission: nil)
43
+ # Derive the key exactly like allowed_to? — including controller_path, so a
44
+ # namespaced controller's list resolves to the same key as its gate
45
+ # (admin/reports#index, not reports#index) and the two never drift.
46
+ controller = controller_path if respond_to?(:controller_path)
47
+ CurrentScope.scope_for(
48
+ subject: current_scope_user,
49
+ model: model,
50
+ permission: CurrentScope.permission_key(permission || :index, record: model, controller_path: controller)
51
+ )
52
+ end
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
+ def current_scope_user
64
+ CurrentScope::Current.user
65
+ end
66
+
67
+ # The REAL actor behind the request (never nil when a subject is set — it
68
+ # falls back to the subject). Read this for attribution, not Current.
69
+ def current_scope_actor
70
+ CurrentScope::Current.actor
71
+ end
72
+
73
+ # True only while a distinct real actor stands behind the effective
74
+ # subject (act-as). Views use it as the read-only-state signal. Delegates
75
+ # to the one definition on Current, shared with the mutation guard.
76
+ def impersonating?
77
+ CurrentScope::Current.impersonating?
78
+ end
79
+
80
+ private
81
+
82
+ # Internal binding for gate/view agreement (#50 KTD-6) — not host API.
83
+ # Private so it does not appear in controller action_methods. Matches on
84
+ # the KEY's controller, not just the controller: kwarg: a full
85
+ # "reports#index" key from a projects view must not borrow the projects
86
+ # ambient. A bare action uses the resolved controller. (#50 review, cubic)
87
+ def ambient_collection_model(action, controller)
88
+ key_controller = action.to_s.include?("#") ? action.to_s.split("#").first : controller
89
+ return nil unless key_controller && key_controller == CurrentScope::Current.collection_model_path
90
+
91
+ CurrentScope::Current.collection_model
92
+ end
93
+ end
94
+ end