Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
62 changes: 62 additions & 0 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
@@ -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
46 changes: 44 additions & 2 deletions README.rdoc
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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

Expand Down Expand Up @@ -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

11 changes: 11 additions & 0 deletions Rakefile
Original file line number Diff line number Diff line change
@@ -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'
Expand Down Expand Up @@ -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'
Expand Down
41 changes: 40 additions & 1 deletion Readme.md
Original file line number Diff line number Diff line change
@@ -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)

Expand All @@ -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
```
164 changes: 164 additions & 0 deletions benchmarks/authorization_rules_benchmark.rb
Original file line number Diff line number Diff line change
@@ -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
7 changes: 7 additions & 0 deletions gemfiles/7.2.gemfile
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
source 'https://rubygems.org'

gem 'rails', '~> 7.2.0'
gem 'sqlite3'
gem 'rdoc'
gem 'rails-controller-testing'
gemspec path: '..'
7 changes: 7 additions & 0 deletions gemfiles/8.0.gemfile
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
source 'https://rubygems.org'

gem 'rails', '~> 8.0.0'
gem 'sqlite3'
gem 'rdoc'
gem 'rails-controller-testing'
gemspec path: '..'
Loading
Loading