lib/volt/models/permissions.rb

Summary

Maintainability
C
7 hrs
Test Coverage
module Volt
  class Model
    # The permissions module provides helpers for working with Volt permissions.
    module Permissions
      module ClassMethods
        # Own by user requires a logged in user (Volt.current_user) to save a model.  If
        # the user is not logged in, an validation error will occur.  Once created
        # the user can not be changed.
        #
        # @param key [Symbol] the name of the attribute to store
        def own_by_user(key = :user_id)
          relation, pattern = key.to_s, /_id$/
          if relation.match(pattern)
            belongs_to key.to_s.gsub(pattern, '')
          else
            raise "You tried to auto associate a model using #{key}, but #{key} "\
                  "does not end in `_id`"
          end          # When the model is created, assign it the user_id (if the user is logged in)
          on(:new) do
            # Only assign the user_id if there isn't already one and the user is logged in.
            if get(:user_id).nil? && !(user_id = Volt.current_user_id).nil?
              set(key, user_id)
            end
          end

          permissions(:update) do
            # Don't allow the key to be changed
            deny(key)
          end

          # Setup a validation that requires a user_id
          validate do
            # Lookup directly in @attributes to optimize and prevent the need
            # for a nil model.
            unless @attributes[:user_id]
              # Show an error that the user is not logged in
              next { key => ['requires a logged in user'] }
            end
          end
        end

        # permissions takes a block and yields
        def permissions(*actions, &block)
          # Store the permissions block so we can run it in validations
          self.__permissions__ ||= {}

          # if no action was specified, assume all actions
          actions += [:create, :read, :update, :delete] if actions.size == 0

          actions.each do |action|
            # Add to an array of proc's for each action
            (self.__permissions__[action] ||= []) << block
          end

          validate do
            action = new? ? :create : :update
            run_permissions(action)
          end
        end
      end

      def self.included(base)
        base.send(:extend, ClassMethods)
        base.class_attribute :__permissions__
      end

      def allow(*fields)
        if @__allow_fields
          if @__allow_fields != true
            if fields.size == 0
              # No field's were passed, this means we deny all
              @__allow_fields = true
            else
              # Fields were specified, add them to the list
              @__allow_fields += fields.map(&:to_sym)
            end
          end
        else
          fail 'allow should be called inside of a permissions block'
        end
      end

      def deny(*fields)
        if @__deny_fields
          if @__deny_fields != true
            if fields.size == 0
              # No field's were passed, this means we deny all
              @__deny_fields = true
            else
              # Fields were specified, add them to the list
              @__deny_fields += fields.map(&:to_sym)
            end
          end
        else
          fail 'deny should be called inside of a permissions block'
        end
      end

      # owner? can be called on a model to check if the currently logged
      # in user (```Volt.current_user```) is the owner of this instance.
      #
      # @param key [Symbol] the name of the attribute where the user_id is stored
      def owner?(key = :user_id)
        # Lookup the original user_id
        owner_id = was(key) || send(:"_#{key}")
        !owner_id.nil? && owner_id == Volt.current_user_id
      end

      [:create, :update, :read, :delete].each do |action|
        # Each can_action? (can_delete? for example) returns a promise that
        # resolves to true or false if the user
        define_method(:"can_#{action}?") do
          action_allowed?(action)
        end
      end

      # Checks if any denies are in place for an action (read or delete)
      def action_allowed?(action_name)
        # TODO: this does some unnecessary work
        compute_allow_and_deny(action_name).then do

          deny = @__deny_fields == true || (@__deny_fields && @__deny_fields.size > 0)

          clear_allow_and_deny

          !deny
        end
      end

      # Return the list of allowed fields
      def allow_and_deny_fields(action_name)
        compute_allow_and_deny(action_name).then do

          result = [@__allow_fields, @__deny_fields]

          clear_allow_and_deny

          result
        end
      end

      # Filter fields returns the attributes with any denied or not allowed fields
      # removed based on the current user.
      #
      # Run with Volt.as_user(...) to change the user
      def filtered_attributes
        # Run the read permission check
        allow_and_deny_fields(:read).then do |allow, deny|

          result = nil

          if allow && allow != true && allow.size > 0
            # always keep id
            allow << :id

            # Only keep fields in the allow list
            result = @attributes.select { |key| allow.include?(key) }
          elsif deny == true
            # Only keep id
            # TODO: Should this be a full reject?
            result = @attributes.reject { |key| key != :id }
          elsif deny && deny.size > 0
            # Reject any in the deny list
            result = @attributes.reject { |key| deny.include?(key) }
          else
            result = @attributes
          end

          # Deeply filter any nested models
          result.then do |res|
            keys = []
            values = []
            res.each do |key, value|
              if value.is_a?(Model)
                value = value.filtered_attributes
              end
              keys << key
              values << value
            end

            Promise.when(*values).then do |values|
              keys.zip(values).to_h
            end
          end
        end
      end

      private

      def run_permissions(action_name = nil)
        compute_allow_and_deny(action_name).then do

          errors = {}

          if @__allow_fields == true
            # Allow all fields
          elsif @__allow_fields && @__allow_fields.size > 0
            # Deny all not specified in the allow list
            changed_attributes.keys.each do |field_name|
              unless @__allow_fields.include?(field_name)
                add_error_if_changed(errors, field_name)
              end
            end
          end

          if @__deny_fields == true
            # Don't allow any field changes
            changed_attributes.keys.each do |field_name|
              add_error_if_changed(errors, field_name)
            end
          elsif @__deny_fields
            # Allow all except the denied
            @__deny_fields.each do |field_name|
              add_error_if_changed(errors, field_name) if changed?(field_name)
            end
          end

          clear_allow_and_deny

          errors
        end
      end

      def clear_allow_and_deny
        @__deny_fields = nil
        @__allow_fields = nil
      end

      # Run through the permission blocks for the action name, acumulate
      # all allow/deny fields.
      def compute_allow_and_deny(action_name)
        @__deny_fields = []
        @__allow_fields = []

        # Skip permissions can be run on the server to ignore the permissions
        return if Volt.in_mode?(:skip_permissions)

        # Run the permission blocks
        action_name ||= new? ? :create : :update

        # Run each of the permission blocks for this action
        permissions = self.class.__permissions__
        if permissions && (blocks = permissions[action_name])
          results = blocks.map do |block|
            # Call the block, pass the action name
            instance_exec(action_name, &block)
          end

          # Wait for any promises returned
          Promise.when(*results)
        end
      end

      def add_error_if_changed(errors, field_name)
        if changed?(field_name)
          (errors[field_name] ||= []) << 'can not be changed'
        end
      end
    end
  end
end