Last active
          September 8, 2025 12:50 
        
      - 
      
- 
        Save pcreux/16c7689cb2c5a6d28def22609bc26089 to your computer and use it in GitHub Desktop. 
Revisions
- 
        pcreux revised this gist Sep 8, 2025 . 1 changed file with 6 additions and 6 deletions.There are no files selected for viewingThis file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters. Learn more about bidirectional Unicode charactersOriginal file line number Diff line number Diff line change @@ -1,12 +1,12 @@ # It'd be easy to add a DSL to existing policy frameworks such as pundit or activepolicy. class MessagePolicy < Policy rule(:is_admin) { |user| user.admin? } rule(:is_publisher) { |user| user.publisher? } rule(:is_owner) { |user, message| message.user_id == user.id } permit :read, true permit :create, Any(:is_admin, :is_publisher) permit :update, Any(:is_admin, All(:is_publisher, :is_owner)) permit :delete, Any(:is_admin, All(:is_publisher, :is_owner)) end 
- 
        pcreux revised this gist Sep 8, 2025 . 1 changed file with 10 additions and 3 deletions.There are no files selected for viewingThis file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters. Learn more about bidirectional Unicode charactersOriginal file line number Diff line number Diff line change @@ -1,6 +1,15 @@ # 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? @@ -48,14 +57,12 @@ def publisher? # {: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?" 
- 
        pcreux revised this gist Sep 8, 2025 . 1 changed file with 2 additions and 3 deletions.There are no files selected for viewingThis file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters. Learn more about bidirectional Unicode charactersOriginal file line number Diff line number Diff line change @@ -1,6 +1,5 @@ # 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. # Let's define a User and a Message struct for demonstration purposes. User = Struct.new(:id, :admin, :publisher, keyword_init: true) do 
- 
        pcreux revised this gist Sep 8, 2025 . 2 changed files with 18 additions and 2 deletions.There are no files selected for viewingThis file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters. Learn more about bidirectional Unicode charactersOriginal file line number Diff line number Diff line change @@ -1,3 +1,7 @@ # 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. # I played [with this idea 10 years ago](https://gist.github.com/pcreux/445da2f6422d6f348aae)... so it's been on my mind for a while. :) # Let's define a User and a Message struct for demonstration purposes. User = Struct.new(:id, :admin, :publisher, keyword_init: true) do def admin? @@ -17,9 +21,9 @@ def publisher? 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)) @@ -216,4 +220,4 @@ def explain def to_h ast.to_h end end This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters. Learn more about bidirectional Unicode charactersOriginal file line number Diff line number Diff line change @@ -0,0 +1,12 @@ # It'd be easy to add a DSL to existing policy frameworks such as pundit or activepolicy. class MessagePolicy < Policy rule("IsAdmin") { |user| user.is_admin? } rule("IsPublisher") { |user| user.publisher? } rule("IsOwner") { |user, message| message.user_id == user.id } permit :read, true permit :create, Any(IsAdmin, IsPublisher) permit :update, Any(IsAdmin, All(IsPublisher, IsOwner)) permit :delete, Any(IsAdmin, All(IsPublisher, IsOwner)) end 
- 
        pcreux revised this gist Sep 8, 2025 . No changes.There are no files selected for viewing
- 
        pcreux created this gist Sep 8, 2025 .There are no files selected for viewingThis file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters. Learn more about bidirectional Unicode charactersOriginal file line number Diff line number Diff line change @@ -0,0 +1,219 @@ # 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 } CanRead = Rule.define("CanRead") { true } # High level rules are combinations of low level rules 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} # ]} # ]} puts 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)" puts 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