From 8ff82a15732104bd5d4b1322d66a5c01f6c526cf Mon Sep 17 00:00:00 2001 From: Jay Swain Date: Mon, 21 Jul 2025 13:14:19 -0700 Subject: [PATCH] Rake task for policy analysis Create a rake task to generate a graph so we can perform analysis on our policies Caution: VIBECODES --- lib/authz/policy_graph.rb | 238 +++++++++++++++++++++++++++ lib/tasks/gitlab/authz/policies.rake | 118 +++++++++++++ 2 files changed, 356 insertions(+) create mode 100644 lib/authz/policy_graph.rb create mode 100644 lib/tasks/gitlab/authz/policies.rake diff --git a/lib/authz/policy_graph.rb b/lib/authz/policy_graph.rb new file mode 100644 index 00000000000000..e2c3de89c4f3fe --- /dev/null +++ b/lib/authz/policy_graph.rb @@ -0,0 +1,238 @@ +module Authz + class PolicyGraph + class Node + attr_accessor :id, :label, :type, :data + + def initialize(id, label: nil, type: nil, data: {}) + @id = id + @label = label || id + @type = type + @data = data + end + + def to_s + "#{@type}:#{@label}" + end + end + + class Edge + attr_accessor :from, :to, :label, :type, :data + + def initialize(from, to, label: nil, type: nil, data: {}) + @from = from + @to = to + @label = label + @type = type + @data = data + end + + def to_s + "#{@from} -#{@label}-> #{@to}" + end + end + + class Graph + attr_reader :nodes, :edges + + def initialize + @nodes = {} + @edges = [] + end + + def add_node(id, label: nil, type: nil, data: {}) + @nodes[id] = Node.new(id, label: label, type: type, data: data) + end + + def add_edge(from_id, to_id, label: nil, type: nil, data: {}) + from_node = @nodes[from_id] + to_node = @nodes[to_id] + + return unless from_node && to_node + + edge = Edge.new(from_node, to_node, label: label, type: type, data: data) + @edges << edge + edge + end + + def node(id) + @nodes[id] + end + + def neighbors(node_id) + @edges.select { |e| e.from.id == node_id }.map(&:to) + end + + def find_nodes(type: nil) + nodes = @nodes.values + nodes = nodes.select { |n| n.type == type } if type + nodes + end + + def print_summary + puts "Graph Summary:" + puts " Nodes: #{@nodes.size}" + puts " Edges: #{@edges.size}" + + node_types = @nodes.values.group_by(&:type) + node_types.each do |type, nodes| + puts " #{type}: #{nodes.size}" + end + end + + def most_defining_policies(limit: 10) + policy_nodes = find_nodes(type: :policy) + + # Count abilities defined by each policy + policy_counts = policy_nodes.map do |node| + ability_count = neighbors(node.id).size + [node, ability_count] + end + + # Sort by ability count descending + sorted = policy_counts.sort_by { |_, count| -count } + + puts "Top #{limit} Policies by Abilities Defined:" + puts "=" * 50 + + sorted.first(limit).each_with_index do |(node, count), index| + puts "#{index + 1}. #{node.label}" + puts " Defines #{count} abilities" + + # Show sample abilities + abilities = neighbors(node.id).map { |ability_node| ability_node.data[:ability] } + sample_abilities = abilities.first(5) + puts " → #{sample_abilities.join(', ')}" + puts " ... and #{abilities.size - 5} more" if abilities.size > 5 + puts + end + end + + def most_enabling_permissions(limit: 10) + ability_nodes = find_nodes(type: :ability) + + # Count outgoing "enables" edges for each ability + enable_counts = ability_nodes.map do |node| + outgoing_enables = @edges.count { |e| e.from.id == node.id && e.type == :enables } + [node, outgoing_enables] + end + + # Sort by enable count descending + sorted = enable_counts.sort_by { |_, count| -count } + + puts "Top #{limit} Most Enabling Permissions:" + puts "=" * 50 + + sorted.first(limit).each_with_index do |(node, count), index| + next if count == 0 # Skip abilities that don't enable anything + + policy_name = node.data[:policy] + ability_name = node.data[:ability] + + puts "#{index + 1}. #{ability_name} (#{policy_name})" + puts " Enables #{count} other permissions" + + # Show what it enables + enabled_permissions = @edges + .select { |e| e.from.id == node.id && e.type == :enables } + .map { |e| e.to.data[:ability] } + + puts " → #{enabled_permissions.join(', ')}" if enabled_permissions.any? + puts + end + end + end + + def self.create_graph + load_all_policies + graph = Graph.new + + # Walk all policy classes + DeclarativePolicy::Base.descendants.each do |policy_class| + policy_name = policy_class.name + + # Add the policy node + graph.add_node(policy_name, + label: policy_name, + type: :policy, + data: { class: policy_class }) + + # Get abilities and their rules for this policy + ability_map = begin + policy_class.own_ability_map.map + rescue StandardError + {} + end + + ability_map.each do |ability, rules| + ability_id = "#{policy_name}::#{ability}" + + # Add ability node + unless graph.node(ability_id) + graph.add_node(ability_id, + label: ability.to_s, + type: :ability, + data: { ability: ability, policy: policy_name }) + end + + # Add edge from policy to ability + graph.add_edge(policy_name, ability_id, + label: "defines", + type: :defines) + + # Process rules to find ability dependencies + rules.each do |rule_type, rule_obj| + next unless rule_type == :enable + + # Extract abilities that enable this current ability + enabling_abilities = extract_enabling_abilities(rule_obj) + + enabling_abilities.each do |enabling_ability| + enabling_id = "#{policy_name}::#{enabling_ability}" + + # Add enabling ability node if not exists + unless graph.node(enabling_id) + graph.add_node(enabling_id, + label: enabling_ability.to_s, + type: :ability, + data: { ability: enabling_ability, policy: policy_name }) + end + + # Add enables edge: enabling_ability -> current_ability + graph.add_edge(enabling_id, ability_id, + label: "enables", + type: :enables) + end + end + end + end + + graph + end + + private + + def self.load_all_policies + # Load policies from standard Rails app directory + Dir[Rails.root.join('app/policies/**/*.rb')].each do |file| + require_dependency file + end + + # Load EE policies if they exist + ee_policies_path = Rails.root.join('ee/app/policies/**/*.rb') + return unless Dir.exist?(File.dirname(ee_policies_path)) + + Dir[ee_policies_path].each do |file| + require_dependency file + end + end + + def self.extract_enabling_abilities(rule_obj) + abilities = [] + + # Handle simple ability rules that use can?(:ability_name) + abilities << rule_obj.ability if rule_obj.respond_to?(:ability) + + abilities.uniq + end + end +end diff --git a/lib/tasks/gitlab/authz/policies.rake b/lib/tasks/gitlab/authz/policies.rake new file mode 100644 index 00000000000000..69ea8641ab78f6 --- /dev/null +++ b/lib/tasks/gitlab/authz/policies.rake @@ -0,0 +1,118 @@ +# frozen_string_literal: true + +namespace :gitlab do + namespace :authz do + namespace :policies do + desc "Generate authorization graph reports" + task report: :environment do + puts "=== AUTHORIZATION ANALYSIS REPORT ===" + puts "Generated at: #{Time.current}" + puts + + begin + # Create the graph + puts "Loading policies and building graph..." + graph = Authz::PolicyGraph.create_graph + + # Basic summary + graph.print_summary + puts + + # Most complex policies + puts "=" * 60 + graph.most_defining_policies(limit: 10) + puts + + # Most enabling permissions + puts "=" * 60 + graph.most_enabling_permissions(limit: 15) + + rescue StandardError => e + puts "Error generating report: #{e.message}" + puts e.backtrace.first(5) if ENV['DEBUG'] + end + end + + desc "Generate detailed authorization graph reports with custom limits" + task :detailed_report, [:policy_limit, :permission_limit] => :environment do |task, args| + policy_limit = (args[:policy_limit] || 20).to_i + permission_limit = (args[:permission_limit] || 25).to_i + + puts "=== DETAILED AUTHORIZATION ANALYSIS REPORT ===" + puts "Generated at: #{Time.current}" + puts "Policy limit: #{policy_limit}, Permission limit: #{permission_limit}" + puts + + begin + # Create the graph + puts "Loading policies and building graph..." + graph = Authz::PolicyGraph.create_graph + + # Basic summary + graph.print_summary + puts + + # Most complex policies + puts "=" * 60 + graph.most_defining_policies(limit: policy_limit) + puts + + # Most enabling permissions + puts "=" * 60 + graph.most_enabling_permissions(limit: permission_limit) + + rescue StandardError => e + puts "Error generating detailed report: #{e.message}" + puts e.backtrace.first(10) if ENV['DEBUG'] + end + end + + desc "Save authorization graph report to file" + task :report_to_file, [:filename] => :environment do |task, args| + filename = args[:filename] || "auth_report_#{Date.current.strftime('%Y%m%d')}.txt" + filepath = Rails.root.join('tmp', filename) + + puts "Generating authorization report to #{filepath}..." + + begin + File.open(filepath, 'w') do |file| + # Redirect stdout to file + original_stdout = $stdout + $stdout = file + + puts "=== AUTHORIZATION ANALYSIS REPORT ===" + puts "Generated at: #{Time.current}" + puts + + # Create the graph + graph = Authz::PolicyGraph.create_graph + + # Basic summary + graph.print_summary + puts + + # Most complex policies + puts "=" * 60 + graph.most_defining_policies(limit: 15) + puts + + # Most enabling permissions + puts "=" * 60 + graph.most_enabling_permissions(limit: 20) + + # Restore stdout + $stdout = original_stdout + end + + puts "Report saved to #{filepath}" + puts "File size: #{File.size(filepath)} bytes" + + rescue StandardError => e + $stdout = original_stdout if defined?(original_stdout) + puts "Error saving report: #{e.message}" + puts e.backtrace.first(5) if ENV['DEBUG'] + end + end + end + end +end -- GitLab