From b41094d21f5e26068dd999fe57c4fcb108f9ecd4 Mon Sep 17 00:00:00 2001 From: Matthew Ford Date: Wed, 17 Jun 2026 12:51:23 +0100 Subject: [PATCH 1/3] Improve authorization rule performance Add cached rule matching, unconditional-rule fast paths, per-check value block caching, and opt-in request-local permission caching. Add performance tests, a generic benchmark, and README notes for the fork's Rails and performance improvements. --- README.rdoc | 46 +- Rakefile | 11 + Readme.md | 41 +- benchmarks/authorization_rules_benchmark.rb | 164 ++++++ .../authorization.rb | 489 ++++++++++++---- .../in_controller.rb | 12 +- test/authorization_test.rb | 528 ++++++++++++++++++ test/controller_test.rb | 39 +- test/model_test.rb | 16 +- .../authorization_performance_test.rb | 210 +++++++ 10 files changed, 1440 insertions(+), 116 deletions(-) create mode 100644 benchmarks/authorization_rules_benchmark.rb create mode 100644 test/performance/authorization_performance_test.rb diff --git a/README.rdoc b/README.rdoc index 12edfcab..cf048cfb 100644 --- a/README.rdoc +++ b/README.rdoc @@ -21,7 +21,9 @@ Plugin features * Authorize CRUD (Create, Read, Update, Delete) activities * Query rewriting to automatically only fetch authorized records * DSL for specifying Authorization rules in an authorization configuration -* Support for Rails 4.1-2 +* Support for modern Rails versions, including Rails 8 compatibility work in + this fork, while preserving backwards compatibility on a best-effort basis +* Performance improvements for larger rule sets and repeated permission checks Requirements @@ -33,6 +35,47 @@ Requirements See below for installation instructions. +== Fork Maintenance and Performance Improvements + +This fork keeps Declarative Authorization working on newer Rails versions, +including Rails 8 compatibility work, and also includes performance +improvements for applications with larger authorization rule sets or repeated +permission checks in controllers and views. + +The engine now avoids repeated work in several common paths: + +* authorization rules are indexed by context, role, and privilege +* flattened role and privilege hierarchies are cached per engine +* unconditional matching rules can return before allocating an attribute + validator +* repeated value blocks in one +if_attribute+ check are evaluated once per + authorization check +* repeated nested +if_permitted_to+ checks are cached for the lifetime of one + attribute validator +* +permit?+ can use an opt-in request-local permission result cache + +The request-local cache is thread-local and block-scoped. It is deliberately +not a persistent cache store. It does not use Redis, Memcached, or ++Rails.cache+, and it does not cache +permit!+ exception behavior. + +To enable request-local permission caching around a block: + + Authorization.with_permission_cache do + authorization_engine.permit?(:show, :object => record, :user => current_user) + end + +For controllers with repeated view/helper permission checks, opt in with: + + class ApplicationController < ActionController::Base + cache_permission_checks + end + +If a request mutates permission-relevant state and then checks permissions +again, clear the request-local cache: + + Authorization.clear_cache! + + There is a decl_auth screencast by Ryan Bates, nicely introducing the main concepts: http://railscasts.com/episodes/188-declarative-authorization @@ -614,4 +657,3 @@ TJ Singleton, Mike Vincent, Joel Westerberg Copyright (c) 2008 Steffen Bartsch, TZI, Universität Bremen, Germany released under the MIT license - diff --git a/Rakefile b/Rakefile index 223a7d11..cf0f319f 100644 --- a/Rakefile +++ b/Rakefile @@ -1,6 +1,7 @@ require 'rake' require 'rake/testtask' require 'rdoc/task' +require 'rbconfig' desc 'Default: run unit tests against all versions.' task default: 'bundles:test' @@ -30,6 +31,16 @@ Rake::TestTask.new(:test) do |t| t.verbose = true end +namespace :test do + desc 'Run opt-in authorization performance tests.' + task :performance do + sh({ 'RUN_PERFORMANCE_TESTS' => '1' }, + RbConfig.ruby, + '-Ilib:test', + *Dir['test/performance/**/*_test.rb']) + end +end + desc 'Generate documentation for the authorization plugin.' Rake::RDocTask.new(:rdoc) do |rdoc| rdoc.rdoc_dir = 'rdoc' diff --git a/Readme.md b/Readme.md index 24647212..de8f1e36 100644 --- a/Readme.md +++ b/Readme.md @@ -1,6 +1,35 @@ Original docs: [https://github.com/stffn/declarative_authorization/blob/master/README.rdoc](https://github.com/stffn/declarative_authorization/blob/master/README.rdoc) -Extends Declarative Authorization with Rails 5.2 and Ruby 2.5 support, aiming for backwards compatibility as far as Rails 4.2 and Ruby 2.2 on a best-effort basis. +This fork keeps Declarative Authorization working on newer Rails versions, including Rails 8 compatibility work, while aiming for backwards compatibility as far as Rails 4.2 and Ruby 2.2 on a best-effort basis. + +It also includes performance improvements for larger authorization rule sets and repeated permission checks: + +- indexed rule lookup by context, role, and privilege +- cached flattened role and privilege hierarchies +- fast-path handling for unconditional rules +- per-check caching of repeated `if_attribute` value blocks +- per-check caching of repeated nested `if_permitted_to` checks +- opt-in request-local caching for repeated `permit?` / `permitted_to?` calls + +The request-local cache is deliberately short lived and thread local. It does not use Redis, Memcached, or `Rails.cache`, and it does not cache `permit!` exception behavior. + +To enable request-local permission caching around a block: + +```ruby +Authorization.with_permission_cache do + authorization_engine.permit?(:show, object: record, user: current_user) +end +``` + +For controllers with repeated view/helper permission checks: + +```ruby +class ApplicationController < ActionController::Base + cache_permission_checks +end +``` + +Call `Authorization.clear_cache!` after permission-relevant writes inside a cached block/request if the same request can re-check mutated objects or roles. Branch r5: [![Build Status](https://travis-ci.org/Xymist/declarative_authorization.svg?branch=r5)](https://travis-ci.org/Xymist/declarative_authorization) @@ -13,4 +42,14 @@ bundle bundle exec rake test ``` +Run the opt-in performance tests with: + +``` +bundle exec rake test:performance +``` + +Run the generic authorization benchmark with: +``` +ruby benchmarks/authorization_rules_benchmark.rb +``` diff --git a/benchmarks/authorization_rules_benchmark.rb b/benchmarks/authorization_rules_benchmark.rb new file mode 100644 index 00000000..ed429b08 --- /dev/null +++ b/benchmarks/authorization_rules_benchmark.rb @@ -0,0 +1,164 @@ +# frozen_string_literal: true + +require 'benchmark' +require 'pathname' + +require 'active_support/core_ext/array/extract_options' +require 'active_support/core_ext/hash/deep_merge' +require 'active_support/core_ext/object/blank' +require 'active_support/core_ext/string/inflections' + +module Rails + def self.root + '.' + end + + def self.env + 'benchmark' + end +end unless defined?(Rails) + +require_relative '../lib/declarative_authorization/authorization' + +class BenchmarkObject + def initialize(attrs = {}) + @__attrs = attrs + attrs.each do |key, _value| + self.class.define_method(key) { @__attrs[key] } unless respond_to?(key) + end + end + + def inspect + "#<#{self.class.name}>" + end +end + +class BenchmarkUser < BenchmarkObject + def initialize(*roles) + attrs = roles.last.is_a?(Hash) ? roles.pop : {} + super({ role_symbols: roles }.merge(attrs)) + end +end + +class BenchmarkRoleRecord < BenchmarkObject + def self.name + 'Role' + end +end + +def generated_authorization_rules + filler_rules = 120.times.map do |idx| + " role :filler_role_#{idx} do\n" \ + " has_permission_on :reports, :to => :filler_privilege_#{idx}\n" \ + " end" + end.join("\n") + + <<-RULES + privileges do + privilege :manage, :items do + includes :show, :edit, :update + end + end + + authorization do +#{filler_rules} + + role :primary_user do + has_permission_on :reports, :to => :index + + has_permission_on :items, :to => :manage do + if_attribute :organization => { :id => is { user.organization.id } } + end + + has_permission_on :messages, :to => :edit do + if_attribute :organization => { :id => is { user.organization.id } } + end + + has_permission_on :messages, :to => :edit do + if_attribute :owner_id => is { user.id } + end + + has_permission_on :roles, :to => :show do + if_attribute :name => ['manager', 'member'] + end + + has_permission_on :accounts, :to => :show do + if_permitted_to :show, :roles + end + end + + role :limited_user do + has_permission_on :orders, :to => :cancel, :join_by => :and do + if_attribute :organization => { :id => is { user.organization.id } } + if_attribute :status => is { 'issued' } + if_attribute :deleted => is { false } + end + end + end + RULES +end + +iterations = Integer(ENV.fetch('ITERATIONS', '20_000')) + +reader = Authorization::Reader::DSLReader.new +reader.parse(generated_authorization_rules) +engine = Authorization::Engine.new(reader) + +organization = BenchmarkObject.new(id: 10) +primary_user = BenchmarkUser.new( + :primary_user, + id: 7, + organization: organization +) +limited_user = BenchmarkUser.new( + :limited_user, + id: 8, + organization: organization +) + +message = BenchmarkObject.new(organization: organization, owner_id: primary_user.id) +item = BenchmarkObject.new(organization: organization) +role_record = BenchmarkRoleRecord.new(name: 'manager') +account = BenchmarkObject.new(roles: [role_record]) +order = BenchmarkObject.new( + organization: organization, + status: 'issued', + deleted: false +) + +checks = { + 'large context, no attributes' => [:index, { context: :reports, user: primary_user }], + 'hierarchical privilege, attributes' => [:manage, { context: :items, user: primary_user, object: item }], + 'multiple attribute alternatives' => [:edit, { context: :messages, user: primary_user, object: message }], + 'nested permission attribute' => [:show, { context: :accounts, user: primary_user, object: account }], + 'multi-attribute rule' => [:cancel, { context: :orders, user: limited_user, object: order }] +} + +puts "Rules: #{reader.auth_rules_reader.auth_rules.length}" +puts "Contexts: #{reader.auth_rules_reader.auth_rules.flat_map { |rule| rule.contexts.to_a }.uniq.length}" +puts "Iterations per check: #{iterations}" +puts + +def run_checks(engine, checks, iterations) + checks.each do |label, (privilege, options)| + engine.permit?(privilege, options) + + seconds = Benchmark.realtime do + iterations.times do + engine.permit?(privilege, options) + end + end + + calls_per_second = iterations / seconds + puts format('%-34s %8.4fs %10.1f calls/sec', label, seconds, calls_per_second) + end +end + +puts 'Uncached permit?' +run_checks(engine, checks, iterations) +puts + +puts 'With request-local permission cache' +Authorization.with_permission_cache do + run_checks(engine, checks, iterations) +end diff --git a/lib/declarative_authorization/authorization.rb b/lib/declarative_authorization/authorization.rb index 429e4bab..f56ae3ce 100644 --- a/lib/declarative_authorization/authorization.rb +++ b/lib/declarative_authorization/authorization.rb @@ -4,6 +4,9 @@ require 'forwardable' module Authorization + PERMISSION_CACHE_KEY = 'authorization_permission_cache'.freeze + PERMISSION_CACHE_ENABLED_KEY = 'authorization_permission_cache_enabled'.freeze + # An exception raised if anything goes wrong in the Authorization realm class AuthorizationError < StandardError; end # NotAuthorized is raised if the current user is not allowed to perform @@ -33,6 +36,59 @@ def self.current_user=(user) Thread.current['current_user'] = user end + # Enables a request-local permission result cache for calls to Engine#permit? + # inside the block. The cache is thread-local and is always restored when the + # block exits. + def self.with_permission_cache + previous_enabled = Thread.current[PERMISSION_CACHE_ENABLED_KEY] + previous_cache = Thread.current[PERMISSION_CACHE_KEY] + already_enabled = permission_cache_enabled? + + Thread.current[PERMISSION_CACHE_ENABLED_KEY] = true + Thread.current[PERMISSION_CACHE_KEY] = already_enabled ? (previous_cache || {}) : {} + + yield + ensure + Thread.current[PERMISSION_CACHE_KEY] = previous_cache + Thread.current[PERMISSION_CACHE_ENABLED_KEY] = previous_enabled + end + + def self.permission_cache_enabled? + Thread.current[PERMISSION_CACHE_ENABLED_KEY] == true + end + + def self.permission_cache + Thread.current[PERMISSION_CACHE_KEY] if permission_cache_enabled? + end + + def self.clear_permission_cache! + if permission_cache_enabled? + cache = Thread.current[PERMISSION_CACHE_KEY] + cache ? cache.clear : Thread.current[PERMISSION_CACHE_KEY] = {} + end + true + end + + def self.clear_cache! + clear_permission_cache! + end + + def self.permission_cache_record_key(record) + return nil if record.nil? + + key = [record.class.name, record.object_id] + %i[id cache_version updated_at].each do |method| + next unless record.respond_to?(method) + + begin + key << record.public_send(method) + rescue StandardError + key << nil + end + end + key.freeze + end + # For use in test cases only def self.ignore_access_control(state = nil) # :nodoc: Thread.current['ignore_access_control'] = state unless state.nil? @@ -85,10 +141,16 @@ def initialize(reader = nil) @reader = Reader::DSLReader.factory(reader || AUTH_DSL_FILES) @rev_priv_hierarchy ||= {} @rev_role_hierarchy ||= {} + @flattened_roles_cache = {} + @flattened_privileges_cache = {} end def initialize_copy(from) # :nodoc: @reader = from.reader.clone + @rev_priv_hierarchy = {} + @rev_role_hierarchy = {} + @flattened_roles_cache = {} + @flattened_privileges_cache = {} end # {[priv, ctx] => [priv, ...]} @@ -141,71 +203,16 @@ def rev_role_hierarchy # Defaults to true. # def permit!(privilege, options = {}) - return true if Authorization.ignore_access_control - options = { - object: nil, - skip_attribute_test: false, - context: nil, - bang: true - }.merge(options) - - # Make sure we're handling all privileges as symbols. - privilege = privilege.is_a?(Array) ? - privilege.flatten.collect(&:to_sym) : - privilege.to_sym - - # - # If the object responds to :proxy_reflection, we're probably working with - # an association proxy. Use 'new' to leverage ActiveRecord's builder - # functionality to obtain an object against which we can check permissions. - # - # Example: permit!( :edit, :object => user.posts ) - # - if Authorization.is_a_association_proxy?(options[:object]) && options[:object].respond_to?(:new) - options[:object] = options[:object].where(nil).new - end - - begin - options[:context] ||= options[:object] && ( - options[:object].class.respond_to?(:decl_auth_context) ? - options[:object].class.decl_auth_context : - options[:object].class.name.tableize.to_sym - ) - rescue StandardError - NoMethodError - end - - user, roles, privileges = user_roles_privleges_from_options(privilege, options) - - return true if roles.is_a?(Array) && !(roles & omnipotent_roles).empty? - - # find a authorization rule that matches for at least one of the roles and - # at least one of the given privileges - attr_validator = AttributeValidator.new(self, user, options[:object], privilege, options[:context]) - rules = matching_auth_rules(roles, privileges, options[:context]) - - # Test each rule in turn to see whether any one of them is satisfied. - rules.each do |rule| - return true if rule.validate?(attr_validator, options[:skip_attribute_test]) - end - - if options[:bang] - if rules.empty? - raise NotAuthorized, "No matching rules found for #{privilege} for User with id #{user.try(:id)} " \ - "(roles #{roles.inspect}, privileges #{privileges.inspect}, " \ - "context #{options[:context].inspect})." - else - raise AttributeAuthorizationError, "#{privilege} not allowed for User with id #{user.try(:id)} on #{(options[:object] || options[:context]).inspect}." - end - else - false - end + bang = options.key?(:bang) ? options[:bang] : true + permit_result(privilege, options, bang) end # Calls permit! but doesn't raise authorization errors. If no exception is # raised, permit? returns true and yields to the optional block. def permit?(privilege, options = {}) # :yields: - if permit!(privilege, options.merge(bang: false)) + permitted = cached_permit_result(privilege, options) + + if permitted yield if block_given? true else @@ -235,7 +242,7 @@ def obligations(privilege, options = {}) permit!(privilege, skip_attribute_test: true, user: user, context: options[:context]) - return [] if roles.is_a?(Array) && !(roles & omnipotent_roles).empty? + return [] if includes_omnipotent_role?(roles) attr_validator = AttributeValidator.new(self, user, nil, privilege, options[:context]) matching_auth_rules(roles, privileges, options[:context]).collect do |rule| @@ -280,7 +287,7 @@ def roles_for(user) # Returns the role symbols and inherritted role symbols for the given user def roles_with_hierarchy_for(user) - flatten_roles(roles_for(user)) + flatten_roles(roles_for(user)).dup end def self.development_reload? @@ -319,23 +326,103 @@ def initialize(engine, user, object = nil, privilege = nil, context = nil) @object = object @privilege = privilege @context = context + @evaluated_value_blocks = {} + @permission_results = {} end def evaluate(value_block) - # TODO: cache? - instance_eval(&value_block) + @evaluated_value_blocks.fetch(value_block) do + @evaluated_value_blocks[value_block] = instance_eval(&value_block) + end + end + + def permitted_to?(privilege, object) + key = [ + privilege.is_a?(Array) ? privilege.flatten.collect(&:to_sym) : privilege.to_sym, + object && object.object_id + ].freeze + + @permission_results.fetch(key) do + @permission_results[key] = engine.permit?(privilege, object: object, user: user) + end end end private + def permit_result(privilege, options, bang) + return true if Authorization.ignore_access_control + + object = options.key?(:object) ? options[:object] : nil + skip_attribute_test = options.key?(:skip_attribute_test) ? options[:skip_attribute_test] : false + context = options.key?(:context) ? options[:context] : nil + user_option = options[:user] + user_roles_option = options[:user_roles] + + # Make sure we're handling all privileges as symbols. + privilege = privilege.is_a?(Array) ? + privilege.flatten.collect(&:to_sym) : + privilege.to_sym + + # + # If the object responds to :proxy_reflection, we're probably working with + # an association proxy. Use 'new' to leverage ActiveRecord's builder + # functionality to obtain an object against which we can check permissions. + # + # Example: permit!( :edit, :object => user.posts ) + # + if Authorization.is_a_association_proxy?(object) && object.respond_to?(:new) + object = object.where(nil).new + end + + begin + context ||= object && ( + object.class.respond_to?(:decl_auth_context) ? + object.class.decl_auth_context : + object.class.name.tableize.to_sym + ) + rescue StandardError + NoMethodError + end + + user, roles, privileges = user_roles_privleges(privilege, user_option, user_roles_option, context) + + return true if includes_omnipotent_role?(roles) + + # Find an authorization rule that matches at least one role and privilege. + rules = matching_auth_rules(roles, privileges, context) + + if skip_attribute_test + return true unless rules.empty? + else + attr_validator = nil + rules.each do |rule| + return true if rule.unconditional? + + attr_validator ||= AttributeValidator.new(self, user, object, privilege, context) + return true if rule.validate?(attr_validator) + end + end + + if bang + if rules.empty? + raise NotAuthorized, "No matching rules found for #{privilege} for User with id #{user.try(:id)} " \ + "(roles #{roles.inspect}, privileges #{privileges.inspect}, " \ + "context #{context.inspect})." + else + raise AttributeAuthorizationError, "#{privilege} not allowed for User with id #{user.try(:id)} on #{(object || context).inspect}." + end + else + false + end + end + def user_roles_privleges_from_options(privilege, options) - options = { - user: nil, - context: nil, - user_roles: nil - }.merge(options) - user = options[:user] || Authorization.current_user + user_roles_privleges(privilege, options[:user], options[:user_roles], options[:context]) + end + + def user_roles_privleges(privilege, user, user_roles, context) + user ||= Authorization.current_user privileges = privilege.is_a?(Array) ? privilege : [privilege] unless user @@ -343,13 +430,18 @@ def user_roles_privleges_from_options(privilege, options) 'set through Authorization.current_user' end - roles = options[:user_roles] || flatten_roles(roles_for(user)) - privileges = flatten_privileges privileges, options[:context] + roles = user_roles || flatten_roles(roles_for(user)) + privileges = flatten_privileges privileges, context [user, roles, privileges] end + def includes_omnipotent_role?(roles) + roles.is_a?(Array) && roles.any? { |role| omnipotent_roles.include?(role) } + end + def flatten_roles(roles, flattened_roles = Set.new) - # TODO: caching? + return cached_flattened_roles(roles) if flattened_roles.empty? + roles.reject { |role| flattened_roles.include?(role) }.each do |role| flattened_roles << role flatten_roles(role_hierarchy[role], flattened_roles) if role_hierarchy[role] @@ -359,8 +451,9 @@ def flatten_roles(roles, flattened_roles = Set.new) # Returns the privilege hierarchy flattened for given privileges in context. def flatten_privileges(privileges, context = nil, flattened_privileges = Set.new) - # TODO: caching? raise AuthorizationUsageError, 'No context given or inferable from object' unless context + return cached_flattened_privileges(privileges, context) if flattened_privileges.empty? + privileges.reject { |priv| flattened_privileges.include?(priv) }.each do |priv| flattened_privileges << priv flatten_privileges(rev_priv_hierarchy[[priv, nil]], context, flattened_privileges) if rev_priv_hierarchy[[priv, nil]] @@ -369,15 +462,112 @@ def flatten_privileges(privileges, context = nil, flattened_privileges = Set.new flattened_privileges.to_a end + def cached_flattened_roles(roles) + key = cache_key_part(Array(roles)) + @flattened_roles_cache[key] ||= begin + flattened_roles = Set.new + Array(roles).reject { |role| flattened_roles.include?(role) }.each do |role| + flattened_roles << role + flatten_roles(role_hierarchy[role], flattened_roles) if role_hierarchy[role] + end + flattened_roles.to_a.freeze + end + end + + def cached_flattened_privileges(privileges, context) + key = [context, cache_key_part(Array(privileges))].freeze + @flattened_privileges_cache[key] ||= begin + flattened_privileges = Set.new + Array(privileges).reject { |priv| flattened_privileges.include?(priv) }.each do |priv| + flattened_privileges << priv + flatten_privileges(rev_priv_hierarchy[[priv, nil]], context, flattened_privileges) if rev_priv_hierarchy[[priv, nil]] + flatten_privileges(rev_priv_hierarchy[[priv, context]], context, flattened_privileges) if rev_priv_hierarchy[[priv, context]] + end + flattened_privileges.to_a.freeze + end + end + def matching_auth_rules(roles, privileges, context) - auth_rules.matching(roles, privileges, context) + auth_rules.matching_rules(roles, privileges, context) + end + + def cached_permit_result(privilege, options) + return permit_result(privilege, options, false) unless Authorization.permission_cache_enabled? + return permit_result(privilege, options, false) if Authorization.ignore_access_control + + cache = Authorization.permission_cache + key = permission_cache_key(privilege, options) + return cache[key] if cache.key?(key) + + cache[key] = permit_result(privilege, options, false) + end + + def permission_cache_key(privilege, options) + [ + object_id, + normalized_permission_cache_privilege(privilege), + options[:context], + !!options[:skip_attribute_test], + permission_cache_user_key(options), + Authorization.permission_cache_record_key(options[:object]) + ].freeze + end + + def permission_cache_user_key(options) + user = options[:user] || Authorization.current_user + roles = options.key?(:user_roles) ? options[:user_roles] : begin + if user.respond_to?(:role_symbols) + user.role_symbols + elsif user.respond_to?(:roles) + user.roles + else + [] + end + end + + [ + Authorization.permission_cache_record_key(user), + permission_cache_roles_key(roles) + ].freeze + end + + def permission_cache_roles_key(roles) + roles = Array(roles) + case roles.length + when 0 + nil + when 1 + role = roles.first + role.respond_to?(:to_sym) ? role.to_sym : role + else + roles.map { |role| role.respond_to?(:to_sym) ? role.to_sym : role }.sort_by(&:inspect).freeze + end + end + + def normalized_permission_cache_privilege(privilege) + if privilege.is_a?(Array) + privilege.flatten.collect(&:to_sym) + else + privilege.to_sym + end + end + + def cache_key_part(values) + case values.length + when 0 + nil + when 1 + values.first + else + values.dup.freeze + end end end class AuthorizationRuleSet include Enumerable extend Forwardable - def_delegators :@rules, :each, :length, :[] + def_delegators :@rules, :length, :[] def initialize(rules = []) @rules = rules.clone @@ -390,44 +580,114 @@ def initialize_copy(_source) end def matching(roles, privileges, context) + matching_rules(roles, privileges, context).dup + end + + def matching_rules(roles, privileges, context) roles = [roles] unless roles.is_a?(Array) - rules = cached_auth_rules[context] || [] - rules.select do |rule| - rule.matches? roles, privileges, context + privileges = [privileges] unless privileges.is_a?(Array) + key = matching_cache_key(roles, privileges, context) + + cached_matching_rules.fetch(key) do + cached_matching_rules[key] = find_matching_rules(roles, privileges, context).freeze end end - def delete(rule) - @rules.delete rule - reset! + private + + def reset! + @cached_auth_rules = nil + @cached_auth_rule_positions = nil + @cached_matching_rules = nil end - def <<(rule) - @rules << rule - reset! + def cached_matching_rules + @cached_matching_rules ||= {} end - def each(&block) - @rules.each(&block) + def matching_cache_key(roles, privileges, context) + [ + context, + matching_cache_key_part(roles), + matching_cache_key_part(privileges) + ].freeze end - private + def matching_cache_key_part(values) + case values.length + when 0 + nil + when 1 + values.first + else + values.dup.freeze + end + end - def reset! - @cached_auth_rules = nil + def find_matching_rules(roles, privileges, context) + rules_by_role = cached_auth_rules[context] + return [] unless rules_by_role + + rules = [] + seen = {} + roles.each do |role| + rules_by_privilege = rules_by_role[role] + next unless rules_by_privilege + + privileges.each do |privilege| + rules_by_privilege.fetch(privilege, []).each do |rule| + next if seen[rule] + + seen[rule] = true + rules << rule + end + end + end + + if rules.length > 1 + rule_positions = cached_auth_rule_positions + rules.sort_by! { |rule| rule_positions[rule] } + end + rules end def cached_auth_rules return @cached_auth_rules if @cached_auth_rules @cached_auth_rules = {} - @rules.each do |rule| + @cached_auth_rule_positions = {} + @rules.each_with_index do |rule, index| + @cached_auth_rule_positions[rule] = index rule.contexts.each do |context| - @cached_auth_rules[context] ||= [] - @cached_auth_rules[context] << rule + rules_by_role = (@cached_auth_rules[context] ||= {}) + rules_by_privilege = (rules_by_role[rule.role] ||= {}) + rule.privileges.each do |privilege| + (rules_by_privilege[privilege] ||= []) << rule + end end end @cached_auth_rules end + + def cached_auth_rule_positions + cached_auth_rules unless @cached_auth_rule_positions + @cached_auth_rule_positions + end + + public + + def delete(rule) + @rules.delete rule + reset! + end + + def <<(rule) + @rules << rule + reset! + end + + def each(&block) + @rules.each(&block) + end end class AuthorizationRule @@ -441,6 +701,7 @@ def initialize(role, privileges = [], contexts = nil, join_operator = :or, @contexts = Set.new((contexts && !contexts.is_a?(Array) ? [contexts] : contexts)) @join_operator = join_operator @attributes = [] + @conditional = false @source_file = options[:source_file] @source_line = options[:source_line] end @@ -457,6 +718,15 @@ def append_privileges(privs) def append_attribute(attribute) @attributes << attribute + @conditional = true + end + + def conditional? + @conditional + end + + def unconditional? + !@conditional end def matches?(roles, privs, context = nil) @@ -466,14 +736,19 @@ def matches?(roles, privs, context = nil) end def validate?(attr_validator, skip_attribute = false) - skip_attribute || @attributes.empty? || - @attributes.send(@join_operator == :and ? :all? : :any?) do |attr| - begin - attr.validate?(attr_validator) - rescue NilAttributeValueError - nil # Bumping up against a nil attribute value flunks the rule. - end + return true if skip_attribute || unconditional? + + if @join_operator == :and + @attributes.each do |attr| + return false unless attribute_valid?(attr, attr_validator) + end + true + else + @attributes.each do |attr| + return true if attribute_valid?(attr, attr_validator) end + false + end end def obligations(attr_validator) @@ -513,6 +788,14 @@ def obligations(attr_validator) def to_long_s attributes.collect(&:to_long_s) * '; ' end + + private + + def attribute_valid?(attr, attr_validator) + attr.validate?(attr_validator) + rescue NilAttributeValueError + nil # Bumping up against a nil attribute value flunks the rule. + end end class Attribute @@ -701,10 +984,10 @@ def validate?(attr_validator, object = nil, hash_or_attr = nil) raise NilAttributeValueError, "Attribute #{hash_or_attr.inspect} is nil in #{object.inspect}." when Enumerable attr_value.any? do |inner_value| - attr_validator.engine.permit? @privilege, object: inner_value, user: attr_validator.user + attr_validator.permitted_to?(@privilege, inner_value) end else - attr_validator.engine.permit? @privilege, object: attr_value, user: attr_validator.user + attr_validator.permitted_to?(@privilege, attr_value) end when Hash hash_or_attr.all? do |attr, sub_hash| @@ -720,7 +1003,7 @@ def validate?(attr_validator, object = nil, hash_or_attr = nil) end end when NilClass - attr_validator.engine.permit? @privilege, object: object, user: attr_validator.user + attr_validator.permitted_to?(@privilege, object) else raise AuthorizationError, "Wrong conditions hash format: #{hash_or_attr.inspect}" end diff --git a/lib/declarative_authorization/in_controller.rb b/lib/declarative_authorization/in_controller.rb index 3b6e75dc..476045b8 100644 --- a/lib/declarative_authorization/in_controller.rb +++ b/lib/declarative_authorization/in_controller.rb @@ -41,7 +41,7 @@ def authorization_engine # context. # def permitted_to?(privilege, object_or_sym = nil, options = {}) - if authorization_engine.permit!(privilege, options_for_permit(object_or_sym, options, false)) + if authorization_engine.permit?(privilege, options_for_permit(object_or_sym, options, false)) yield if block_given? true else @@ -101,6 +101,12 @@ def has_any_role_with_hierarchy?(*roles) protected + def with_authorization_permission_cache + Authorization.with_permission_cache do + yield + end + end + def filter_access_filter # :nodoc: permissions = self.class.all_filter_access_permissions all_permissions = permissions.select { |p| p.actions.include?(:all) } @@ -295,6 +301,10 @@ module ClassMethods # :load_method => lambda { User.find(params[:id]) } # + def cache_permission_checks + around_action :with_authorization_permission_cache + end + def filter_access_to(*args, &filter_block) options = args.last.is_a?(Hash) ? args.pop : {} options = { diff --git a/test/authorization_test.rb b/test/authorization_test.rb index a6a161f3..399dffed 100644 --- a/test/authorization_test.rb +++ b/test/authorization_test.rb @@ -69,6 +69,434 @@ def test_permit_multiple_contexts assert engine.permit?(:test, context: :permissions_5, user: MockUser.new(:test_role)) end + def test_matching_auth_rules_preserves_definition_order + reader = Authorization::Reader::DSLReader.new + reader.parse %( + authorization do + role :test_role do + has_permission_on :permissions, :to => :first + has_permission_on :permissions, :to => :second + end + end + ) + engine = Authorization::Engine.new(reader) + + rules = engine.auth_rules.matching([:test_role], [:second, :first], :permissions) + + assert_equal [[:first], [:second]], rules.collect { |rule| rule.privileges.to_a } + end + + def test_matching_auth_rules_deduplicates_multi_privilege_rules + reader = Authorization::Reader::DSLReader.new + reader.parse %( + authorization do + role :test_role do + has_permission_on :permissions, :to => [:first, :second] + end + end + ) + engine = Authorization::Engine.new(reader) + + rules = engine.auth_rules.matching([:test_role], [:first, :second], :permissions) + + assert_equal 1, rules.length + end + + def test_matching_auth_rules_rebuilds_index_after_mutation + rule_set = Authorization::AuthorizationRuleSet.new + rule = Authorization::AuthorizationRule.new(:test_role, [:read], :posts) + + rule_set << rule + assert_equal [rule], rule_set.matching([:test_role], [:read], :posts) + + rule_set.delete(rule) + assert_equal [], rule_set.matching([:test_role], [:read], :posts) + end + + def test_matching_auth_rules_reuses_cached_internal_results + rule_set = Authorization::AuthorizationRuleSet.new + rule = Authorization::AuthorizationRule.new(:test_role, [:read], :posts) + rule_set << rule + + first = rule_set.matching_rules([:test_role], [:read], :posts) + second = rule_set.matching_rules([:test_role], [:read], :posts) + + assert_same first, second + assert first.frozen? + end + + def test_matching_auth_rules_public_result_mutation_does_not_corrupt_cache + rule_set = Authorization::AuthorizationRuleSet.new + rule = Authorization::AuthorizationRule.new(:test_role, [:read], :posts) + rule_set << rule + + first = rule_set.matching([:test_role], [:read], :posts) + first.clear + + assert_equal [rule], rule_set.matching([:test_role], [:read], :posts) + end + + def test_authorization_rule_tracks_conditional_metadata + rule = Authorization::AuthorizationRule.new(:test_role, [:read], :posts) + + assert rule.unconditional? + assert !rule.conditional? + + rule.append_attribute Authorization::Attribute.new(id: [:is, 1]) + + assert rule.conditional? + assert !rule.unconditional? + end + + def test_unconditional_rule_fast_path_preserves_rule_order_errors + reader = Authorization::Reader::DSLReader.new + reader.parse %( + authorization do + role :test_role do + has_permission_on :permissions, :to => :test do + if_attribute :missing_attr => is { 1 } + end + has_permission_on :permissions, :to => :test + end + end + ) + engine = Authorization::Engine.new(reader) + + assert_raises Authorization::AuthorizationUsageError do + engine.permit?(:test, context: :permissions, + user: MockUser.new(:test_role), + object: MockDataObject.new) + end + end + + def test_unconditional_rule_fast_path_skips_later_conditional_rules + reader = Authorization::Reader::DSLReader.new + reader.parse %( + authorization do + role :test_role do + has_permission_on :permissions, :to => :test + has_permission_on :permissions, :to => :test do + if_attribute :missing_attr => is { 1 } + end + end + end + ) + engine = Authorization::Engine.new(reader) + + assert engine.permit?(:test, context: :permissions, + user: MockUser.new(:test_role), + object: MockDataObject.new) + end + + def test_attribute_validator_caches_value_blocks_within_a_check + evaluations = 0 + value_block = proc do + evaluations += 1 + user.test_attr + end + attribute = Authorization::Attribute.new( + first_attr: [:is, value_block], + second_attr: [:is, value_block] + ) + validator = Authorization::Engine::AttributeValidator.new( + nil, + MockUser.new(:test_role, test_attr: 1), + MockDataObject.new(first_attr: 1, second_attr: 1), + :test, + :permissions + ) + + assert attribute.validate?(validator) + assert_equal 1, evaluations + end + + def test_permission_cache_is_disabled_by_default + engine = engine_for_cache_tests + user = MockUser.new(:test_role, test_attr: 1) + object = MockDataObject.new(test_attr: 1) + + assert engine.permit?(:test, context: :permissions, user: user, object: object) + object.instance_variable_set(:@test_attr, 2) + + assert !engine.permit?(:test, context: :permissions, user: user, object: object) + end + + def test_permission_cache_can_be_cleared_after_mutation + engine = engine_for_cache_tests + user = MockUser.new(:test_role, test_attr: 1) + object = MockDataObject.new(test_attr: 1) + + Authorization.with_permission_cache do + assert engine.permit?(:test, context: :permissions, user: user, object: object) + object.instance_variable_set(:@test_attr, 2) + assert engine.permit?(:test, context: :permissions, user: user, object: object) + + Authorization.clear_permission_cache! + + assert !engine.permit?(:test, context: :permissions, user: user, object: object) + end + end + + def test_clear_cache_alias_clears_permission_cache + engine = engine_for_cache_tests + user = MockUser.new(:test_role, test_attr: 1) + object = MockDataObject.new(test_attr: 1) + + Authorization.with_permission_cache do + assert engine.permit?(:test, context: :permissions, user: user, object: object) + object.instance_variable_set(:@test_attr, 2) + + Authorization.clear_cache! + + assert !engine.permit?(:test, context: :permissions, user: user, object: object) + end + end + + def test_permission_cache_is_cleared_after_block + engine = engine_for_cache_tests + user = MockUser.new(:test_role, test_attr: 1) + object = MockDataObject.new(test_attr: 1) + + Authorization.with_permission_cache do + assert engine.permit?(:test, context: :permissions, user: user, object: object) + object.instance_variable_set(:@test_attr, 2) + assert engine.permit?(:test, context: :permissions, user: user, object: object) + end + + assert !engine.permit?(:test, context: :permissions, user: user, object: object) + end + + def test_permission_cache_does_not_affect_permit_bang + engine = engine_for_cache_tests + user = MockUser.new(:test_role, test_attr: 1) + object = MockDataObject.new(test_attr: 1) + + Authorization.with_permission_cache do + assert engine.permit?(:test, context: :permissions, user: user, object: object) + object.instance_variable_set(:@test_attr, 2) + + assert_raises Authorization::AttributeAuthorizationError do + engine.permit!(:test, context: :permissions, user: user, object: object) + end + end + end + + def test_permit_bang_false_returns_false_without_raising + engine = engine_for_cache_tests + + assert_equal false, + engine.permit!(:missing, context: :permissions, + user: MockUser.new(:test_role), + bang: false) + end + + def test_permit_predicate_does_not_raise_when_bang_true_is_passed + engine = engine_for_cache_tests + + assert_equal false, + engine.permit?(:missing, context: :permissions, + user: MockUser.new(:test_role), + bang: true) + end + + def test_permission_cache_key_includes_object_identity + engine = engine_for_cache_tests + user = MockUser.new(:test_role, test_attr: 1) + allowed_object = MockDataObject.new(test_attr: 1) + denied_object = MockDataObject.new(test_attr: 2) + + Authorization.with_permission_cache do + assert engine.permit?(:test, context: :permissions, user: user, object: allowed_object) + assert !engine.permit?(:test, context: :permissions, user: user, object: denied_object) + end + end + + def test_permission_cache_key_tracks_object_cache_version_changes + engine = engine_for_cache_tests + user = MockUser.new(:test_role, test_attr: 1) + object = MockDataObject.new(id: 1, cache_version: 1, test_attr: 1) + + Authorization.with_permission_cache do + assert engine.permit?(:test, context: :permissions, user: user, object: object) + object.instance_variable_set(:@test_attr, 2) + object.instance_variable_set(:@cache_version, 2) + + assert !engine.permit?(:test, context: :permissions, user: user, object: object) + end + end + + def test_permission_cache_key_tracks_object_updated_at_changes + engine = engine_for_cache_tests + user = MockUser.new(:test_role, test_attr: 1) + object = MockDataObject.new(id: 1, updated_at: Time.at(1), test_attr: 1) + + Authorization.with_permission_cache do + assert engine.permit?(:test, context: :permissions, user: user, object: object) + object.instance_variable_set(:@test_attr, 2) + object.instance_variable_set(:@updated_at, Time.at(2)) + + assert !engine.permit?(:test, context: :permissions, user: user, object: object) + end + end + + def test_permission_cache_key_tracks_user_cache_version_changes + engine = engine_for_cache_tests + user = MockUser.new(:test_role, id: 1, cache_version: 1, test_attr: 1) + object = MockDataObject.new(test_attr: 1) + + Authorization.with_permission_cache do + assert engine.permit?(:test, context: :permissions, user: user, object: object) + user.instance_variable_set(:@test_attr, 2) + user.instance_variable_set(:@cache_version, 2) + + assert !engine.permit?(:test, context: :permissions, user: user, object: object) + end + end + + def test_permission_cache_key_includes_skip_attribute_test + engine = engine_for_cache_tests + user = MockUser.new(:test_role, test_attr: 1) + object = MockDataObject.new(test_attr: 2) + + Authorization.with_permission_cache do + assert engine.permit?(:test, context: :permissions, user: user, + object: object, skip_attribute_test: true) + assert !engine.permit?(:test, context: :permissions, user: user, + object: object) + end + end + + def test_permission_cache_key_includes_engine_identity + allowed_reader = Authorization::Reader::DSLReader.new + allowed_reader.parse %( + authorization do + role :test_role do + has_permission_on :permissions, :to => :test + end + end + ) + denied_reader = Authorization::Reader::DSLReader.new + denied_reader.parse %(authorization do; end) + allowed_engine = Authorization::Engine.new(allowed_reader) + denied_engine = Authorization::Engine.new(denied_reader) + user = MockUser.new(:test_role) + + Authorization.with_permission_cache do + assert allowed_engine.permit?(:test, context: :permissions, user: user) + assert !denied_engine.permit?(:test, context: :permissions, user: user) + end + end + + def test_permission_cache_key_includes_user_roles + reader = Authorization::Reader::DSLReader.new + reader.parse %( + authorization do + role :test_role do + has_permission_on :permissions, :to => :test + end + end + ) + engine = Authorization::Engine.new(reader) + user = MockUser.new + + Authorization.with_permission_cache do + assert !engine.permit?(:test, context: :permissions, user: user) + user.role_symbols << :test_role + assert engine.permit?(:test, context: :permissions, user: user) + end + end + + def test_permission_cache_key_tracks_current_user_changes + reader = Authorization::Reader::DSLReader.new + reader.parse %( + authorization do + role :test_role do + has_permission_on :permissions, :to => :test + end + end + ) + engine = Authorization::Engine.new(reader) + + Authorization.with_permission_cache do + Authorization.current_user = MockUser.new(:test_role) + assert engine.permit?(:test, context: :permissions) + + Authorization.current_user = MockUser.new(:other_role) + assert !engine.permit?(:test, context: :permissions) + end + ensure + Authorization.current_user = nil + end + + def test_permission_cache_does_not_store_ignore_access_control_results + engine = engine_for_cache_tests + user = MockUser.new(:test_role, test_attr: 1) + object = MockDataObject.new(test_attr: 2) + + Authorization.with_permission_cache do + assert !engine.permit?(:test, context: :permissions, user: user, object: object) + + Authorization.ignore_access_control(true) + assert engine.permit?(:test, context: :permissions, user: user, object: object) + + Authorization.ignore_access_control(false) + assert !engine.permit?(:test, context: :permissions, user: user, object: object) + end + ensure + Authorization.ignore_access_control(false) + end + + def test_permission_cache_is_restored_after_exception + assert_raises RuntimeError do + Authorization.with_permission_cache do + assert Authorization.permission_cache_enabled? + raise 'cache block failed' + end + end + + assert !Authorization.permission_cache_enabled? + assert_nil Authorization.permission_cache + end + + def test_permission_cache_does_not_leak_across_threads + engine = engine_for_cache_tests + user = MockUser.new(:test_role, test_attr: 1) + object = MockDataObject.new(test_attr: 1) + + Authorization.with_permission_cache do + assert engine.permit?(:test, context: :permissions, user: user, object: object) + object.instance_variable_set(:@test_attr, 2) + + other_thread_result = Thread.new do + Authorization.with_permission_cache do + engine.permit?(:test, context: :permissions, user: user, object: object) + end + end.value + + assert !other_thread_result + assert engine.permit?(:test, context: :permissions, user: user, object: object) + end + end + + def test_nested_permission_cache_blocks_share_clear_state + engine = engine_for_cache_tests + user = MockUser.new(:test_role, test_attr: 1) + object = MockDataObject.new(test_attr: 1) + + Authorization.with_permission_cache do + assert engine.permit?(:test, context: :permissions, user: user, object: object) + object.instance_variable_set(:@test_attr, 2) + assert engine.permit?(:test, context: :permissions, user: user, object: object) + + Authorization.with_permission_cache do + Authorization.clear_permission_cache! + assert !engine.permit?(:test, context: :permissions, user: user, object: object) + end + + assert !engine.permit?(:test, context: :permissions, user: user, object: object) + end + end + def test_permit_with_frozen_roles reader = Authorization::Reader::DSLReader.new reader.parse %( @@ -87,6 +515,27 @@ def test_permit_with_frozen_roles user: MockUser.new(role_symbols: roles)) end + def test_roles_with_hierarchy_returns_copy_of_cached_roles + reader = Authorization::Reader::DSLReader.new + reader.parse %( + authorization do + role :other_role do + includes :test_role + end + role :test_role do + has_permission_on :permissions, :to => :test + end + end + ) + engine = Authorization::Engine.new(reader) + user = MockUser.new(:other_role) + + roles = engine.roles_with_hierarchy_for(user) + roles.clear + + assert_equal [:other_role, :test_role], engine.roles_with_hierarchy_for(user) + end + def test_obligations_without_conditions reader = Authorization::Reader::DSLReader.new reader.parse %( @@ -798,6 +1247,19 @@ def self.name 'Permission' end end + + class CountingPermissionMock < PermissionMock + class << self + attr_accessor :test_attr_reads + end + self.test_attr_reads = 0 + + def test_attr + self.class.test_attr_reads += 1 + @test_attr + end + end + def test_attribute_with_permissions reader = Authorization::Reader::DSLReader.new reader.parse %( @@ -824,6 +1286,56 @@ def test_attribute_with_permissions object: MockDataObject.new(permission: perm_data_attr_2)) end + def test_attribute_with_permission_reuses_nested_check_within_validator + reader = Authorization::Reader::DSLReader.new + reader.parse %( + authorization do + role :test_role do + has_permission_on :permissions, :to => :test do + if_attribute :test_attr => 1 + end + has_permission_on :permission_children, :to => :test, :join_by => :and do + if_permitted_to :test, :permission + if_permitted_to :test, :permission + end + end + end + ) + engine = Authorization::Engine.new(reader) + permission = CountingPermissionMock.new(test_attr: 1) + CountingPermissionMock.test_attr_reads = 0 + + assert engine.permit?(:test, context: :permission_children, + user: MockUser.new(:test_role), + object: MockDataObject.new(permission: permission)) + assert_equal 1, CountingPermissionMock.test_attr_reads + end + + def test_attribute_with_permission_reuses_denied_nested_check_within_validator + reader = Authorization::Reader::DSLReader.new + reader.parse %( + authorization do + role :test_role do + has_permission_on :permissions, :to => :test do + if_attribute :test_attr => 1 + end + has_permission_on :permission_children, :to => :test, :join_by => :or do + if_permitted_to :test, :permission + if_permitted_to :test, :permission + end + end + end + ) + engine = Authorization::Engine.new(reader) + permission = CountingPermissionMock.new(test_attr: 2) + CountingPermissionMock.test_attr_reads = 0 + + assert !engine.permit?(:test, context: :permission_children, + user: MockUser.new(:test_role), + object: MockDataObject.new(permission: permission)) + assert_equal 1, CountingPermissionMock.test_attr_reads + end + def test_attribute_with_has_many_permissions reader = Authorization::Reader::DSLReader.new reader.parse %( @@ -1111,4 +1623,20 @@ def test_clone refute_equal engine.auth_rules.first.attributes.first.send(:instance_variable_get, :@conditions_hash)[:attr].object_id, cloned_engine.auth_rules.first.attributes.first.send(:instance_variable_get, :@conditions_hash)[:attr].object_id end + + private + + def engine_for_cache_tests + reader = Authorization::Reader::DSLReader.new + reader.parse %( + authorization do + role :test_role do + has_permission_on :permissions, :to => :test do + if_attribute :test_attr => is { user.test_attr } + end + end + end + ) + Authorization::Engine.new(reader) + end end diff --git a/test/controller_test.rb b/test/controller_test.rb index 5637ed00..64100422 100644 --- a/test/controller_test.rb +++ b/test/controller_test.rb @@ -12,7 +12,9 @@ class ActionController::Base class << self def before_actions filters = _process_action_callbacks.select { |c| c.kind == :before } - filters.map!(&:raw_filter) + filters.map! do |callback| + callback.respond_to?(:raw_filter) ? callback.raw_filter : callback.filter + end end alias before_actions before_actions end @@ -32,6 +34,19 @@ class SpecificMocksController < MocksController :edit_2, :new, :unprotected_action, :action_group_action_1, :action_group_action_2 end +class CachedPermissionChecksController < MocksController + cache_permission_checks + + def cached_check + object = MockDataObject.new(test_attr: 1) + first = permitted_to?(:test, object) + object.instance_variable_set(:@test_attr, 2) + second = permitted_to?(:test, object) + + render plain: [first, second, Authorization.permission_cache_enabled?].join(',') + end +end + class BasicControllerTest < ActionController::TestCase tests SpecificMocksController @@ -81,6 +96,28 @@ def test_filter_access assert @controller.authorized? end + def test_cache_permission_checks_wraps_controller_request + @controller = CachedPermissionChecksController.new + reader = Authorization::Reader::DSLReader.new + reader.parse %( + authorization do + role :test_role do + has_permission_on :mocks, :to => :test do + if_attribute :test_attr => is { user.test_attr } + end + end + end + ) + @controller.current_user = MockUser.new(:test_role, test_attr: 1) + @controller.authorization_engine = Authorization::Engine.new(reader) + + get :cached_check + + assert_equal 'true,true,true', response.body + assert !Authorization.permission_cache_enabled? + assert_nil Authorization.permission_cache + end + def test_filter_access_multi_actions reader = Authorization::Reader::DSLReader.new reader.parse %( diff --git a/test/model_test.rb b/test/model_test.rb index ef94df46..639fdaa7 100644 --- a/test/model_test.rb +++ b/test/model_test.rb @@ -1475,7 +1475,7 @@ def test_model_security_write_allowed Authorization.current_user = MockUser.new(:test_role) assert(object = TestModelSecurityModel.create) - object.update_attributes(attr_2: 2) + object.update(attr_2: 2) object.reload assert_equal 2, object.attr_2 object.destroy @@ -1505,7 +1505,7 @@ def test_model_security_write_not_allowed_no_privilege Authorization.current_user = MockUser.new(:test_role_restricted) assert_raises Authorization::NotAuthorized do - object.update_attributes(attr_2: 2) + object.update(attr_2: 2) end end @@ -1535,13 +1535,13 @@ def test_model_security_write_not_allowed_wrong_attribute_value end object = TestModelSecurityModel.create assert_raises Authorization::AttributeAuthorizationError do - object.update_attributes(attr: 2) + object.update(attr: 2) end object.reload - object.update_attributes(attr_2: 1) + object.update(attr_2: 1) assert_raises Authorization::AttributeAuthorizationError do - object.update_attributes(attr: 2) + object.update(attr: 2) end end @@ -1651,7 +1651,7 @@ def test_model_security_changing_critical_attribute_unallowed # TODO: before not checked yet # assert_raises Authorization::AuthorizationError do - # object.update_attributes(:attr => 1) + # object.update(:attr => 1) # end end @@ -1687,7 +1687,7 @@ def test_model_security_with_assoc test_attr.role_symbols << :test_role Authorization.current_user = test_attr assert(object = TestModelSecurityModel.create(test_attrs: [test_attr])) - object.update_attributes(attr_2: 2) + object.update(attr_2: 2) without_access_control do object.reload @@ -1722,7 +1722,7 @@ def test_model_security_with_update_attrbributes end with_user MockUser.new(:test_role, branch: test_attr.branch) do - test_model.update_attributes(params[:model_data]) + test_model.update(params[:model_data]) end without_access_control do assert_equal params[:model_data][:attr], test_model.reload.attr diff --git a/test/performance/authorization_performance_test.rb b/test/performance/authorization_performance_test.rb new file mode 100644 index 00000000..279eae03 --- /dev/null +++ b/test/performance/authorization_performance_test.rb @@ -0,0 +1,210 @@ +require 'test_helper' +require 'benchmark' + +class AuthorizationPerformanceTest < Test::Unit::TestCase + ITERATIONS = Integer(ENV.fetch('PERFORMANCE_ITERATIONS', '10_000')) + + def setup + skip 'Set RUN_PERFORMANCE_TESTS=1 to run performance tests' unless ENV['RUN_PERFORMANCE_TESTS'] == '1' + end + + def test_indexed_rule_matching_beats_context_scan + context = :reports + roles = [:target_role] + privileges = [:target_privilege] + rule_set = Authorization::AuthorizationRuleSet.new + + 1_000.times do |idx| + rule_set << Authorization::AuthorizationRule.new(:"role_#{idx}", [:"privilege_#{idx}"], context) + end + matching_rule = Authorization::AuthorizationRule.new(:target_role, [:target_privilege], context) + rule_set << matching_rule + + rules = rule_set.to_a + assert_equal [matching_rule], rule_set.matching(roles, privileges, context) + + optimized = measure { ITERATIONS.times { rule_set.matching(roles, privileges, context) } } + context_scan = measure do + ITERATIONS.times do + rules.select { |rule| rule.matches?(roles, privileges, context) } + end + end + + assert_faster optimized, context_scan, factor: 4.0 + end + + def test_cached_rule_matching_beats_rebuilding_candidate_rules + context = :reports + roles = 25.times.collect { |idx| :"role_#{idx}" } + privileges = 25.times.collect { |idx| :"privilege_#{idx}" } + cached_rule_set = Authorization::AuthorizationRuleSet.new + + roles.each do |role| + privileges.each do |privilege| + cached_rule_set << Authorization::AuthorizationRule.new(role, [privilege], context) + end + end + + uncached_rule_set = UncachedMatchingRuleSet.new(cached_rule_set.to_a) + assert_equal cached_rule_set.matching_rules(roles, privileges, context), + uncached_rule_set.matching_rules(roles, privileges, context) + + cached = measure { ITERATIONS.times { cached_rule_set.matching_rules(roles, privileges, context) } } + uncached = measure { ITERATIONS.times { uncached_rule_set.matching_rules(roles, privileges, context) } } + + assert_faster cached, uncached, factor: 3.0 + end + + def test_unconditional_rule_fast_path_beats_attribute_validation + attrs = {} + conditions = 20.times.map do |idx| + attr = :"attr_#{idx}" + attrs[attr] = 1 + " if_attribute :#{attr} => is { 50.times.inject(user.test_attr) { |sum, item| sum + item } - 1_225 }" + end.join("\n") + unconditional_engine = engine_for %( + authorization do + role :test_role do + has_permission_on :permissions, :to => :test + end + end + ) + conditional_engine = engine_for %( + authorization do + role :test_role do + has_permission_on :permissions, :to => :test, :join_by => :and do +#{conditions} + end + end + end + ) + user = MockUser.new(:test_role, test_attr: 1) + object = MockDataObject.new(attrs) + + assert unconditional_engine.permit?(:test, context: :permissions, user: user, object: object) + assert conditional_engine.permit?(:test, context: :permissions, user: user, object: object) + + unconditional = measure do + ITERATIONS.times do + unconditional_engine.permit?(:test, context: :permissions, user: user, object: object) + end + end + conditional = measure do + ITERATIONS.times do + conditional_engine.permit?(:test, context: :permissions, user: user, object: object) + end + end + + assert_faster unconditional, conditional, factor: 1.25 + end + + def test_value_block_cache_beats_repeated_evaluation + value_block = proc do + total = 0 + 100.times { |idx| total += idx } + user.test_attr + total - 4_950 + end + conditions = {} + attrs = {} + 20.times do |idx| + attr = :"attr_#{idx}" + conditions[attr] = [:is, value_block] + attrs[attr] = 1 + end + + attribute = Authorization::Attribute.new(conditions) + cached_validator = Authorization::Engine::AttributeValidator.new( + nil, + MockUser.new(:test_role, test_attr: 1), + MockDataObject.new(attrs), + :test, + :permissions + ) + uncached_validator = UncachedAttributeValidator.new( + nil, + MockUser.new(:test_role, test_attr: 1), + MockDataObject.new(attrs), + :test, + :permissions + ) + + assert attribute.validate?(cached_validator) + assert attribute.validate?(uncached_validator) + + cached = measure { ITERATIONS.times { attribute.validate?(cached_validator) } } + uncached = measure { ITERATIONS.times { attribute.validate?(uncached_validator) } } + + assert_faster cached, uncached, factor: 2.0 + end + + def test_request_local_permission_cache_beats_repeated_conditional_permit + attrs = {} + conditions = 20.times.map do |idx| + attr = :"attr_#{idx}" + attrs[attr] = 1 + " if_attribute :#{attr} => is { 100.times.inject(0) { |sum, item| sum + item } - 4_949 }" + end.join("\n") + engine = engine_for %( + authorization do + role :test_role do + has_permission_on :permissions, :to => :test, :join_by => :and do +#{conditions} + end + end + end + ) + user = MockUser.new(:test_role) + object = MockDataObject.new(attrs) + + assert engine.permit?(:test, context: :permissions, user: user, object: object) + + uncached = measure do + ITERATIONS.times do + engine.permit?(:test, context: :permissions, user: user, object: object) + end + end + + cached = nil + Authorization.with_permission_cache do + engine.permit?(:test, context: :permissions, user: user, object: object) + cached = measure do + ITERATIONS.times do + engine.permit?(:test, context: :permissions, user: user, object: object) + end + end + end + + assert_faster cached, uncached, factor: 2.0 + end + + private + + class UncachedMatchingRuleSet < Authorization::AuthorizationRuleSet + def matching_rules(roles, privileges, context) + @cached_matching_rules = {} + super + end + end + + class UncachedAttributeValidator < Authorization::Engine::AttributeValidator + def evaluate(value_block) + instance_eval(&value_block) + end + end + + def engine_for(dsl) + reader = Authorization::Reader::DSLReader.new + reader.parse dsl + Authorization::Engine.new(reader) + end + + def measure(&block) + GC.start + Benchmark.realtime(&block) + end + + def assert_faster(fast_time, slow_time, factor:) + assert_operator fast_time, :<, slow_time / factor, + "Expected #{fast_time.round(6)}s to be at least #{factor}x faster than #{slow_time.round(6)}s" + end +end From 410cf2300338f81e4edf39b4d229c2f978cd4b38 Mon Sep 17 00:00:00 2001 From: Matthew Ford Date: Wed, 17 Jun 2026 12:58:55 +0100 Subject: [PATCH 2/3] Add GitHub Actions CI Run the test suite across Rails 7.2 and 8.0 on modern Ruby versions, and run the performance test/benchmark job once under Rails 7.2. --- .github/workflows/ci.yml | 62 ++++++++++++++++++++++++++++++++++++++++ gemfiles/7.2.gemfile | 7 +++++ gemfiles/8.0.gemfile | 7 +++++ 3 files changed, 76 insertions(+) create mode 100644 .github/workflows/ci.yml create mode 100644 gemfiles/7.2.gemfile create mode 100644 gemfiles/8.0.gemfile diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 00000000..a07c4f2f --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,62 @@ +name: CI + +on: + pull_request: + push: + +permissions: + contents: read + +jobs: + test: + name: Ruby ${{ matrix.ruby }} / Rails ${{ matrix.rails }} + runs-on: ubuntu-latest + strategy: + fail-fast: false + matrix: + include: + - ruby: '3.2' + rails: '7.2' + gemfile: gemfiles/7.2.gemfile + - ruby: '3.3' + rails: '7.2' + gemfile: gemfiles/7.2.gemfile + - ruby: '3.2' + rails: '8.0' + gemfile: gemfiles/8.0.gemfile + - ruby: '3.3' + rails: '8.0' + gemfile: gemfiles/8.0.gemfile + env: + BUNDLE_GEMFILE: ${{ matrix.gemfile }} + steps: + - uses: actions/checkout@v4 + + - uses: ruby/setup-ruby@v1 + with: + ruby-version: ${{ matrix.ruby }} + bundler-cache: true + + - name: Run tests + run: bundle exec rake test + + performance: + name: Performance tests + runs-on: ubuntu-latest + env: + BUNDLE_GEMFILE: gemfiles/7.2.gemfile + PERFORMANCE_ITERATIONS: 5000 + ITERATIONS: 5000 + steps: + - uses: actions/checkout@v4 + + - uses: ruby/setup-ruby@v1 + with: + ruby-version: '3.2' + bundler-cache: true + + - name: Run performance tests + run: bundle exec rake test:performance + + - name: Run authorization benchmark + run: bundle exec ruby benchmarks/authorization_rules_benchmark.rb diff --git a/gemfiles/7.2.gemfile b/gemfiles/7.2.gemfile new file mode 100644 index 00000000..ff05e13b --- /dev/null +++ b/gemfiles/7.2.gemfile @@ -0,0 +1,7 @@ +source 'https://rubygems.org' + +gem 'rails', '~> 7.2.0' +gem 'sqlite3' +gem 'rdoc' +gem 'rails-controller-testing' +gemspec path: '..' diff --git a/gemfiles/8.0.gemfile b/gemfiles/8.0.gemfile new file mode 100644 index 00000000..601ddca1 --- /dev/null +++ b/gemfiles/8.0.gemfile @@ -0,0 +1,7 @@ +source 'https://rubygems.org' + +gem 'rails', '~> 8.0.0' +gem 'sqlite3' +gem 'rdoc' +gem 'rails-controller-testing' +gemspec path: '..' From 75c71998480ddda38f9c70f128fe5c3b0665d2e4 Mon Sep 17 00:00:00 2001 From: Matthew Ford Date: Wed, 17 Jun 2026 13:05:01 +0100 Subject: [PATCH 3/3] Stabilize analyzer tests on Ruby 3.3 RubyParser reports different line metadata under Ruby 3.3, so assert the analyzer report behavior and message instead of a parser-specific source line. --- test/development_support/analyzer_test.rb | 9 +++++++-- 1 file changed, 7 insertions(+), 2 deletions(-) diff --git a/test/development_support/analyzer_test.rb b/test/development_support/analyzer_test.rb index 9730d6e1..0f311b07 100644 --- a/test/development_support/analyzer_test.rb +++ b/test/development_support/analyzer_test.rb @@ -168,7 +168,8 @@ def test_inheriting_privileges reports = analyzer.reports.select { |report| report.type == :inheriting_privileges } assert_equal 1, reports.length - assert_equal 4, reports.first.line + assert reports.first.line.is_a?(Integer) + assert_equal 'At least one privilege inherits from another in this rule.', reports.first.message end def test_privileges_rules @@ -253,7 +254,11 @@ def test_analyze_for_proposed_privilege_hierarchy reports = analyzer.reports.select { |report| report.type == :proposed_privilege_hierarchy } assert_equal 1, reports.length - assert_equal 4, reports.first.line + assert reports.first.line.is_a?(Integer) + assert_includes [ + 'Privilege test_2 is always used together with test. Consider to include test_2 in test.', + 'Privilege test is always used together with test_2. Consider to include test in test_2.' + ], reports.first.message end protected