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.
All introspection of authorization rules to understand why users can/cannot perform an action. That helps with debugging and offers better error messages.
# 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 [email protected](*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
# 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
Copy link
Author

pcreux commented Sep 8, 2025

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... so it's been on my mind for a while. :)

It'd be easy to add this 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

The API remains the same (ex: message_policy.update?) but we'd get better error messages, such as:
Cannot update: "(❌ IsAdmin) or (✅ IsPublisher and ❌ IsOwner)"

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment