Skip to content

Latest commit

 

History

History
175 lines (129 loc) · 4.92 KB

reasons.md

File metadata and controls

175 lines (129 loc) · 4.92 KB

Failure Reasons

When you have complex policy rules, it could be helpful to have an ability to define an exact reason for why a specific authorization was rejected.

It is especially helpful when you compose policies (i.e., use one policy within another) or want to expose permissions to client applications (see GraphQL).

Action Policy allows you to track failed allowed_to? checks in your rules.

Consider an example:

class ApplicantPolicy < ApplicationPolicy
  def show?
    user.has_permission?(:view_applicants) &&
      allowed_to?(:show?, record.stage)
  end
end

When ApplicantPolicy#show? check fails, the exception has the result object, which in its turn contains additional information about the failure (reasons):

class ApplicationController < ActionController::Base
  rescue_from ActionPolicy::Unauthorized do |ex|
    p ex.result.reasons.details #=> { stage: [:show?] }

    # or with i18n support
    p ex.result.reasons.full_messages #=> ["You do not have access to the stage"]
  end
end

The reason key is the corresponding policy identifier.

You can also wrap local rules into allowed_to? to populate reasons:

class ApplicantPolicy < ApplicationPolicy
  def show?
    allowed_to?(:view_applicants?) &&
      allowed_to?(:show?, record.stage)
  end

  def view_applicants?
    user.has_permission?(:view_applicants)
  end
end

# then the reasons object could be
p ex.result.reasons.details #=> { applicant: [:view_applicants?] }

# or
p ex.result.reasons.details #=> { stage: [:show?] }

Reason could also be specified for deny! calls:

class TeamPolicy < ApplicationPolicy
  def show?
    deny!(:no_user) if user.anonymous?

    user.has_permission?(:view_teams)
  end
end

p ex.result.reasons.details #=> { applicant: [:no_user] }

In some cases it might be useful to propagate reasons from the nested policy calls instead of adding a top-level reason. You can achieve this by adding the inline_reasons: true option:

class ApplicantPolicy < ApplicationPolicy
  def show?
    allowed_to?(:show?, record.stage, inline_reasons: true)
  end
end

class StagePolicy < ApplicationPolicy
  def show?
    deny!(:archived) if record.archived?
  end
end

# When applying ApplicationPolicy and the stage is archived
p ex.result.reasons.details #=> { stage: [:archived] }
# Without inline_reasons we would get { stage: [:show?] } instead

See also #186 for discussion.

Detailed Reasons

You can provide additional details to your failure reasons by using a details: { ... } option:

class ApplicantPolicy < ApplicationPolicy
  def show?
    allowed_to?(:show?, record.stage)
  end
end

class StagePolicy < ApplicationPolicy
  def show?
    # Add stage title to the failure reason (if any)
    # (could be used by client to show more descriptive message)
    details[:title] = record.title

    # then perform the checks
    user.stages.where(id: record.id).exists?
  end
end

# when accessing the reasons
p ex.result.reasons.details #=> { stage: [{show?: {title: "Onboarding"}] }

NOTE: when using detailed reasons, the details array contains as the last element a hash with ALL details reasons for the policy (in a form of <rule> => <details>).

The additional details are especially helpful when combined with localization, 'cause you can you them as interpolation data source for your translations. For example, for the above policy:

en:
  action_policy:
    policy:
      stage:
        show?: "The %{title} stage is not accessible"

And then when you call full_messages:

p ex.result.reasons.full_messages #=> The Onboarding stage is not accessible

result.all_details

Sometimes details could be useful not only to provide more context to the user, but to handle failures differently.

In this cases, digging through the ex.result.reasons.details could be cumbersome (see this PR for discussion). We provide a helper method, result.all_details, which could be used to get all details merged into a single Hash:

rescue_from ActionPolicy::Unauthorized do |ex|
  if ex.result.all_details[:not_found]
    head :not_found
  else
    head :unauthorized
  end
end

P.S. What is the point of failure reasons?

Failure reasons helps you to write actionable error messages, i.e. to provide a user with helpful feedback.

For example, in the above scenario, when the reason is ApplicantPolicy#view_applicants?, you could show the following message:

You don't have enough permissions to view applicants.
Please, ask your manager to update your role.

And when the reason is StagePolicy#show?:

You don't have access to the stage XYZ.
Please, ask your manager to grant access to this stage.

Much more useful than just showing "You are not authorized to perform this action," isn't it?