Skip to content

Instantly share code, notes, and snippets.

@pcreux
Last active September 8, 2025 12:50
Show Gist options
  • Save pcreux/16c7689cb2c5a6d28def22609bc26089 to your computer and use it in GitHub Desktop.
Save pcreux/16c7689cb2c5a6d28def22609bc26089 to your computer and use it in GitHub Desktop.

Revisions

  1. pcreux revised this gist Sep 8, 2025. 1 changed file with 6 additions and 6 deletions.
    12 changes: 6 additions & 6 deletions integration.rb
    Original 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("IsAdmin") { |user| user.is_admin? }
    rule("IsPublisher") { |user| user.publisher? }
    rule("IsOwner") { |user, message| message.user_id == user.id }
    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(IsAdmin, IsPublisher)
    permit :update, Any(IsAdmin, All(IsPublisher, IsOwner))
    permit :delete, Any(IsAdmin, All(IsPublisher, IsOwner))
    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
  2. pcreux revised this gist Sep 8, 2025. 1 changed file with 10 additions and 3 deletions.
    13 changes: 10 additions & 3 deletions authorizations.rb
    Original 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}
    # ]}
    # ]}
    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
    # => "(❌ IsAdmin) or (✅ IsPublisher and ❌ IsOwner)"

    auth = Authorize.new(CanUpdate, owner, message_2)
    puts "Can owner update message_2?"
  3. pcreux revised this gist Sep 8, 2025. 1 changed file with 2 additions and 3 deletions.
    5 changes: 2 additions & 3 deletions authorizations.rb
    Original 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.

    # I played [with this idea 10 years ago](https://gist.github.com/pcreux/445da2f6422d6f348aae)... so it's been on my mind for a while. :)
    # 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
  4. pcreux revised this gist Sep 8, 2025. 2 changed files with 18 additions and 2 deletions.
    8 changes: 6 additions & 2 deletions authorizations.rb
    Original 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 }
    CanRead = Rule.define("CanRead") { true }

    # 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
    end
    12 changes: 12 additions & 0 deletions integration.rb
    Original 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
  5. pcreux revised this gist Sep 8, 2025. No changes.
  6. pcreux created this gist Sep 8, 2025.
    219 changes: 219 additions & 0 deletions authorizations.rb
    Original 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