ruby_ability_graph 0.1.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.
data/README.md ADDED
@@ -0,0 +1,139 @@
1
+ # ruby_ability_graph
2
+
3
+ Loads a Rails app's [CanCanCan](https://github.com/CanCanCommunity/cancancan) `Ability` class in isolation and reports raw `can?`/`cannot?` results across every role x action x model combination -- so you can see who can access what without booting the full app or hand-tracing every conditional.
4
+
5
+ **Status:** Each result is classified `resolved` (structured condition captured) or `unsupported` (declared but not analyzed, with a reason and source pointer). `scan` prints a human-readable table by default, a versioned JSON schema on request, can diff results against a simple policy file, and can render an interactive HTML graph of the whole thing.
6
+
7
+ ## Installation
8
+
9
+ Not on RubyGems yet -- point Bundler at the repo instead:
10
+
11
+ ```ruby
12
+ gem "ruby_ability_graph", github: "m1gd0n-dev/ruby-ability-graph"
13
+ ```
14
+
15
+ Requires Ruby >= 4.0 and a C compiler to install -- `prism` compiles a native extension at install time. The Rails app you're scanning doesn't need to be on Ruby 4, though -- point `--ruby-bin` at whatever Ruby that app runs on and the analysis subprocess uses that instead.
16
+
17
+ ## Usage
18
+
19
+ ### 1. Find out what your Ability class needs
20
+
21
+ `inspect` statically scans `Ability#initialize` (via [Prism](https://github.com/ruby/prism), without executing your app) and lists every method it calls on `user`:
22
+
23
+ ```
24
+ ruby-ability-graph inspect APP_PATH [--ability-file FILE] [--ability-class NAME]
25
+ ```
26
+
27
+ ```
28
+ Methods called on `user` in Ability#initialize:
29
+ admin?
30
+ id
31
+
32
+ Your roles file needs a value for each, per role that reaches it.
33
+ ```
34
+
35
+ ### 2. Write a roles file
36
+
37
+ Create `.ability_graph_roles.yml` at your app root, mapping each role you want scanned to stand-in values for the methods `inspect` found:
38
+
39
+ ```yaml
40
+ admin:
41
+ admin?: true
42
+ member:
43
+ admin?: false
44
+ id: 42
45
+ ```
46
+
47
+ ### 3. Scan
48
+
49
+ ```
50
+ ruby-ability-graph scan APP_PATH [--roles-file FILE] [--ability-file FILE] [--ability-class NAME]
51
+ [--require FILE]... | [--rails-boot [--rails-env ENV]]
52
+ [--ruby-bin PATH] [--format table|json] [--policy-file FILE]
53
+ [--html-report FILE]
54
+ ```
55
+
56
+ This loads your `Ability` class in a subprocess and runs `can?` for every role x action x model combination it finds.
57
+
58
+ By default it prints a human-readable table plus a resolved/unsupported coverage line:
59
+
60
+ ```
61
+ ROLE ACTION MODEL ALLOWED CONFIDENCE CONDITION
62
+ admin read Document true resolved -
63
+ member read Document true resolved {"team_id" => 7}
64
+ member update Document true unsupported -
65
+
66
+ 2/3 resolved (66.7%)
67
+ ```
68
+
69
+ Pass `--format json` for the same data as structured, versioned JSON instead:
70
+
71
+ ```json
72
+ {
73
+ "schema_version": 1,
74
+ "results": [
75
+ { "role": "admin", "action": "read", "model": "Document", "allowed": true,
76
+ "confidence": "resolved", "condition": null, "reasons": [], "sources": [] },
77
+ { "role": "member", "action": "read", "model": "Document", "allowed": true,
78
+ "confidence": "resolved", "condition": { "team_id": 7 }, "reasons": [], "sources": [] },
79
+ { "role": "member", "action": "update", "model": "Document", "allowed": true,
80
+ "confidence": "unsupported", "condition": null, "reasons": ["block_condition"],
81
+ "sources": [{ "file": "app/models/ability.rb", "line": 12, "text": "can :update, Document do |doc| ... end" }] }
82
+ ]
83
+ }
84
+ ```
85
+
86
+ **Options:**
87
+
88
+ | Flag | Default | Purpose |
89
+ |---|---|---|
90
+ | `--roles-file FILE` | `APP_PATH/.ability_graph_roles.yml` | YAML file from step 2 |
91
+ | `--ability-file FILE` | `app/models/ability.rb` | Path to the `Ability` class, relative to `APP_PATH` |
92
+ | `--ability-class NAME` | `Ability` | Constant name to load, e.g. `Spree::Ability` for a namespaced/engine-provided one |
93
+ | `--require FILE` (repeatable) | -- | Preload a file your `Ability` class references but doesn't require itself. Not compatible with `--rails-boot` |
94
+ | `--rails-boot [--rails-env ENV]` | off / `test` | Boot via the target's own `bin/rails runner` so real Zeitwerk autoloading resolves everything -- no manual `--require` list needed. Not compatible with `--require` |
95
+ | `--ruby-bin PATH` | `ruby` (via `PATH`) | Ruby executable for the analysis subprocess, if the target app needs a different Ruby version than this gem runs under |
96
+ | `--format table\|json` | `table` | `table` for a terminal-friendly summary, `json` for the versioned schema above |
97
+ | `--policy-file FILE` | -- | Run a policy check against the results (see below); path is relative to `APP_PATH` |
98
+ | `--html-report FILE` | -- | Write an interactive HTML graph of the results (see below) to this path, relative to `APP_PATH` |
99
+
100
+ Note: `--rails-boot` runs the target's *entire* boot sequence, not just the `Ability` class -- if the app needs a JS runtime, a live DB connection, or anything else at boot time (not merely at asset-compile time), that has to already be satisfied in whatever environment you're running the scan from. This is most likely to bite in a stripped-down CI container or sandbox rather than a normal dev machine. If the target's full boot is heavy or fragile, `--require`ing just what the `Ability` class needs is usually simpler than fighting its boot process.
101
+
102
+ ### 4. Policy checks (optional)
103
+
104
+ Declare who's *supposed* to be able to do what, and let `scan` flag any role that can actually do more. The policy language is deliberately flat -- `model` + `action` + `allowed_roles`, no nested logic:
105
+
106
+ ```yaml
107
+ # policy.yml
108
+ policies:
109
+ - model: Payment
110
+ action: read
111
+ allowed_roles: [admin]
112
+ - model: Document
113
+ action: destroy
114
+ allowed_roles: [admin, owner]
115
+ ```
116
+
117
+ ```
118
+ ruby-ability-graph scan APP_PATH --policy-file policy.yml
119
+ ```
120
+
121
+ A violation is any `(role, action, model)` combination the scan found `allowed: true` for, where `role` isn't in that policy's `allowed_roles`. Violations are appended to the table (or the `"policy_violations"` key in JSON output), and `scan` exits `1` if any are found -- so this doubles as a CI gate. A clean policy check exits `0`.
122
+
123
+ Note: a policy check is only as trustworthy as the underlying result's `confidence`. A violation on an `unsupported` result means the role stand-in used for this run happened to be allowed -- it isn't a guarantee about every user with that role, since the condition itself wasn't fully analyzed. Treat those as "investigate," not "confirmed."
124
+
125
+ ### 5. HTML report (optional)
126
+
127
+ ```
128
+ ruby-ability-graph scan APP_PATH --html-report report.html
129
+ ```
130
+
131
+ Writes a single, self-contained HTML file -- inlined CSS/JS, no server, no CDN assets, nothing fetched over the network -- showing an interactive role → action → resource graph. Hover a role to trace everything it can reach; hover an edge for the underlying condition and confidence. Edges are solid green where `resolved`, dashed amber where `unsupported`, and solid red where a `--policy-file` check (if given) found a violation. A coverage line at the top mirrors the table's resolved/total summary. Only `allowed: true` results become edges -- it's a "who can access what" diagram, not a dump of every denial.
132
+
133
+ Combine it with `--policy-file` to get violations highlighted directly on the graph, not just listed in the table/JSON output. The file opens straight from disk (`file://`) in any browser -- no need to serve it.
134
+
135
+ ## Security
136
+
137
+ `scan` executes code in `APP_PATH` -- it loads your `Ability` class (and anything that file requires) in a subprocess. Only run it against apps you trust. `inspect` is static analysis only (via Prism) and never executes the target.
138
+
139
+ `--html-report` makes no network requests, ever -- everything it needs is inlined into the one file it writes. It does embed your role names, action names, model names, and raw condition values into that file, though, so treat the generated report itself with the same care as the scan results: don't publish it somewhere untrusted people can read it if that data is sensitive.
@@ -0,0 +1,6 @@
1
+ #!/usr/bin/env ruby
2
+ # frozen_string_literal: true
3
+
4
+ require "ruby_ability_graph"
5
+
6
+ RubyAbilityGraph::CLI.start(ARGV)
@@ -0,0 +1,160 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "optparse"
4
+ require "yaml"
5
+ require "json"
6
+
7
+ module RubyAbilityGraph
8
+ # Command-line entry point: `scan` runs the role x action x model analysis
9
+ # via Harness, `inspect` runs the roles-file authoring aid via Inspector.
10
+ class CLI
11
+ USAGE = <<~USAGE.chomp
12
+ Usage: ruby-ability-graph scan APP_PATH [--roles-file FILE] [--ability-file FILE]
13
+ [--ability-class NAME]
14
+ [--require FILE]... | [--rails-boot [--rails-env ENV]]
15
+ [--ruby-bin PATH] [--format table|json] [--policy-file FILE]
16
+ [--html-report FILE]
17
+ ruby-ability-graph inspect APP_PATH [--ability-file FILE] [--ability-class NAME]
18
+ USAGE
19
+
20
+ def self.start(argv)
21
+ new.run(argv)
22
+ end
23
+
24
+ def run(argv)
25
+ command, *rest = argv
26
+ case command
27
+ when "scan" then scan(rest)
28
+ when "inspect" then inspect_ability(rest)
29
+ else warn USAGE and exit(1)
30
+ end
31
+ rescue OptionParser::ParseError => e
32
+ abort(e.message)
33
+ end
34
+
35
+ private
36
+
37
+ def scan(argv)
38
+ options, app_path = RubyAbilityGraph::ScanOptions.parse(argv)
39
+ roles = load_roles(options[:roles_file], app_path)
40
+ results = RubyAbilityGraph::Harness.new(**harness_kwargs(options, app_path, roles)).run
41
+ policy_report = load_policy_report(options[:policy_file], app_path, results)
42
+ output_results(options, app_path, results, policy_report)
43
+ end
44
+
45
+ def output_results(options, app_path, results, policy_report)
46
+ write_html_report(options[:html_report], app_path, results, policy_report)
47
+
48
+ presenter = RubyAbilityGraph::ScanPresenter.new(
49
+ format: options[:format], results: results, policy_report: policy_report
50
+ )
51
+ puts presenter.render
52
+ exit(1) if presenter.problems?
53
+ end
54
+
55
+ # rails_env/ruby_bin are only included when set at all, so Harness's own
56
+ # keyword defaults apply otherwise -- an explicit nil would override
57
+ # them instead. #compact drops both when absent (rails_boot: false is a
58
+ # real value, not "absent", so it survives).
59
+ def harness_kwargs(options, app_path, roles)
60
+ {
61
+ app_path: app_path, roles: roles, ability_file: options[:ability_file],
62
+ ability_class_name: options[:ability_class], requires: options[:requires],
63
+ rails_boot: options[:rails_boot], rails_env: options[:rails_env], ruby_bin: options[:ruby_bin]
64
+ }.compact
65
+ end
66
+
67
+ # nil (not merely empty) means "no --policy-file given" -- ScanPresenter
68
+ # uses that distinction to decide whether to print a policy section at all.
69
+ def load_policy_report(policy_file, app_path, results)
70
+ return nil unless policy_file
71
+
72
+ path = File.expand_path(policy_file, app_path)
73
+ abort("No policy file found at #{path}.") unless File.exist?(path)
74
+
75
+ RubyAbilityGraph::PolicyChecker.call(policies: parse_policy_file(path), results: results)
76
+ end
77
+
78
+ def parse_policy_file(path)
79
+ data = YAML.safe_load_file(path)
80
+ abort("Policy file #{path} must be a YAML mapping with a top-level `policies:` list.") unless data.is_a?(Hash)
81
+
82
+ data["policies"] || []
83
+ rescue Psych::SyntaxError => e
84
+ abort("Failed to parse policy file #{path}: #{e.message}")
85
+ end
86
+
87
+ # Written to stderr, not stdout -- keeps `--format json` pipeable without
88
+ # this confirmation line landing in the middle of the JSON payload.
89
+ def write_html_report(html_report, app_path, results, policy_report)
90
+ return unless html_report
91
+
92
+ path = File.expand_path(html_report, app_path)
93
+ violations = policy_report&.violations
94
+ File.write(path, RubyAbilityGraph::HtmlReport.call(results: results, violations: violations))
95
+ warn "HTML report written to #{path}"
96
+ end
97
+
98
+ def inspect_ability(argv)
99
+ app_path, options = parse_inspect_args(argv)
100
+ inspector = build_inspector(options, app_path)
101
+ result = run_inspector(inspector)
102
+ print_inspection_result(result, options[:ability_class])
103
+ end
104
+
105
+ def build_inspector(options, app_path)
106
+ RubyAbilityGraph::Inspector.new(
107
+ ability_file: File.expand_path(options[:ability_file], app_path),
108
+ ability_class_name: options[:ability_class]
109
+ )
110
+ end
111
+
112
+ def run_inspector(inspector)
113
+ inspector.call
114
+ rescue RubyAbilityGraph::Inspector::InspectionError => e
115
+ abort(e.message)
116
+ end
117
+
118
+ def parse_inspect_args(argv)
119
+ options = { ability_file: RubyAbilityGraph::Harness::DEFAULT_ABILITY_FILE, ability_class: "Ability" }
120
+ build_inspect_parser(options).parse!(argv)
121
+
122
+ app_path = argv.shift
123
+ abort(USAGE) unless app_path
124
+ [app_path, options]
125
+ end
126
+
127
+ ABILITY_CLASS_HELP = "The class name exactly as written at its `class` statement in that file -- " \
128
+ "usually just Ability even when it's namespaced (e.g. `module Spree; class " \
129
+ "Ability`), unless it's written inline as `class Spree::Ability` (default: Ability)"
130
+ private_constant :ABILITY_CLASS_HELP
131
+
132
+ def build_inspect_parser(options)
133
+ OptionParser.new do |opts|
134
+ opts.on("--ability-file FILE", "Path to the Ability class file, relative to APP_PATH") do |v|
135
+ options[:ability_file] = v
136
+ end
137
+ opts.on("--ability-class NAME", ABILITY_CLASS_HELP) { |v| options[:ability_class] = v }
138
+ end
139
+ end
140
+
141
+ def print_inspection_result(result, ability_class)
142
+ puts "Methods called on `user` in #{ability_class}#initialize:"
143
+ result.method_names.each { |m| puts " #{m}" }
144
+ puts
145
+ puts "Your roles file needs a value for each, per role that reaches it."
146
+ puts
147
+ puts "Warning: #{result.warning}" if result.warning
148
+ end
149
+
150
+ def load_roles(roles_file, app_path)
151
+ path = roles_file || File.join(app_path, ".ability_graph_roles.yml")
152
+ unless File.exist?(path)
153
+ abort("No roles file found at #{path}. Pass --roles-file or add .ability_graph_roles.yml to the app root.")
154
+ end
155
+ YAML.safe_load_file(path, permitted_classes: [Symbol]).transform_values do |attrs|
156
+ (attrs || {}).to_h { |k, v| [k.to_s.to_sym, v] }
157
+ end
158
+ end
159
+ end
160
+ end
@@ -0,0 +1,250 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "rule_classifier"
4
+
5
+ module RubyAbilityGraph
6
+ # Runs a loaded Ability class's can? checks across every role x declared
7
+ # action x declared model combination, classifying each result as
8
+ # resolved/unsupported (via RuleClassifier) rather than returning a bare
9
+ # boolean.
10
+ class Enumerator
11
+ Result = Struct.new(
12
+ :role, :action, :model, :allowed, :confidence, :condition, :reasons, :sources, keyword_init: true
13
+ )
14
+ DEFAULT_ACTIONS = %i[index show create update destroy manage read].freeze
15
+
16
+ # Records where each rule was truly declared, by wrapping CanCan::Ability's
17
+ # own rule-append point -- prepended onto the CanCan::Ability MODULE
18
+ # itself (not a specific Ability class), so it applies uniformly to every
19
+ # class that includes it. That matters because larger apps commonly split
20
+ # authorization across several classes merged together (e.g. a top-level
21
+ # `Ability` doing `merge Abilities::Administrator.new(user)`, itself
22
+ # merging further sub-abilities -- found dogfooding consuldemocracy).
23
+ # Prepending only the top-level class would mean a rule declared inside
24
+ # a merged-in class gets *re-added* during merge, and naive recording
25
+ # would attribute it to the merge call site, not its real declaration --
26
+ # every rule sharing that merge line would then look like it fired more
27
+ # than once, indistinguishable from a genuine loop-built rule.
28
+ #
29
+ # The source is stashed directly on the rule object (set once, at first
30
+ # sighting) rather than in an array indexed by position, so it survives
31
+ # being re-added to another ability's own @rules during merge.
32
+ module RuleSourceRecording
33
+ # Larger apps commonly split `can`/`cannot` declarations out of Ability
34
+ # itself via a plain forwarding method -- Solidus's whole
35
+ # PermissionSets framework works this way (`delegate :can, :cannot,
36
+ # :user, to: :ability` in every permission set's base class, found
37
+ # dogfooding solidus). A delegate-generated method's OWN recorded
38
+ # file/line is wherever `delegate :can, ...` itself was written, not
39
+ # the permission set subclass that actually calls it -- so the first
40
+ # non-cancancan frame there is a dead end, and every rule declared
41
+ # through that same delegate line collapses to one identical,
42
+ # unhelpful source pointer. Skipping frames whose method name is
43
+ # itself can/cannot (regardless of whether the forwarding was done via
44
+ # `delegate`, `alias`, or a hand-written wrapper) walks past that and
45
+ # lands on the real call site instead.
46
+ #
47
+ # Location#label isn't just the bare method name -- on this Ruby
48
+ # version it's qualified as "PermBase#can" (confirmed empirically;
49
+ # comparing against a bare "can"/"cannot" silently never matched and
50
+ # let the very bug this is meant to fix through). Match on either form.
51
+ FORWARDING_METHOD_NAME = /(\A|#)(can|cannot)\z/
52
+
53
+ def add_rule(rule)
54
+ unless rule.instance_variable_defined?(:@rag_source)
55
+ location = caller_locations.find do |loc|
56
+ !loc.path.include?("cancancan") && !FORWARDING_METHOD_NAME.match?(loc.label)
57
+ end
58
+ rule.instance_variable_set(:@rag_source, location)
59
+ end
60
+ super
61
+ end
62
+ end
63
+ private_constant :RuleSourceRecording
64
+
65
+ def self.call(ability_class:, role_stand_ins:)
66
+ new(ability_class: ability_class, role_stand_ins: role_stand_ins).call
67
+ end
68
+
69
+ def initialize(ability_class:, role_stand_ins:)
70
+ @ability_class = ability_class
71
+ @role_stand_ins = role_stand_ins
72
+ end
73
+
74
+ def call
75
+ ensure_source_recording!
76
+ abilities = @role_stand_ins.transform_values { |user| @ability_class.new(user) }
77
+ models = declared_models(abilities.values)
78
+ actions = declared_actions(abilities.values)
79
+
80
+ build_results(abilities, models, actions)
81
+ end
82
+
83
+ private
84
+
85
+ # Prepended onto the CanCan::Ability module -- see RuleSourceRecording --
86
+ # so this only ever needs to run once, regardless of which Ability-like
87
+ # class we're pointed at. No-op entirely if the target's cancancan
88
+ # version doesn't define #add_rule -- source/dynamic-generation data is
89
+ # simply unavailable then, not fatal.
90
+ def ensure_source_recording!
91
+ return if CanCan::Ability.ancestors.include?(RuleSourceRecording)
92
+ return unless CanCan::Ability.method_defined?(:add_rule) || CanCan::Ability.private_method_defined?(:add_rule)
93
+
94
+ CanCan::Ability.prepend(RuleSourceRecording)
95
+ end
96
+
97
+ def build_results(abilities, models, actions)
98
+ results = []
99
+ abilities.each do |role, ability|
100
+ dynamic_rules = dynamic_rule_set(ability.send(:rules))
101
+ models.each do |model|
102
+ actions.each { |action| results << build_result(role, ability, model, action, dynamic_rules) }
103
+ end
104
+ end
105
+ results
106
+ end
107
+
108
+ # A rule counts as dynamically generated only if it shares its true
109
+ # declaration line with at least one OTHER rule from this same
110
+ # instantiation whose subjects/actions/conditions actually differ -- e.g.
111
+ # Fat Free CRM's `permissions.each { |p| can :manage, ..., id: p.asset_id }`,
112
+ # where every iteration produces a different condition. Same line but
113
+ # every rule otherwise identical is what a merged-in class reached via
114
+ # more than one path looks like (see RuleSourceRecording) -- that's
115
+ # static duplication, not a loop, and shouldn't taint an otherwise
116
+ # perfectly resolvable rule.
117
+ def dynamic_rule_set(rules)
118
+ rules.group_by { |rule| location_key(rule_source(rule)) }
119
+ .reject { |key, _| key.nil? }
120
+ .values
121
+ .select { |group| group.size > 1 && varies?(group) }
122
+ .flatten
123
+ .to_set
124
+ end
125
+
126
+ # Thread::Backtrace::Location has no value equality of its own -- two
127
+ # separate calls to caller_locations, even for the exact same physical
128
+ # line, return objects that are neither `==` nor `eql?` to each other
129
+ # (confirmed empirically). Grouping by the raw Location silently never
130
+ # merged anything, so this whole dynamic-vs-static check was a no-op
131
+ # from the moment it shipped. [path, lineno] is a plain, hashable-by-
132
+ # value key that actually collapses same-site rules.
133
+ def location_key(location)
134
+ return nil unless location
135
+
136
+ [location.path, location.lineno]
137
+ end
138
+
139
+ def varies?(group)
140
+ group.map { |rule| [rule.subjects, rule.actions, rule.conditions] }.uniq.size > 1
141
+ end
142
+
143
+ def build_result(role, ability, model, action, dynamic_rules)
144
+ contributing = relevant_rules(ability, action, model)
145
+ classifications = contributing.map { |rule| classify(rule, model, dynamic_rules) }
146
+
147
+ Result.new(
148
+ role: role.to_s,
149
+ action: action.to_s,
150
+ model: model_name(model),
151
+ allowed: ability.can?(action, model),
152
+ **verdict(classifications)
153
+ )
154
+ end
155
+
156
+ def classify(rule, model, dynamic_rules)
157
+ return dynamic_classification(rule) if dynamic_rules.include?(rule)
158
+
159
+ classification = RuleClassifier.call(rule: rule, model: model)
160
+ classification.source = source_snippet(rule_source(rule)) if classification.confidence == "unsupported"
161
+ classification
162
+ end
163
+
164
+ def dynamic_classification(rule)
165
+ RuleClassifier::Classification.new(
166
+ confidence: "unsupported",
167
+ condition: nil,
168
+ reason: "dynamic_rule_generation",
169
+ source: source_snippet(rule_source(rule))
170
+ )
171
+ end
172
+
173
+ # Every rule contributing to this (action, model) pair must itself be
174
+ # resolved for the combined result to be resolved -- we trust can? for
175
+ # the boolean, not our own guess at resolution order.
176
+ def verdict(classifications)
177
+ unsupported = classifications.reject { |c| c.confidence == "resolved" }
178
+ return resolved_verdict(classifications) if unsupported.empty?
179
+
180
+ {
181
+ confidence: "unsupported",
182
+ condition: nil,
183
+ reasons: unsupported.map(&:reason).uniq,
184
+ sources: unsupported.map(&:source).compact
185
+ }
186
+ end
187
+
188
+ def resolved_verdict(classifications)
189
+ conditions = classifications.filter_map(&:condition)
190
+ condition = conditions.size <= 1 ? conditions.first : conditions
191
+ { confidence: "resolved", condition: condition, reasons: [], sources: [] }
192
+ end
193
+
194
+ # Rules whose action/subject match this (action, model) pair, regardless
195
+ # of whether their condition currently evaluates true or false -- any one
196
+ # of them being unsupported taints the whole pair (see #verdict).
197
+ def relevant_rules(ability, action, model)
198
+ if ability.respond_to?(:relevant_rules, true)
199
+ ability.send(:relevant_rules, action, model)
200
+ else
201
+ ability.send(:rules).select { |r| naive_relevant?(r, action, model) }
202
+ end
203
+ end
204
+
205
+ # Fallback only, if #relevant_rules isn't available on this cancancan
206
+ # version -- no alias-expansion awareness beyond :manage.
207
+ def naive_relevant?(rule, action, model)
208
+ (rule.subjects.include?(model) || rule.subjects.include?(:all)) &&
209
+ (rule.actions.include?(action) || rule.actions.include?(:manage))
210
+ end
211
+
212
+ def rule_source(rule)
213
+ rule.instance_variable_get(:@rag_source) if rule.instance_variable_defined?(:@rag_source)
214
+ end
215
+
216
+ def source_snippet(location)
217
+ return nil unless location
218
+
219
+ { "file" => location.path, "line" => location.lineno, "text" => source_line(location) }
220
+ end
221
+
222
+ def source_line(location)
223
+ File.readlines(location.path)[location.lineno - 1]&.strip
224
+ rescue Errno::ENOENT, ArgumentError
225
+ nil
226
+ end
227
+
228
+ def declared_models(abilities)
229
+ models = Set.new
230
+ abilities.each do |ability|
231
+ ability.send(:rules).each do |rule|
232
+ rule.subjects.each { |subject| models << subject unless subject == :all }
233
+ end
234
+ end
235
+ models.to_a
236
+ end
237
+
238
+ def declared_actions(abilities)
239
+ actions = Set.new(DEFAULT_ACTIONS)
240
+ abilities.each do |ability|
241
+ ability.send(:rules).each { |rule| rule.actions.each { |action| actions << action } }
242
+ end
243
+ actions.to_a
244
+ end
245
+
246
+ def model_name(model)
247
+ model.respond_to?(:name) ? model.name : model.to_s
248
+ end
249
+ end
250
+ end
@@ -0,0 +1,130 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "open3"
4
+ require "tempfile"
5
+ require "json"
6
+
7
+ module RubyAbilityGraph
8
+ # Loads a target app's CanCanCan Ability class in a subprocess and reports
9
+ # raw can?/cannot? results across the role x action x model cross-product.
10
+ class Harness
11
+ class LoadError < StandardError; end
12
+
13
+ DEFAULT_ABILITY_FILE = "app/models/ability.rb"
14
+ DEFAULT_RAILS_ENV = "test"
15
+ MARKER = "RUBY_ABILITY_GRAPH_RESULT:"
16
+
17
+ def initialize(app_path:, roles:, ability_file: DEFAULT_ABILITY_FILE, ability_class_name: "Ability", requires: [],
18
+ rails_boot: false, rails_env: DEFAULT_RAILS_ENV, ruby_bin: "ruby")
19
+ validate_requires_compatibility!(rails_boot, requires)
20
+
21
+ @app_path = File.expand_path(app_path)
22
+ @roles = roles
23
+ @ability_file = ability_file
24
+ @ability_class_name = ability_class_name
25
+ @requires = requires
26
+ @rails_boot = rails_boot
27
+ @rails_env = rails_env
28
+ @ruby_bin = ruby_bin
29
+ end
30
+
31
+ def run
32
+ Tempfile.create(["ruby_ability_graph_runner", ".rb"]) do |file|
33
+ file.write(runner_script)
34
+ file.flush
35
+ stdout, stderr, status = execute(file.path)
36
+ unless status.success?
37
+ raise LoadError, "Failed to load and analyze #{@ability_class_name} in #{@app_path}:\n#{stderr}"
38
+ end
39
+
40
+ extract_results(stdout)
41
+ end
42
+ end
43
+
44
+ private
45
+
46
+ def validate_requires_compatibility!(rails_boot, requires)
47
+ return unless rails_boot && !requires.empty?
48
+
49
+ raise ArgumentError, "requires: is not compatible with rails_boot: -- once Zeitwerk is live via " \
50
+ "bin/rails runner, referenced classes resolve on their own; a manual requires: " \
51
+ "list is superfluous. Drop one or the other."
52
+ end
53
+
54
+ def execute(script_path)
55
+ return execute_via_rails_runner(script_path) if @rails_boot
56
+
57
+ command = bundler_project? ? ["bundle", "exec", @ruby_bin] : [@ruby_bin]
58
+ # nosemgrep: ruby.lang.security.dangerous-exec.dangerous-exec -- @ruby_bin/@app_path are caller-supplied
59
+ # tool config, not untrusted remote input; spawning the target app is this harness's intended function.
60
+ Open3.capture3(*command, script_path, chdir: @app_path)
61
+ end
62
+
63
+ def execute_via_rails_runner(script_path)
64
+ rails_bin = File.join(@app_path, "bin", "rails")
65
+ unless File.exist?(rails_bin)
66
+ raise LoadError, "rails_boot: true but no bin/rails found in #{@app_path} -- this doesn't look like a " \
67
+ "Rails app."
68
+ end
69
+
70
+ # nosemgrep: ruby.lang.security.dangerous-exec.dangerous-exec -- same rationale as execute/1 above.
71
+ Open3.capture3({ "RAILS_ENV" => @rails_env }, @ruby_bin, "--", rails_bin, "runner", script_path,
72
+ chdir: @app_path)
73
+ end
74
+
75
+ def bundler_project?
76
+ File.exist?(File.join(@app_path, "Gemfile"))
77
+ end
78
+
79
+ def require_lines(paths)
80
+ paths.map { |path| "require #{path.inspect}" }.join("\n")
81
+ end
82
+
83
+ def extract_results(stdout)
84
+ line = stdout.lines.rfind { |l| l.start_with?(MARKER) }
85
+ raise LoadError, "RubyAbilityGraph runner produced no result.\n\nFull output:\n#{stdout}" unless line
86
+
87
+ JSON.parse(line.sub(MARKER, ""))
88
+ end
89
+
90
+ def runner_script
91
+ <<~RUBY
92
+ require "json"
93
+ require #{File.expand_path('role_stand_in.rb', __dir__).inspect}
94
+ require #{File.expand_path('enumerator.rb', __dir__).inspect}
95
+ #{ability_loading_lines}
96
+ role_stand_ins = JSON.parse(#{@roles.to_json.inspect}).transform_values do |attrs|
97
+ RubyAbilityGraph::RoleStandIn.new(attrs)
98
+ end
99
+ #{resolve_and_report_lines}
100
+ RUBY
101
+ end
102
+
103
+ def resolve_and_report_lines
104
+ <<~RUBY.chomp
105
+ ability_class = Object.const_get(#{@ability_class_name.inspect})
106
+ results = RubyAbilityGraph::Enumerator.call(ability_class: ability_class, role_stand_ins: role_stand_ins)
107
+ puts #{MARKER.inspect} + results.map(&:to_h).to_json
108
+ RUBY
109
+ end
110
+
111
+ def ability_loading_lines
112
+ @rails_boot ? rails_boot_loading_lines : plain_require_loading_lines
113
+ end
114
+
115
+ def rails_boot_loading_lines
116
+ "# rails_boot: true -- Ability file resolves via Zeitwerk autoloading, no explicit require needed. See #3."
117
+ end
118
+
119
+ def plain_require_loading_lines
120
+ ability_path = File.expand_path(@ability_file, @app_path)
121
+ require_paths = @requires.map { |path| File.expand_path(path, @app_path) }
122
+
123
+ <<~RUBY
124
+ require "cancancan"
125
+ #{require_lines(require_paths)}
126
+ require #{ability_path.inspect}
127
+ RUBY
128
+ end
129
+ end
130
+ end