# I would really like to introspect complex authorization rules to understand why users can/cannot perform an action. # That helps with debugging and offers better error messages. # Sample: # # ```ruby # auth = Authorize.new(CanUpdate, owner, message_1) # pp auth.permitted? # => false # pp auth.explain # # => "(❌ IsAdmin) or (✅ IsPublisher and ❌ IsOwner)" # ``` # Let's define a User and a Message struct for demonstration purposes. User = Struct.new(:id, :admin, :publisher, keyword_init: true) do def admin? admin end def publisher? publisher end end Message = Struct.new(:id, :user_id, keyword_init: true) at_exit do # We now define the rules. # Low level rules that take a user (and optionally a resource) IsAdmin = Rule.define("IsAdmin") { |user| user.admin? } IsPublisher = Rule.define("IsPublisher") { |user| user.publisher? } IsOwner = Rule.define("IsOwner") { |user, message| message.user_id == user.id } # High level rules are combinations of low level rules CanRead = Rule.define("CanRead") { true } CanCreate = Rule.any(IsAdmin, IsPublisher) CanUpdate = Rule.any(IsAdmin, Rule.all(IsPublisher, IsOwner)) CanDelete = Rule.any(IsAdmin, Rule.all(IsPublisher, IsOwner)) admin = User.new(id: 1, admin: true, publisher: false) owner = User.new(id: 2, admin: false, publisher: true) not_publisher = User.new(id: 3, admin: false, publisher: false) message_1 = Message.new(id: 100, user_id: 1) message_2 = Message.new(id: 101, user_id: 2) message_3 = Message.new(id: 102, user_id: 3) auth = Authorize.new(CanUpdate, admin, message_1) puts "Can admin update message_1?" pp auth.permitted? # => true pp auth.explain # => "(✅ IsAdmin) or (❌ IsPublisher and ✅ IsOwner)" pp auth.to_h # => {:type=>:any, :label=>"Any", :ok=>true, :children=>[ # {:type=>:leaf, :label=>"IsAdmin", :ok=>true}, # {:type=>:all, :label=>"All", :ok=>false, children=>[ # {:type=>:leaf, :label=>"IsPublisher", :ok=>false}, # {:type=>:leaf, :label=>"IsOwner", :ok=>true} # ]} # ]} auth = Authorize.new(CanUpdate, owner, message_1) puts "Can owner update message_1?" pp auth.permitted? # => false pp auth.explain # => "(❌ IsAdmin) or (✅ IsPublisher and ❌ IsOwner)" auth = Authorize.new(CanUpdate, owner, message_2) puts "Can owner update message_2?" pp auth.permitted? # => true pp auth.explain # => "(❌ IsAdmin) or (✅ IsPublisher and ✅ IsOwner)" auth = Authorize.new(CanUpdate, not_publisher, message_3) puts "Can not_publisher update message_3?" pp auth.permitted? # => false pp auth.explain # => "(❌ IsAdmin) or (❌ IsPublisher and ✅ IsOwner)" end # Implementation # An AstNode represents the result of evaluating a Rule. class AstNode attr_reader :type, :label, :ok, :children def initialize(type:, label:, ok:, children: []) @type = type # :leaf | :all | :any @label = label # e.g., "IsAdmin", "All", "Any" @ok = !!ok # boolean result at this node @children = children # [AstNode] end # Pretty rendering: "(✅ IsAdmin) or (❌ IsOwner and ✅ IsPublisher)" def to_s case type when :leaf "#{ok ? '✅' : '❌'} #{label}" when :any inner = children.map(&:group_string) "(#{inner.join(') or (')})" when :all # keep a stable order; tweak if you prefer alpha: inner = children.map(&:to_s) "(#{inner.join(' and ')})" end end # Minimal group string (used by :any to avoid double parens) def group_string return to_s if type == :leaf to_s.sub(/\A\(/, '').sub(/\)\z/, '') end # Hash for UI (e.g., JSON to your frontend) def to_h h = { type: type, label: label, ok: ok } h[:children] = children.map(&:to_h) unless children.empty? h end end # A Rule can be a leaf (a callable) or a composite (any/all of other rules). class Rule attr_reader :type, :children def initialize(name: nil, type: :leaf, callable: nil, children: []) @explicit_name = name @type = type @callable = callable @children = children end def self.define(name = nil, &block) raise ArgumentError, "block required" unless block new(name: name, type: :leaf, callable: block) end def self.any(*rules) flat = rules.flatten raise ArgumentError, "at least one rule" if flat.empty? new(name: "Any", type: :any, children: flat) end def self.all(*rules) flat = rules.flatten raise ArgumentError, "at least one rule" if flat.empty? new(name: "All", type: :all, children: flat) end def evaluate(*ctx) case type when :leaf then !!@callable.call(*ctx) when :any then children.any? { |r| r.evaluate(*ctx) } when :all then children.all? { |r| r.evaluate(*ctx) } else raise "unknown rule type #{type}" end end def label @explicit_name || infer_constant_name || (type == :leaf ? "(anonymous rule)" : type.to_s.capitalize) end # --- AST builder --- def build_ast(*ctx) case type when :leaf AstNode.new(type: :leaf, label: label, ok: evaluate(*ctx)) when :any child_nodes = children.map { |r| r.build_ast(*ctx) } AstNode.new(type: :any, label: label, ok: child_nodes.any?(&:ok), children: child_nodes) when :all child_nodes = children.map { |r| r.build_ast(*ctx) } AstNode.new(type: :all, label: label, ok: child_nodes.all?(&:ok), children: child_nodes) end end private def infer_constant_name ObjectSpace.each_object(Module) do |mod| mod.constants(false).each do |const| begin return const.to_s if mod.const_get(const).equal?(self) rescue NameError next end end end nil end end # Take a Rule and context (user, resource, etc) and evaluate it. class Authorize def initialize(rule, *context) @rule = rule @context = context @ast = nil end # Build (or return cached) AST def ast @ast ||= @rule.build_ast(*@context) end # High-level API def permitted? ast.ok end def explain ast.to_s end # For UIs that want raw data (e.g., JSON) def to_h ast.to_h end end