class Shamu::Security::Policy

@example

class UserPolicy < Shamu::Security::Policy

  role :admin, inherits: :manager
  role :manager
  role :user

  private

    def permissions
      alias_action :email, to: :contact

      permit :contact, UserEntity if in_role?( :manager )
      permit :email, UserEntity do |user|
        user.public_profile?
      end
    end
end

principal = Shamu::Security::Principal.new( user_id: user.id )
policy = UserPolicy.new(
  principal: principal,
  roles: roles_service.roles_for( principal )
  )

if policy.permit? :contact, user
  mail_to user
end

Attributes

principal[R]

@!attribute @return [Principal] principal holding user identity and access credentials.

roles[R]

@!attribute @return [Array<Roles>] roles that have been granted to the {#principal}.

Public Class Methods

new( principal: nil, roles: nil, related_user_ids: nil ) click to toggle source

@!endgroup Dependencies

# File lib/shamu/security/policy.rb, line 59
def initialize( principal: nil, roles: nil, related_user_ids: nil )
  @principal        = principal || Principal.new
  @roles            = roles || []
  @related_user_ids = Array.wrap( related_user_ids )
end

Public Instance Methods

authorize!( action, resource, additional_context = nil ) click to toggle source

Authorize the given `action` on the given resource. If it is not {#permit? permitted} then an exception is raised.

@param (see permit?) @return [resource] @raise [AccessDeniedError] if not permitted.

# File lib/shamu/security/policy.rb, line 71
def authorize!( action, resource, additional_context = nil )
  return resource if permit?( action, resource, additional_context ) == :yes

  fail Security::AccessDeniedError,
       action: action,
       resource: resource,
       additional_context: additional_context,
       principal: principal
end
permit?( action, resource, additional_context = nil ) click to toggle source

Determines if the given `action` may be performed on the given `resource`.

@param [Symbol] action to perform. @param [Object] resource the resource the action will be performed on. @param [Object] additional_context that the policy may consider. @return [:yes, :maybe, false] a truthy value if permitted, otherwise

false. The truthy value depends on the certainty of the policy. A
value of `:yes` or `true` indicates the action is always permitted.
A value of `:maybe` indicates the action is permitted but the user
may need to present additional credentials such as logging on this
session or entering a TFA code.
# File lib/shamu/security/policy.rb, line 93
def permit?( action, resource, additional_context = nil )
  fail_on_active_record_check( resource )

  rules.each do |rule|
    next unless rule.match?( action, resource, additional_context )

    return rule.result
  end

  false
end

Private Instance Methods

add_rule( actions, resource, result, &block ) click to toggle source
# File lib/shamu/security/policy.rb, line 337
def add_rule( actions, resource, result, &block )
  rules.unshift PolicyRule.new( expand_aliases( actions ), resource, result, block )
end
alias_action( *actions, to: fail ) click to toggle source

@!visibility public

Add an action alias so that granting the alias will result in permits for any of the listed actions.

@example

alias_action :show, :list, to: :read
permit :read, :stuff

permit?( :show, :stuff )  # => :yes
permit?( :list, :stuff )  # => :yes
permit?( :read, :stuff )  # => :yes
permit?( :write, :stuff ) # => false

@param [Array<Symbol>] actions to alias. @param [Symbol] to the action that should permit all the listed aliases. @return [void]

# File lib/shamu/security/policy.rb, line 297
def alias_action( *actions, to: fail ) # bug in rubocop chokes on trailing required keyword
  aliases[to] ||= []
  aliases[to] |= actions
end
aliases() click to toggle source

Mapping of action names to aliases.

# File lib/shamu/security/policy.rb, line 117
def aliases
  @aliases ||= default_aliases
end
anonymous?() click to toggle source

@return [Boolean] true if the {#principal} has not authenticated.

# File lib/shamu/security/policy.rb, line 162
def anonymous?
  !authenticated?
end
authenticated?() click to toggle source

@return [Boolean] true if {#principal} has authenticated.

# File lib/shamu/security/policy.rb, line 157
def authenticated?
  principal.try( :user_id )
end
default_aliases() click to toggle source
# File lib/shamu/security/policy.rb, line 121
def default_aliases
  {
    view: [ :read, :list ],
    change: [ :create, :update, :destroy ]
  }
end
deny( *actions, &block ) click to toggle source

@!visibility public

Explicitly deny an action previously granted with {#permit}.

@param (see permit) @return [void] @yield (see permit) @yieldparam (see permit) @yieldreturn [Boolean] true to deny the action.

# File lib/shamu/security/policy.rb, line 254
def deny( *actions, &block )
  resource, actions = extract_resource( actions )
  add_rule( actions, resource, false, &block )
end
dsl_resource() click to toggle source

@!endgroup DSL

# File lib/shamu/security/policy.rb, line 328
def dsl_resource
  @dsl_resource || fail( "Provide a `resource` argument or use a #resource block to declare the protected resource." ) # rubocop:disable Metrics/LineLength
end
expand_alias_into( candidate, expanded ) click to toggle source
# File lib/shamu/security/policy.rb, line 350
def expand_alias_into( candidate, expanded )
  return unless mapped = aliases[candidate]

  mapped.each do |action|
    next if expanded.include? action

    expanded << action
    expand_alias_into( action, expanded )
  end
end
expand_aliases( actions ) click to toggle source
# File lib/shamu/security/policy.rb, line 341
def expand_aliases( actions )
  expanded = actions.dup
  actions.each do |action|
    expand_alias_into( action, expanded )
  end

  expanded
end
extract_resource( actions ) click to toggle source
# File lib/shamu/security/policy.rb, line 332
def extract_resource( actions )
  resource = actions.last.is_a?( Symbol ) ? dsl_resource : actions.pop
  [ resource, actions ]
end
fail_on_active_record_check( resource ) click to toggle source
# File lib/shamu/security/policy.rb, line 361
def fail_on_active_record_check( resource )
  return unless resource
  return unless defined? ActiveRecord

  if resource.is_a?( ActiveRecord::Base ) || ( resource.is_a?( Class ) && resource < ActiveRecord::Base )
    fail NoActiveRecordPolicyChecksError
  end
end
in_role?( *roles ) click to toggle source

@!visibility public

@param [Array<Symbol>] roles to check. @return [Boolean] true if the {#principal} has been granted one of the

given roles.
# File lib/shamu/security/policy.rb, line 133
def in_role?( *roles )
  ( principal_roles & roles ).any?
end
is_principal?( id ) click to toggle source

@!visibility public

@param [Integer] id of the candidate user. @return [Boolean] true if the given id is one of the authorized user ids on the principal.

# File lib/shamu/security/policy.rb, line 152
def is_principal?( id ) # rubocop:disable Style/PredicateName
  principal.try( :user_id ) == id || related_user_ids.include?( id )
end
permissions() click to toggle source

@!visibility public

Hook to be overridden by a derived class to define the set of rules that {#permit?} should consider when evaluating the {#principal}'s permissions on a resource.

Rules defined in the permissions block are evaluated in reverse order such that the last matching {#permit} or {#deny} will determine the permission.

If no rules match, the permission is denied.

@example

def permissions
  permit :read, UserEntity

  deny :read, UserEntity do |user|
    user.protected_account? && !in_role( :admin )
  end
end

@return [void]

# File lib/shamu/security/policy.rb, line 192
def permissions
  if respond_to?( :anonymous_permissions, true ) && respond_to?( :authenticated_permissions, true )
    if in_role?( :authenticated )
      authenticated_permissions
    else
      anonymous_permissions
    end
  else
    fail IncompleteSetupError, "Permissions have not been defined. Add a private `permissions` method to #{ self.class.name }" # rubocop:disable Metrics/LineLength
  end
end
permit( *actions, &block ) click to toggle source

@!visibility public

Permit one or more `actions` to be performed on a given `resource`.

When a block is provided the policy will yield to the block to allow for more complex or context aware policy checks. The block is not called if the resource offered to {#permit?} is a Class or Module.

@example

permit :read, UserEntity
permit :show, :dashboard
permit :update, UserEntity do |user|
  user.id == principal.user_id
end
permit :destroy, UserEntity do |user, additional_context|
  in_role?( :admin ) && additional_context[:custom_data] == :safe
end

@param [Array<Symbol>] actions to be permitted. @param [Object] resource to perform the action on or the Class of

instances to permit the action on.

@yield ( resource, additional_context ) @yieldparam [Object] resource instance or Class offered to {#permit?}

that the requested action is to be performed on.

@yieldparam [Object] additional_context offered to {#permit?}. @yieldreturn [:yes, :maybe, false] see {#permit?}. @return [void]

# File lib/shamu/security/policy.rb, line 238
def permit( *actions, &block )
  result = @when_elevated ? :maybe : :yes
  resource, actions = extract_resource( actions )

  add_rule( actions, resource, result, &block )
end
principal_roles() click to toggle source
# File lib/shamu/security/policy.rb, line 137
def principal_roles
  @principal_roles ||= begin
    expanded = self.class.expand_roles( *roles )
    expanded << :authenticated if principal.user_id && self.class.role_defined?( :authenticated )
    expanded.select do |role|
      principal.scoped?( role )
    end
  end
end
resolve_permissions() click to toggle source

Makes sure the {#permissions} method is invoked only once.

# File lib/shamu/security/policy.rb, line 205
def resolve_permissions
  return if @permissions_resolved
  @permissions_resolved = true
  permissions
end
resource( resource ) { || ... } click to toggle source

@!visibility public

Define the `resource` to {#permit} or {#deny} access to. Inside the block you can omit the `resource` param on DSL methods that expect it.

@example

resource UserEntity do
  permit :read
  permit :update do |user|
    user.id == principal.user_id
  end

  permit :chop, OtherKindOfEntity
end
# File lib/shamu/security/policy.rb, line 317
def resource( resource )
  last_resource = @dsl_resource
  @dsl_resource = resource
  yield
ensure
  @dsl_resource = last_resource
end
rules() click to toggle source

The rules that have been defined.

# File lib/shamu/security/policy.rb, line 108
def rules
  @rules ||= begin
    @rules = []
    resolve_permissions
    @rules
  end
end
when_elevated( ) { || ... } click to toggle source

@!visibility public

Only {#authorize!} the permissions defined in the given block when the {#principal} has elevated this session by providing their credentials.

Permissions defined in the block will yield a `:maybe` result when queried via {#permit?} and will raise an {AccessDeniedError} when an {#authorize!} check is enforced.

This allows you to enable/disable UX in response to what a user should be capable of doing but wait to actually allow it until they have offered their credentials.

@return [void]

# File lib/shamu/security/policy.rb, line 273
def when_elevated( &block )
  current = @when_elevated
  @when_elevated = true
  yield
  @when_elevated = current
end