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.
- checksums.yaml +7 -0
- data/LICENSE.txt +661 -0
- data/README.md +139 -0
- data/exe/ruby-ability-graph +6 -0
- data/lib/ruby_ability_graph/cli.rb +160 -0
- data/lib/ruby_ability_graph/enumerator.rb +250 -0
- data/lib/ruby_ability_graph/harness.rb +130 -0
- data/lib/ruby_ability_graph/html_report.rb +452 -0
- data/lib/ruby_ability_graph/inspector.rb +146 -0
- data/lib/ruby_ability_graph/policy_checker.rb +59 -0
- data/lib/ruby_ability_graph/role_stand_in.rb +34 -0
- data/lib/ruby_ability_graph/rule_classifier.rb +71 -0
- data/lib/ruby_ability_graph/scan_options.rb +132 -0
- data/lib/ruby_ability_graph/scan_presenter.rb +74 -0
- data/lib/ruby_ability_graph/table_formatter.rb +64 -0
- data/lib/ruby_ability_graph/terminal_safe.rb +12 -0
- data/lib/ruby_ability_graph/version.rb +5 -0
- data/lib/ruby_ability_graph.rb +19 -0
- metadata +78 -0
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,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
|