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.
@@ -0,0 +1,71 @@
1
+ # frozen_string_literal: true
2
+
3
+ module RubyAbilityGraph
4
+ # Classifies a single CanCan::Rule's condition:
5
+ # "resolved" (unconditional or flat scalar hash, fully structured) or
6
+ # "unsupported" (block, association-reaching, or otherwise opaque).
7
+ # Shape only -- source/dynamic-generation handling lives in Enumerator.
8
+ class RuleClassifier
9
+ Classification = Struct.new(:confidence, :condition, :reason, :source, keyword_init: true)
10
+
11
+ def self.call(rule:, model:)
12
+ new(rule: rule, model: model).call
13
+ end
14
+
15
+ def initialize(rule:, model:)
16
+ @rule = rule
17
+ @model = model
18
+ end
19
+
20
+ def call
21
+ # only_block?, not @rule.block -- cancancan only made `block` a public
22
+ # reader from ~3.x on (it's a private ivar in, e.g., 1.17.0, still
23
+ # bundled by real apps -- found via dogfooding dradis-ce). only_block?
24
+ # (conditions_empty? && block-present) is public across both and, since
25
+ # both versions treat a Hash-conditions-plus-block combo as a raise-on-
26
+ # declaration error, it's equivalent to "has a block" for every rule
27
+ # that could actually exist.
28
+ return unsupported("block_condition") if @rule.only_block?
29
+
30
+ conditions = @rule.conditions
31
+ return resolved(nil) if blank?(conditions)
32
+ return unsupported("unrecognized_condition_value") unless conditions.is_a?(Hash)
33
+
34
+ classify_hash(conditions)
35
+ end
36
+
37
+ private
38
+
39
+ def blank?(conditions)
40
+ conditions.nil? || (conditions.respond_to?(:empty?) && conditions.empty?)
41
+ end
42
+
43
+ def classify_hash(conditions)
44
+ conditions.each do |key, value|
45
+ return unsupported("association_chained") if value.is_a?(Hash)
46
+ return unsupported("requires_association_traversal") if association_key?(key)
47
+ return unsupported("unrecognized_condition_value") unless scalar?(value)
48
+ end
49
+ resolved(conditions)
50
+ end
51
+
52
+ # Deliberately no special-casing by key name (e.g. tenant_id/team_id) --
53
+ # a flat scalar comparison is resolved regardless of what it's called.
54
+ def association_key?(key)
55
+ @model.respond_to?(:reflect_on_association) && !@model.reflect_on_association(key).nil?
56
+ end
57
+
58
+ def scalar?(value)
59
+ value.nil? || value == true || value == false ||
60
+ value.is_a?(String) || value.is_a?(Numeric) || value.is_a?(Symbol)
61
+ end
62
+
63
+ def resolved(condition)
64
+ Classification.new(confidence: "resolved", condition: condition, reason: nil, source: nil)
65
+ end
66
+
67
+ def unsupported(reason)
68
+ Classification.new(confidence: "unsupported", condition: nil, reason: reason, source: nil)
69
+ end
70
+ end
71
+ end
@@ -0,0 +1,132 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "optparse"
4
+
5
+ module RubyAbilityGraph
6
+ # Parses and validates `scan` subcommand argv into an options hash + app
7
+ # path. Split out from CLI so that class stays a thin command dispatcher
8
+ # as flags accumulate (see .rubocop.yml's CLI Metrics/ClassLength note).
9
+ class ScanOptions
10
+ def self.parse(argv)
11
+ new.parse(argv)
12
+ end
13
+
14
+ def parse(argv)
15
+ options = defaults
16
+ build_parser(options).parse!(argv)
17
+
18
+ app_path = argv.shift
19
+ abort(CLI::USAGE) unless app_path
20
+
21
+ validate!(options)
22
+ [options, app_path]
23
+ end
24
+
25
+ private
26
+
27
+ def defaults
28
+ {
29
+ ability_file: Harness::DEFAULT_ABILITY_FILE, ability_class: "Ability",
30
+ requires: [], rails_boot: false, format: "table"
31
+ }
32
+ end
33
+
34
+ def build_parser(options)
35
+ OptionParser.new do |opts|
36
+ add_roles_file_option!(opts, options)
37
+ add_ability_file_option!(opts, options)
38
+ add_ability_class_option!(opts, options)
39
+ add_ruby_bin_option!(opts, options)
40
+ add_format_option!(opts, options)
41
+ add_policy_file_option!(opts, options)
42
+ add_html_report_option!(opts, options)
43
+ add_loader_strategy_options!(opts, options)
44
+ end
45
+ end
46
+
47
+ def add_roles_file_option!(opts, options)
48
+ opts.on("--roles-file FILE", "YAML file mapping role name => user stand-in attributes") do |v|
49
+ options[:roles_file] = v
50
+ end
51
+ end
52
+
53
+ def add_ability_file_option!(opts, options)
54
+ opts.on("--ability-file FILE", "Path to the Ability class file, relative to APP_PATH") do |v|
55
+ options[:ability_file] = v
56
+ end
57
+ end
58
+
59
+ def add_ability_class_option!(opts, options)
60
+ opts.on("--ability-class NAME", "Constant name of the Ability class, e.g. Spree::Ability for a " \
61
+ "namespaced/engine-provided one (default: Ability)") do |v|
62
+ options[:ability_class] = v
63
+ end
64
+ end
65
+
66
+ def add_ruby_bin_option!(opts, options)
67
+ opts.on("--ruby-bin PATH", "Ruby executable for the target subprocess (default: " \
68
+ "\"ruby\" via PATH); target can use a different Ruby version. " \
69
+ "See README.") do |v|
70
+ options[:ruby_bin] = v
71
+ end
72
+ end
73
+
74
+ def add_format_option!(opts, options)
75
+ opts.on("--format FORMAT", %w[table json], "Output format: table (default) or json") do |v|
76
+ options[:format] = v
77
+ end
78
+ end
79
+
80
+ def add_policy_file_option!(opts, options)
81
+ opts.on("--policy-file FILE", "YAML file declaring role/action/model access expectations " \
82
+ "(see README); violations are flagged and exit non-zero") do |v|
83
+ options[:policy_file] = v
84
+ end
85
+ end
86
+
87
+ def add_html_report_option!(opts, options)
88
+ opts.on("--html-report FILE", "Write a self-contained, interactive HTML graph of the results " \
89
+ "(see README) to this path, relative to APP_PATH") do |v|
90
+ options[:html_report] = v
91
+ end
92
+ end
93
+
94
+ def add_loader_strategy_options!(opts, options)
95
+ add_require_option!(opts, options)
96
+ add_rails_boot_option!(opts, options)
97
+ add_rails_env_option!(opts, options)
98
+ end
99
+
100
+ def add_require_option!(opts, options)
101
+ opts.on("--require FILE", "Path, relative to APP_PATH, to preload before the " \
102
+ "Ability file (repeatable), see #2. Not compatible " \
103
+ "with --rails-boot.") do |v|
104
+ options[:requires] << v
105
+ end
106
+ end
107
+
108
+ def add_rails_boot_option!(opts, options)
109
+ opts.on("--rails-boot", "Run inside the target's own `bin/rails runner` for real " \
110
+ "Zeitwerk autoloading, see #3. Not compatible with --require.") do
111
+ options[:rails_boot] = true
112
+ end
113
+ end
114
+
115
+ def add_rails_env_option!(opts, options)
116
+ opts.on("--rails-env ENV", "RAILS_ENV to boot under with --rails-boot (default: " \
117
+ "#{Harness::DEFAULT_RAILS_ENV.inspect}).") do |v|
118
+ options[:rails_env] = v
119
+ end
120
+ end
121
+
122
+ def validate!(options)
123
+ abort("--rails-env only applies with --rails-boot.") if options[:rails_env] && !options[:rails_boot]
124
+
125
+ return unless options[:rails_boot] && !options[:requires].empty?
126
+
127
+ abort("--require is not compatible with --rails-boot -- once Zeitwerk is live via bin/rails runner, " \
128
+ "referenced classes resolve on their own; a manual --require list is superfluous. " \
129
+ "Drop one or the other.")
130
+ end
131
+ end
132
+ end
@@ -0,0 +1,74 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "json"
4
+ require_relative "table_formatter"
5
+ require_relative "terminal_safe"
6
+
7
+ module RubyAbilityGraph
8
+ # Turns a scan's results (plus an optional PolicyChecker::Report) into the
9
+ # CLI's printed output, in either format, and decides the process exit
10
+ # status -- kept separate from CLI so that class stays a thin dispatcher.
11
+ class ScanPresenter
12
+ SCHEMA_VERSION = 1
13
+
14
+ def initialize(format:, results:, policy_report:)
15
+ @format = format
16
+ @results = results
17
+ @policy_report = policy_report
18
+ end
19
+
20
+ def render
21
+ @format == "json" ? render_json : render_table
22
+ end
23
+
24
+ # nil policy_report means no --policy-file was given. An unmatched policy
25
+ # (typo'd model/action) counts as a problem too, same as a real violation.
26
+ def problems?
27
+ !@policy_report.nil? && (@policy_report.violations.any? || @policy_report.unmatched.any?)
28
+ end
29
+
30
+ private
31
+
32
+ def render_json
33
+ payload = { "schema_version" => SCHEMA_VERSION, "results" => @results }
34
+ if @policy_report
35
+ payload["policy_violations"] = @policy_report.violations
36
+ payload["policy_unmatched"] = @policy_report.unmatched
37
+ end
38
+ JSON.pretty_generate(payload)
39
+ end
40
+
41
+ def render_table
42
+ return TableFormatter.call(@results) if @policy_report.nil?
43
+
44
+ sections = [render_violations, render_unmatched].compact
45
+ "#{TableFormatter.call(@results)}\n\n#{sections.join("\n\n")}"
46
+ end
47
+
48
+ def render_violations
49
+ return "Policy check: no violations." if @policy_report.violations.empty?
50
+
51
+ lines = ["Policy violations (#{@policy_report.violations.size}):"] +
52
+ @policy_report.violations.map { |v| violation_line(v) }
53
+ lines.join("\n")
54
+ end
55
+
56
+ def render_unmatched
57
+ return nil if @policy_report.unmatched.empty?
58
+
59
+ lines = ["Policy entries with no matching scan result -- check for typos " \
60
+ "(#{@policy_report.unmatched.size}):"] + @policy_report.unmatched.map { |p| unmatched_line(p) }
61
+ lines.join("\n")
62
+ end
63
+
64
+ def violation_line(violation)
65
+ role, action, model = %w[role action model].map { |k| TerminalSafe.sanitize(violation[k]) }
66
+ allowed_roles = violation["allowed_roles"].map { |r| TerminalSafe.sanitize(r) }.join(", ")
67
+ " #{role} can #{action} #{model} but is not in allowed_roles (#{allowed_roles}) [#{violation['confidence']}]"
68
+ end
69
+
70
+ def unmatched_line(policy)
71
+ " #{TerminalSafe.sanitize(policy['model'])} / #{TerminalSafe.sanitize(policy['action'])}"
72
+ end
73
+ end
74
+ end
@@ -0,0 +1,64 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "terminal_safe"
4
+
5
+ module RubyAbilityGraph
6
+ # Renders scan results (role x action x model rows, string-keyed as they
7
+ # come back from Harness) as a plain-text table -- the human-readable
8
+ # counterpart to `--format json`, plus a resolved/unsupported coverage
9
+ # summary line.
10
+ class TableFormatter
11
+ HEADERS = %w[ROLE ACTION MODEL ALLOWED CONFIDENCE CONDITION].freeze
12
+
13
+ def self.call(results)
14
+ new(results).call
15
+ end
16
+
17
+ def initialize(results)
18
+ @results = results
19
+ end
20
+
21
+ def call
22
+ rows = sorted_rows
23
+ widths = column_widths(rows)
24
+ table_lines = [format_row(HEADERS, widths), format_row(separator_cells(widths), widths)]
25
+ rows.each { |row| table_lines << format_row(row, widths) }
26
+ "#{table_lines.join("\n")}\n\n#{summary_line}"
27
+ end
28
+
29
+ private
30
+
31
+ def sorted_rows
32
+ @results.sort_by { |r| [r["role"], r["model"], r["action"]] }
33
+ .map { |r| [r["role"], r["action"], r["model"], r["allowed"].to_s, r["confidence"], condition_cell(r)] }
34
+ end
35
+
36
+ def condition_cell(result)
37
+ result["condition"].nil? ? "-" : TerminalSafe.sanitize(result["condition"])
38
+ end
39
+
40
+ def separator_cells(widths)
41
+ widths.map { |w| "-" * w }
42
+ end
43
+
44
+ def column_widths(rows)
45
+ HEADERS.each_index.map { |i| ([HEADERS[i].length] + rows.map { |r| r[i].to_s.length }).max }
46
+ end
47
+
48
+ def format_row(cells, widths)
49
+ cells.each_with_index.map { |cell, i| cell.to_s.ljust(widths[i]) }.join(" ").rstrip
50
+ end
51
+
52
+ def summary_line
53
+ resolved = @results.count { |r| r["confidence"] == "resolved" }
54
+ total = @results.size
55
+ "#{resolved}/#{total} resolved (#{coverage_pct(resolved, total)}%)"
56
+ end
57
+
58
+ def coverage_pct(resolved, total)
59
+ return 0 if total.zero?
60
+
61
+ ((resolved.to_f / total) * 100).round(1)
62
+ end
63
+ end
64
+ end
@@ -0,0 +1,12 @@
1
+ # frozen_string_literal: true
2
+
3
+ module RubyAbilityGraph
4
+ # Strips control chars (e.g. ANSI escapes) before untrusted values hit the terminal.
5
+ module TerminalSafe
6
+ CONTROL_CHARS = /[\p{Cc}&&[^\n]]/
7
+
8
+ def self.sanitize(value)
9
+ value.to_s.gsub(CONTROL_CHARS, "")
10
+ end
11
+ end
12
+ end
@@ -0,0 +1,5 @@
1
+ # frozen_string_literal: true
2
+
3
+ module RubyAbilityGraph
4
+ VERSION = "0.1.0"
5
+ end
@@ -0,0 +1,19 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "ruby_ability_graph/version"
4
+ require_relative "ruby_ability_graph/role_stand_in"
5
+ require_relative "ruby_ability_graph/rule_classifier"
6
+ require_relative "ruby_ability_graph/enumerator"
7
+ require_relative "ruby_ability_graph/harness"
8
+ require_relative "ruby_ability_graph/inspector"
9
+ require_relative "ruby_ability_graph/terminal_safe"
10
+ require_relative "ruby_ability_graph/table_formatter"
11
+ require_relative "ruby_ability_graph/policy_checker"
12
+ require_relative "ruby_ability_graph/html_report"
13
+ require_relative "ruby_ability_graph/scan_presenter"
14
+ require_relative "ruby_ability_graph/scan_options"
15
+ require_relative "ruby_ability_graph/cli"
16
+
17
+ module RubyAbilityGraph
18
+ class Error < StandardError; end
19
+ end
metadata ADDED
@@ -0,0 +1,78 @@
1
+ --- !ruby/object:Gem::Specification
2
+ name: ruby_ability_graph
3
+ version: !ruby/object:Gem::Version
4
+ version: 0.1.0
5
+ platform: ruby
6
+ authors:
7
+ - Jessica Grider
8
+ bindir: exe
9
+ cert_chain: []
10
+ date: 1980-01-02 00:00:00.000000000 Z
11
+ dependencies:
12
+ - !ruby/object:Gem::Dependency
13
+ name: prism
14
+ requirement: !ruby/object:Gem::Requirement
15
+ requirements:
16
+ - - "~>"
17
+ - !ruby/object:Gem::Version
18
+ version: '1.0'
19
+ type: :runtime
20
+ prerelease: false
21
+ version_requirements: !ruby/object:Gem::Requirement
22
+ requirements:
23
+ - - "~>"
24
+ - !ruby/object:Gem::Version
25
+ version: '1.0'
26
+ description: |
27
+ ruby_ability_graph loads a Rails app's CanCanCan Ability class in isolation,
28
+ enumerates roles x actions x models, and reports resolved permissions --
29
+ distinguishing patterns it can fully resolve from ones it honestly flags as
30
+ unsupported rather than guessing.
31
+ executables:
32
+ - ruby-ability-graph
33
+ extensions: []
34
+ extra_rdoc_files: []
35
+ files:
36
+ - LICENSE.txt
37
+ - README.md
38
+ - exe/ruby-ability-graph
39
+ - lib/ruby_ability_graph.rb
40
+ - lib/ruby_ability_graph/cli.rb
41
+ - lib/ruby_ability_graph/enumerator.rb
42
+ - lib/ruby_ability_graph/harness.rb
43
+ - lib/ruby_ability_graph/html_report.rb
44
+ - lib/ruby_ability_graph/inspector.rb
45
+ - lib/ruby_ability_graph/policy_checker.rb
46
+ - lib/ruby_ability_graph/role_stand_in.rb
47
+ - lib/ruby_ability_graph/rule_classifier.rb
48
+ - lib/ruby_ability_graph/scan_options.rb
49
+ - lib/ruby_ability_graph/scan_presenter.rb
50
+ - lib/ruby_ability_graph/table_formatter.rb
51
+ - lib/ruby_ability_graph/terminal_safe.rb
52
+ - lib/ruby_ability_graph/version.rb
53
+ homepage: https://github.com/m1gd0n-dev/ruby-ability-graph
54
+ licenses:
55
+ - AGPL-3.0-or-later
56
+ metadata:
57
+ homepage_uri: https://github.com/m1gd0n-dev/ruby-ability-graph
58
+ source_code_uri: https://github.com/m1gd0n-dev/ruby-ability-graph
59
+ rubygems_mfa_required: 'true'
60
+ rdoc_options: []
61
+ require_paths:
62
+ - lib
63
+ required_ruby_version: !ruby/object:Gem::Requirement
64
+ requirements:
65
+ - - ">="
66
+ - !ruby/object:Gem::Version
67
+ version: '4.0'
68
+ required_rubygems_version: !ruby/object:Gem::Requirement
69
+ requirements:
70
+ - - ">="
71
+ - !ruby/object:Gem::Version
72
+ version: '0'
73
+ requirements: []
74
+ rubygems_version: 4.0.16
75
+ specification_version: 4
76
+ summary: Maps CanCanCan authorization rules into a visual, queryable 'who can access
77
+ what' model.
78
+ test_files: []