Skip to content

Instantly share code, notes, and snippets.

@serradura
Last active January 18, 2019 13:20
Show Gist options
  • Save serradura/7d51b979b90609d8601d0f416a9aa373 to your computer and use it in GitHub Desktop.
Save serradura/7d51b979b90609d8601d0f416a9aa373 to your computer and use it in GitHub Desktop.
Simple authorization library and role managment for Ruby - https://rubygems.org/gems/u-authorization

µ-authorization

Simple authorization library and role managment for Ruby.

Prerequisites

Ruby >= 2.2.2

Installation

Add this line to your application's Gemfile:

gem 'u-authorization'

And then execute:

$ bundle

Or install it yourself as:

$ gem install u-authorization

Usage

  require 'ostruct'
  require 'authorization'

  role = OpenStruct.new(
    name: 'user',
    permissions: {
      'visit' => { 'except' => ['billings'] },
      'edit_users' => false, # Same as: 'edit_users' => { 'any' => false },
      'export_as_csv' => { 'except' => ['sales'] }
    }
  )

  user = OpenStruct.new(id: 1, role: role)

  class SalesPolicy < Authorization::Policy
    def edit?(record)
      user.id == record.user_id
    end
  end

  authorization = Authorization::Model.build(user, user.role.permissions,
    context: ['dashboard', 'controllers', 'sales', 'index'],
    policies: { default: :sales, sales: SalesPolicy }
  )

  # Verifying the permissions for the given context
  authorization.permissions.to?('visit')         #=> true
  authorization.permissions.to?('export_as_csv') #=> false

  # Verifying permission for a given feature in different contexts
  has_permission_to = authorization.permissions.to('export_as_csv')
  has_permission_to.context?('billings') #=> true
  has_permission_to.context?('sales')    #=> false

  charge = OpenStruct.new(id: 2, user_id: user.id)

  # The #to() method fetch and build a policy.
  authorization.to(:sales).edit?(charge)   #=> true

  # :default is the only permitted key to receive
  # another symbol as value (a policy reference).
  authorization.to(:default).edit?(charge) #=> true

  # #policy() method has a similar behavior of #to(),
  # but if there is a policy named as ":default", it will be fetched and instantiated by default.
  authorization.policy.edit?(charge)         #=> true
  authorization.policy(:sales).edit?(charge) #=> true


  # Cloning the authorization changing only its context.
  new_authorization = authorization.map(context: [
    'dashboard', 'controllers', 'billings', 'index'
  ])

  new_authorization.permissions.to?('visit') #=> false

  authorization == new_authorization #=> false
# frozen_string_literal: true
module Authorization
VERSION = '1.4.0'
MapValuesAsDowncasedStrings = -> (values) do
Array(values).map { |value| String(value).downcase }
end
module CheckRolePermission
extend self
def call(context, role_permissions, required_features)
required_features
.all? { |feature| has_permission?(context, role_permissions[feature]) }
end
private
def has_permission?(context, role_permission)
return false if role_permission.nil?
if role_permission == false || role_permission == true
role_permission
elsif !(any = role_permission['any']).nil?
any
elsif only = role_permission['only']
check_feature_permission(only, context)
elsif except = role_permission['except']
!check_feature_permission(except, context)
else
raise NotImplementedError
end
end
def check_feature_permission(context_values, context)
MapValuesAsDowncasedStrings.(context_values).any? do |context_value|
Array(context_value.split('.')).all? { |permission| context.include?(permission) }
end
end
end
class FeaturesPermissionChecker
attr_reader :required_features
def initialize(role, features)
@role = role
@required_features = MapValuesAsDowncasedStrings.(features)
end
def context?(context)
CheckRolePermission.call(context, @role, @required_features)
end
end
class Permissions
attr_reader :role, :context
def self.[](instance)
return instance if instance.is_a?(Permissions)
raise ArgumentError, "#{instance.inspect} must be a #{self.name}"
end
def initialize(role_permissions, context: [])
@role = role_permissions.dup.freeze
@cache = {}
@context = MapValuesAsDowncasedStrings.(context).freeze
end
def to(features)
FeaturesPermissionChecker.new(@role, features)
end
def to?(features = nil)
has_permission_to = to(features)
cache_key = has_permission_to.required_features.inspect
return @cache[cache_key] unless @cache[cache_key].nil?
@cache[cache_key] = has_permission_to.context?(@context)
end
def to_not?(features = nil)
!to?(features)
end
end
class Policy
def self.type(klass)
return klass if klass < self
raise ArgumentError, "policy must be a #{self.name}"
end
def initialize(context, subject = nil, permissions: nil)
@context = context
@subject = subject
@permissions = permissions
end
def method_missing(method, *args, **keyargs, &block)
return false if method =~ /\?\z/
super(method)
end
private
def permissions; @permissions; end
def context; @context; end
def subject; @subject; end
def user
@user ||=
context.is_a?(Hash) ? context[:user] || context[:current_user] : context
end
alias_method :current_user, :user
end
class Model
attr_reader :user, :permissions
def self.build(user, role, context: [], policies: {})
permissions = Permissions.new(role, context: context)
self.new(user, permissions: permissions, policies: policies)
end
def initialize(user, permissions:, policies: {})
@user = user
@policies = {}
@policies_cache = {}
@permissions = Permissions[permissions]
add_policies(policies)
end
def map(context: nil, policies: nil)
if context.nil? && policies.nil?
raise ArgumentError, 'context or policies keywords args must be defined'
end
new_permissions =
Permissions.new(permissions.role, context: context || @context)
self.class.new(
user, permissions: new_permissions, policies: policies || @policies
)
end
def add_policy(key, policy_klass)
raise ArgumentError, 'key must be a Symbol' unless key.is_a?(Symbol)
default_ref = key == :default && policy_klass.is_a?(Symbol)
@policies[key] ||= default_ref ? policy_klass : Policy.type(policy_klass)
self
end
def add_policies(new_policies)
unless new_policies.is_a?(Hash)
raise ArgumentError, "policies must be a Hash (key => #{Policy.name})"
end
new_policies.each(&method(:add_policy))
self
end
def to(policy_key, subject: nil)
policy_klass = fetch_policy(policy_key)
return policy_klass.new(user, subject, permissions: permissions) if subject
return @policies_cache[policy_key] if @policies_cache[policy_key]
policy_klass.new(user, permissions: permissions).tap do |instance|
@policies_cache[policy_key] = instance if policy_klass != Policy
end
end
def policy(key = :default, subject: nil)
to(key, subject: subject)
end
private
def fetch_policy(policy_key)
data = @policies[policy_key]
value = data || @policies.fetch(:default, Policy)
value.is_a?(Symbol) ? fetch_policy(value) : value
end
end
end
# frozen_string_literal: true
class TestAuthorizationModel < Microtest::Test
require 'ostruct'
def setup
@user = OpenStruct.new(id: 1)
@role_permissions = {
'visit' => {'any' => true},
'export_as_csv' => {'except' => ['sales', 'foo']}
}
end
def test_permissions
authorization = Authorization::Model.build(
@user, @role_permissions,
context: ['dashboard', 'controllers', 'sales', 'index']
)
assert authorization.permissions.to?('visit')
refute authorization.permissions.to?('export_as_csv')
end
class FooPolicy < Authorization::Policy
def index?
!user.id.nil?
end
end
class BarPolicy < Authorization::Policy
def index?
true
end
end
class BazPolicy < Authorization::Policy
def index?(value)
value == true
end
end
class NumericSubjectPolicy < Authorization::Policy
def valid?
subject.is_a? Numeric
end
def number
subject
end
end
test '#to' do
authorization = Authorization::Model.build(@user, {}, context: [])
refute authorization.to(:foo).index?, "forbids if the policy wasn't added"
refute authorization.to(:bar).index?, "forbids if the policy wasn't added"
refute authorization.to(:baz).index?(true), "forbids if the policy wasn't added"
refute authorization.to(:numeric_subject).valid?, "forbids if the policy wasn't added"
authorization.add_policies(foo: FooPolicy, bar: BarPolicy, baz: BazPolicy)
assert authorization.to(:foo).index?
assert authorization.to(:bar).index?
assert authorization.to(:baz).index?(true)
authorization.add_policy(:numeric_subject, NumericSubjectPolicy)
assert authorization.to(:numeric_subject, subject: 1).valid?
end
test '#to (default)' do
authorization = Authorization::Model.build(@user, {}, context: [])
assert authorization.to(:foo).class == Authorization::Policy
assert authorization.to(:bar).class == Authorization::Policy
authorization.add_policy(:default, FooPolicy)
assert authorization.to(:foo).class == FooPolicy
assert authorization.to(:bar).class == FooPolicy
end
test '#to (cache strategy)' do
authorization = Authorization::Model.build(
@user, {}, context: [], policies: { bar: BarPolicy }
)
assert authorization.to(:bar).index?
authorization.to(:bar).class.class_eval do
class << self
alias_method :original_new, :new
end
def self.new(*args, **kargs)
raise
end
end
assert authorization.to(:bar).index?
authorization.to(:bar).class.class_eval do
class << self
alias_method :new, :original_new
end
end
end
test '#policy (default)' do
authorization1 = Authorization::Model.build(
@user, {}, context: [], policies: { default: FooPolicy }
)
assert authorization1.policy.class == authorization1.to(:default).class
authorization2 = Authorization::Model.build(
@user, {}, context: [], policies: { default: :foo, foo: FooPolicy }
)
assert authorization2.policy.class == authorization2.to(:default).class
end
test 'same behavior to #policy and #to methods' do
authorization = Authorization::Model.build(@user, {}, context: [],
policies: {
default: FooPolicy, baz: BazPolicy, numeric_subject: NumericSubjectPolicy
}
)
assert authorization.policy(:baz).class == authorization.to(:baz).class
assert authorization.policy(:unknow).class == authorization.to(:unknow).class
numeric_subject_policy_a = authorization.to(:numeric_subject, subject: 1)
numeric_subject_policy_b = authorization.policy(:numeric_subject, subject: 1)
assert numeric_subject_policy_a.number == numeric_subject_policy_b.number
end
test '#map context:' do
authorization = Authorization::Model.build(
@user, @role_permissions,
context: ['dashboard', 'controllers', 'sales', 'index']
)
new_authorization = authorization.map(context: [
'dashboard', 'controllers', 'releases', 'index'
])
refute authorization == new_authorization
assert new_authorization.permissions.to?('visit')
assert new_authorization.permissions.to?('export_as_csv')
end
test '#map policies:' do
@user.id = nil
authorization = Authorization::Model.build(
@user, @role_permissions,
context: ['sales'], policies: { default: FooPolicy }
)
refute authorization.policy.index?
assert authorization.permissions.to_not?('export_as_csv')
new_authorization = authorization.map(policies: { default: BarPolicy })
assert new_authorization.policy.index?
assert authorization.permissions.to_not?('export_as_csv')
end
test '#map context: nil, policies: nil' do
begin
authorization = Authorization::Model.build(
@user, @role_permissions,
context: ['sales'], policies: { default: FooPolicy }
)
authorization.map()
rescue ArgumentError => _e
assert true
end
end
end
# frozen_string_literal: true
class TestAuthorizationPermissions < Microtest::Test
require 'json'
def setup_all
json = self.class.const_get(:ROLE).tap do |raw|
identation = raw.scan(/^\s*/).min_by{ |l| l.length }
striped_heredoc = raw.gsub(/^#{identation}/, '')
puts striped_heredoc
end
@role = JSON.parse(json)
@start_time = Time.now
end
def build_permissions(context)
Authorization::Permissions.new @role['permissions'], context: context
end
def teardown_all
print 'Elapsed time in milliseconds: '
puts (Time.now - @start_time) * 1000.0
puts ''
end
end
class TestAdminPermissions < TestAuthorizationPermissions
ROLE = <<-JSON
{
"name": "admin",
"permissions": {
"visit": true,
"refund": { "any": true },
"export_as_csv": { "any": true }
}
}
JSON
def test_role_permissions
permissions1 = build_permissions('home')
permissions2 = build_permissions(['home'])
assert permissions1.to?('visit')
assert permissions2.to?(['visit'])
assert permissions2.to?(['visit', 'refund'])
assert permissions1.to?('export_as_csv')
refute permissions1.to_not?('visit')
refute permissions2.to_not?(['visit'])
refute permissions2.to_not?(['visit', 'refund'])
refute permissions1.to_not?(['export_as_csv'])
assert permissions1.to('visit').context?('home')
assert permissions2.to('visit').context?(['home'])
end
end
module ReadOnlyRoles
A = <<-JSON
{
"name": "visitonly",
"permissions": {
"visit": { "any": true }
}
}
JSON
B = <<-JSON
{
"name": "visitonly2",
"permissions": {
"visit": { "any": true },
"refund": false,
"export_as_csv": false
}
}
JSON
end
class TestReadonlyPermissions < TestAuthorizationPermissions
def call_permissions_test
permissions1 = build_permissions('home')
permissions2 = build_permissions(['home'])
assert permissions1.to?('visit')
assert permissions2.to?(['visit'])
refute permissions1.to_not?('visit')
refute permissions2.to_not?(['visit'])
refute permissions2.to?(['visit', 'refund'])
assert permissions2.to_not?(['visit', 'refund'])
refute permissions1.to?('export_as_csv')
assert permissions1.to_not?(['export_as_csv'])
end
end
class TestReadonlyAPermissions < TestReadonlyPermissions
ROLE = ReadOnlyRoles::A
alias_method :test_role_permissions, :call_permissions_test
end
class TestReadonlyBPermissions < TestReadonlyPermissions
ROLE = ReadOnlyRoles::B
alias_method :test_role_permissions, :call_permissions_test
end
class TestUser0Permissions < TestAuthorizationPermissions
ROLE = <<-JSON
{
"name": "user0",
"permissions": {
"visit": { "any": true },
"export_as_csv": { "only": ["sales"] }
}
}
JSON
def test_role_permissions
permissions1 = build_permissions('home')
assert permissions1.to?('visit')
assert permissions1.to?(['visit'])
refute permissions1.to?('export_as_csv')
permissions_to = permissions1.to('export_as_csv')
refute permissions_to.context?('home')
permissions2 = build_permissions(['sales'])
assert permissions2.to?(['visit'])
assert permissions2.to?(['visit', 'export_as_csv'])
assert permissions2.to?('export_as_csv')
another_permissions_to = permissions2.to('export_as_csv')
assert another_permissions_to.context?('sales')
end
end
class TestUser1Permissions < TestAuthorizationPermissions
ROLE = <<-JSON
{
"name": "user1",
"permissions": {
"visit": { "only": ["sales", "commissionings", "releases"] },
"export_as_csv": { "only": ["sales"] }
}
}
JSON
def test_role_permissions
permissions1 = build_permissions('home')
refute permissions1.to?('visit')
refute permissions1.to?('export_as_csv')
permissions2 = build_permissions('sales')
assert permissions2.to?('visit')
assert permissions2.to?('export_as_csv')
assert permissions2.to?(['visit', 'export_as_csv'])
permissions3 = build_permissions('commissionings')
assert permissions3.to?('visit')
refute permissions3.to?('export_as_csv')
refute permissions3.to?(['visit', 'export_as_csv'])
permissions4 = build_permissions('commissionings')
assert permissions4.to?('visit')
refute permissions4.to?('export_as_csv')
refute permissions4.to?(['visit', 'export_as_csv'])
end
end
class TestUser2Permissions < TestAuthorizationPermissions
ROLE = <<-JSON
{
"name": "user2",
"permissions": {
"visit": { "only": ["sales", "commissionings", "releases"] },
"export_as_csv": { "except": ["sales"] }
}
}
JSON
def test_role_permissions
permissions1 = build_permissions('home')
refute permissions1.to?('visit')
assert permissions1.to?('export_as_csv') # Warning!!!
permissions2 = build_permissions('sales')
assert permissions2.to?('visit')
refute permissions2.to?('export_as_csv')
refute permissions2.to?(['visit', 'export_as_csv'])
permissions3 = build_permissions('commissionings')
assert permissions3.to?('visit')
assert permissions3.to?('export_as_csv')
assert permissions3.to?(['visit', 'export_as_csv'])
permissions4 = build_permissions('commissionings')
assert permissions4.to?('visit')
assert permissions4.to?('export_as_csv')
assert permissions4.to?(['visit', 'export_as_csv'])
end
end
class TestUser3Permissions < TestAuthorizationPermissions
ROLE = <<-JSON
{
"name": "user3",
"permissions": {
"visit": { "except": ["sales"] },
"export_as_csv": { "except": ["sales"] }
}
}
JSON
def test_role_permissions
permissions1 = build_permissions('home')
assert permissions1.to?('visit')
assert permissions1.to?('export_as_csv')
permissions2 = build_permissions('sales')
refute permissions2.to?('visit')
refute permissions2.to?('export_as_csv')
refute permissions2.to?(['visit', 'export_as_csv'])
permissions3 = build_permissions('commissionings')
assert permissions3.to?('visit')
assert permissions3.to?('export_as_csv')
assert permissions3.to?(['visit', 'export_as_csv'])
permissions4 = build_permissions('commissionings')
assert permissions4.to?('visit')
assert permissions4.to?('export_as_csv')
assert permissions4.to?(['visit', 'export_as_csv'])
end
end
class TestAuthorizationPermissionsToHanamiClasses < TestAuthorizationPermissions
module Dashboard
module Controllers
module Sales
class Index
end
end
end
module Views
module Sales
class Index
end
end
end
end
ROLE = <<-JSON
{
"name": "hanami_classes",
"permissions": {
"visit": { "any": true },
"export_as_csv": { "except": ["sales"] }
}
}
JSON
def extract_permissions_context_from_hanami_class(klass)
klass.name.downcase.split('::').tap do |context|
puts "Context: #{context.inspect}"
end
end
def test_role_permissions
controller_context = extract_permissions_context_from_hanami_class(
Dashboard::Controllers::Sales::Index
)
permissions1 = build_permissions(controller_context)
assert permissions1.to?('visit')
refute permissions1.to?('export_as_csv')
view_context = extract_permissions_context_from_hanami_class(
Dashboard::Views::Sales::Index
)
permissions2 = build_permissions(view_context)
assert permissions2.to?('visit')
refute permissions2.to?('export_as_csv')
end
end
class TestAuthorizationPermissionsCacheStrategy < TestAuthorizationPermissions
ROLE = <<-JSON
{
"name": "cache_strategy",
"permissions": {
"visit": { "any": ["true"] },
"refund": { "except": ["sales"] }
}
}
JSON
def test_cache_with_single_feature_verification
permissions = build_permissions(['sales'])
assert permissions.to?('visit')
refute permissions.to?('refund')
def permissions.permitted?(_feature_context); raise; end
assert permissions.to?('visit')
refute permissions.to?('refund')
end
def test_cache_with_multiple_features_verification
permissions = build_permissions(['sales'])
assert permissions.to?('visit')
assert permissions.to_not?(['visit', 'refund'])
def permissions.permitted?(_feature_context); raise; end
assert permissions.to?('visit')
assert permissions.to_not?(['visit', 'refund'])
end
end
# frozen_string_literal: true
module TestAuthorizationPolicies
require 'ostruct'
class StandardBehavior < Microtest::Test
class StardardPolicy < Authorization::Policy
end
def test_false_as_the_default_result_to_any_kind_of_query
user = {}
record = {}
policy = StardardPolicy.new(user)
refute policy.index?
refute policy.show?(record)
refute policy.show?(record: record)
refute policy.show? { record }
end
def test_non_predicate_method
StardardPolicy.new({}).foo
rescue NoMethodError => e
assert e.message.include?('foo')
end
end
class CustomBehavior < Microtest::Test
def setup
@user = OpenStruct.new(name: 'User', id: 1)
@record_a = OpenStruct.new(user_id: @user.id)
@record_b = OpenStruct.new(user_id: 2)
end
class CustomPolicyA < Authorization::Policy
def show?
user.id == subject.user_id
end
end
def test_policy_result_when_receives_a_subject_in_the_initializer
assert CustomPolicyA.new(@user, @record_a).show?
refute CustomPolicyA.new(@user, @record_b).show?
end
class CustomPolicyB < Authorization::Policy
def show?(record)
permissions.to?('visit') && current_user.id == record.user_id
end
end
def test_policy_result_when_receives_the_subject_as_a_query_argument
permissions = Authorization::Permissions.new(
{ 'visit' => { 'only' => ['test'] } }, context: ['test']
)
policy = CustomPolicyB.new(@user, permissions: permissions)
assert policy.show?(@record_a) == true && policy.show?(@record_b) == false
end
end
end
# frozen_string_literal: true
require 'bundler/inline'
gemfile do
source 'https://rubygems.org'
gem 'u-test', '0.9.0'
end
require_relative 'authorization'
require_relative 'test_authorization_permissions'
require_relative 'test_authorization_policy'
require_relative 'test_authorization_model'
Microtest.call
require_relative 'authorization'
Gem::Specification.new do |s|
s.name = 'u-authorization'
s.summary = 'Authorization library and role managment'
s.description = 'Simple authorization library and role managment for Ruby.'
s.version = Authorization::VERSION
s.licenses = ['MIT']
s.platform = Gem::Platform::RUBY
s.required_ruby_version = '>= 2.2.2'
s.files = ['authorization.rb', 'u-authorization.rb']
s.require_path = '.'
s.author = 'Rodrigo Serradura'
s.email = 'rodrigo.serradura@gmail.com'
s.homepage = 'https://gist.github.com/serradura/7d51b979b90609d8601d0f416a9aa373'
end
# frozen_string_literal: true
require 'authorization'
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment