|
# 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 |
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.
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)"