lib/view_model/access_control.rb



# frozen_string_literal: true

require 'view_model/access_control_error'

## Defines an access control discipline for a given action against a viewmodel.
##
## Access control is based around three edit check hooks: visible, editable and
## valid_edit. The visible determines whether a view can be seen. The editable
## check determines whether a view in its current state is eligible to be
## changed. The valid_edit change determines whether an attempted change is
## permitted. Each edit check returns a pair of boolean success and optional
## exception to raise.
class ViewModel::AccessControl
  Result = Struct.new(:permit, :error) do
    def initialize(permit, error: nil)
      raise ArgumentError.new('Successful AccessControl::Result may not have an error') if permit && error

      super(permit, error)
    end

    alias_method :permit?, :permit

    # Merge this result with another access control result. Takes a block
    # returning a result, and returns a combined result for both tests. Access
    # is permitted if both results permit. Otherwise, access is denied with the
    # error value of the first denying Result.
    def merge(&_block)
      if permit?
        yield
      else
        self
      end
    end
  end

  Result::PERMIT = Result.new(true).freeze
  Result::DENY   = Result.new(false).freeze

  def initialize
    @initial_editability_store = {}
  end

  # Check that the user is permitted to view the record in its current state, in
  # the given context.
  def visible_check(_traversal_env)
    Result::DENY
  end

  # Editable checks during deserialization are always a combination of
  # `editable_check` and `valid_edit_check`, which express the following
  # separate properties. `The after_deserialize check passes if both checks are
  # successful.

  # Check that the record is eligible to be changed in its current state, in the
  # given context. This must be called before any edits have taken place (thus
  # checking against the initial state of the viewmodel), and if editing is
  # denied, an error must be raised only if an edit is later attempted. To be
  # overridden by viewmodel implementations.
  def editable_check(_traversal_env)
    Result::DENY
  end

  # Once the changes to be made to the viewmodel are known, check that the
  # attempted changes are permitted in the given context. For viewmodels with
  # transactional backing models, the changes may be made in advance to give the
  # edit checks the opportunity to compare values. To be overridden by viewmodel
  # implementations.
  def valid_edit_check(_traversal_env)
    Result::DENY
  end

  # Wrappers to check access control for a single view directly. Because the
  # checking is run directly on one node without any tree context, it's only
  # valid to run:
  # * on root views
  # * when no children could contribute to the result
  def visible!(view, context:)
    run_callback(ViewModel::Callbacks::Hook::BeforeVisit, view, context)
    run_callback(ViewModel::Callbacks::Hook::AfterVisit,  view, context)
  end

  def editable!(view, deserialize_context:, changes:)
    run_callback(ViewModel::Callbacks::Hook::BeforeVisit,       view, deserialize_context)
    run_callback(ViewModel::Callbacks::Hook::BeforeDeserialize, view, deserialize_context)
    run_callback(ViewModel::Callbacks::Hook::OnChange,          view, deserialize_context, changes: changes) if changes
    run_callback(ViewModel::Callbacks::Hook::AfterDeserialize,  view, deserialize_context, changes: changes)
    run_callback(ViewModel::Callbacks::Hook::AfterVisit,        view, deserialize_context)
  end

  # Edit checks are invoked via traversal callbacks:
  include ViewModel::Callbacks

  before_visit do
    result = visible_check(self)

    raise_if_error!(result) do
      ViewModel::AccessControlError.new(
        "Illegal access to viewmodel '#{view.class.view_name}'",
        view.blame_reference)
    end
  end

  before_deserialize do
    initial_result = editable_check(self)

    save_editability(view, initial_result)
  end

  on_change do
    initial_result = fetch_editability(view)
    result = initial_result.merge do
      valid_edit_check(self)
    end

    raise_if_error!(result) do
      ViewModel::AccessControlError.new(
        "Illegal edit to viewmodel '#{view.class.view_name}'",
        view.blame_reference)
    end
  end

  after_deserialize do
    # If there was no change to consume the initial editability we still want to clean it up
    cleanup_editability(view)
  end

  private

  def save_editability(view, initial_editability)
    if @initial_editability_store.has_key?(view.object_id)
      raise RuntimeError.new("Access control data already recorded for view #{view.to_reference}")
    end

    @initial_editability_store[view.object_id] = initial_editability
  end

  def fetch_editability(view)
    unless @initial_editability_store.has_key?(view.object_id)
      raise RuntimeError.new("No access control data recorded for view #{view.to_reference}")
    end

    @initial_editability_store.delete(view.object_id)
  end

  def cleanup_editability(view)
    @initial_editability_store.delete(view.object_id)
  end

  def raise_if_error!(result)
    raise (result.error || yield) unless result.permit?
  end

  # Called from composed access controls via the `env`, this is used to make the
  # if/unless DSL more readable when returning a custom failure error.
  def failure(err)
    raise ArgumentError.new("Unexpected failure type: #{err}") unless err.is_a?(StandardError)

    err
  end
end

require 'view_model/access_control/open'
require 'view_model/access_control/read_only'
require 'view_model/access_control/composed'
require 'view_model/access_control/tree'