# Copyright 2011-2013 Amazon.com, Inc. or its affiliates. All Rights Reserved.
#
# Licensed under the Apache License, Version 2.0 (the "License"). You
# may not use this file except in compliance with the License. A copy of
# the License is located at
#
#     http://aws.amazon.com/apache2.0/
#
# or in the "license" file accompanying this file. This file is
# distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF
# ANY KIND, either express or implied. See the License for the specific
# language governing permissions and limitations under the License.

require 'date'
require 'json'

module AWS
  module Core

    # Represents an access policy for AWS operations and resources.  For example:
    #
    #     policy = Policy.new
    #     policy.allow(
    #       :actions => ['s3:PutObject'],
    #       :resources => "arn:aws:s3:::mybucket/mykey/*",
    #       :principals => :any
    #     ).where(:acl).is("public-read")
    #
    #     policy.to_json # => '{ "Version":"2008-10-17", ...'
    #
    # @see #initialize More ways to construct a policy.
    # @see http://docs.amazonwebservices.com/AmazonS3/latest/dev/AccessPolicyLanguage_UseCases_s3_a.html Example policies (in JSON).
    class Policy

      # @see Statement
      # @return [Array] An array of policy statements.
      attr_reader :statements

      # @return [String] The version of the policy language used in this
      #   policy object.
      attr_reader :version

      # @return [String] A unique ID for the policy.
      attr_reader :id

      class Statement; end

      # Constructs a policy.  There are a few different ways to
      # build a policy:
      #
      # * With hash arguments:
      #
      #     Policy.new(:statements => [
      #       {
      #         :effect => :allow,
      #         :actions => :all,
      #         :principals => ["abc123"],
      #         :resources => "mybucket/mykey"
      #       }
      #     ])
      #
      # * From a JSON policy document:
      #
      #     Policy.from_json(policy_json_string)
      #
      # * With a block:
      #
      #     Policy.new do |policy|
      #       policy.allow(
      #         :actions => ['s3:PutObject'],
      #         :resources => "arn:aws:s3:::mybucket/mykey/*",
      #         :principals => :any
      #       ).where(:acl).is("public-read")
      #     end
      #
      def initialize(opts = {})
        @statements = opts.values_at(:statements, "Statement").select do |a|
          a.kind_of?(Array)
        end.flatten.map do |stmt|
          self.class::Statement.new(stmt)
        end

        if opts.has_key?(:id) or opts.has_key?("Id")
          @id = opts[:id] || opts["Id"]
        else
          @id = SecureRandom.uuid.tr('-','')
        end
        if opts.has_key?(:version) or opts.has_key?("Version")
          @version = opts[:version] || opts["Version"]
        else
          @version = "2008-10-17"
        end

        yield(self) if block_given?
      end

      # @return [Boolean] Returns true if the two policies are the same.
      def ==(other)
        if other.kind_of?(Core::Policy)
          self.hash_without_ids == other.hash_without_ids
        else
          false
        end
      end
      alias_method :eql?, :==

      # Removes the ids from the policy and its statements for the purpose
      # of comparing two policies for equivilence.
      # @return [Hash] Returns the policy as a hash with no ids
      # @api private
      def hash_without_ids
        hash = self.to_h
        hash.delete('Id')
        hash['Statement'].each do |statement|
          statement.delete('Sid')
        end
        hash
      end
      protected :hash_without_ids

      # Returns a hash representation of the policy. The following
      # statements are equivalent:
      #
      #     policy.to_h.to_json
      #     policy.to_json
      #
      # @return [Hash]
      def to_h
        {
          "Version" => version,
          "Id" => id,
          "Statement" => statements.map { |st| st.to_h }
        }
      end

      # @return [String] a JSON representation of the policy.
      def to_json
        to_h.to_json
      end

      # Constructs a policy from a JSON representation.
      # @see #initialize
      # @return [Policy] Returns a Policy object constructed by parsing
      #   the passed JSON policy.
      def self.from_json(json)
        new(JSON.parse(json))
      end

      # Convenient syntax for expressing operators in statement
      # condition blocks.  For example, the following:
      #
      #     policy.allow.where(:s3_prefix).not("forbidden").
      #       where(:current_time).lte(Date.today+1)
      #
      # is equivalent to:
      #
      #     conditions = Policy::ConditionBlock.new
      #     conditions.add(:not, :s3_prefix, "forbidden")
      #     conditions.add(:lte, :current_time, Date.today+1)
      #     policy.allow(:conditions => conditions)
      #
      # @see ConditionBlock#add
      class OperatorBuilder

        # @api private
        def initialize(condition_builder, key)
          @condition_builder = condition_builder
          @key = key
        end

        def method_missing(m, *values)
          @condition_builder.conditions.add(m, @key, *values)
          @condition_builder
        end

      end

      # Convenient syntax for adding conditions to a statement.
      # @see Policy#allow
      # @see Policy#deny
      class ConditionBuilder

        # @return [Array] Returns an array of policy conditions.
        attr_reader :conditions

        # @api private
        def initialize(conditions)
          @conditions = conditions
        end

        # Adds a condition for the given key.  For example:
        #
        #     policy.allow(...).where(:current_time).lte(Date.today + 1)
        #
        # @return [OperatorBuilder]
        def where(key, operator = nil, *values)
          if operator
            @conditions.add(operator, key, *values)
            self
          else
            OperatorBuilder.new(self, key)
          end
        end

      end

      # Convenience method for constructing a new statement with the
      # "Allow" effect and adding it to the policy.  For example:
      #
      #     policy.allow(
      #       :actions => [:put_object],
      #       :principals => :any,
      #       :resources => "mybucket/mykey/*").
      #     where(:acl).is("public-read")
      #
      # @option (see Statement#initialize)
      # @see Statement#initialize
      # @return [ConditionBuilder]
      def allow(opts = {})
        stmt = self.class::Statement.new(opts.merge(:effect => :allow))
        statements << stmt
        ConditionBuilder.new(stmt.conditions)
      end

      # Convenience method for constructing a new statement with the
      # "Deny" effect and adding it to the policy.  For example:
      #
      #     policy.deny(
      #       :actions => [:put_object],
      #       :principals => :any,
      #       :resources => "mybucket/mykey/*"
      #     ).where(:acl).is("public-read")
      #
      # @param (see Statement#initialize)
      # @see Statement#initialize
      # @return [ConditionBuilder]
      def deny(opts = {})
        stmt = self.class::Statement.new(opts.merge(:effect => :deny))
        statements << stmt
        ConditionBuilder.new(stmt.conditions)
      end

      # Represents the condition block of a policy.  In JSON,
      # condition blocks look like this:
      #
      #     { "StringLike": { "s3:prefix": ["photos/*", "photos.html"] } }
      #
      # ConditionBlock lets you specify conditions like the above
      # example using the add method, for example:
      #
      #     conditions.add(:like, :s3_prefix, "photos/*", "photos.html")
      #
      # See the add method documentation for more details about how
      # to specify keys and operators.
      #
      # This class also provides a convenient way to query a
      # condition block to see what operators, keys, and values it
      # has.  For example, consider the following condition block
      # (in JSON):
      #
      #   {
      #     "StringEquals": {
      #       "s3:prefix": "photos/index.html"
      #     },
      #     "DateEquals": {
      #       "aws:CurrentTime": ["2010-10-12", "2011-01-02"]
      #     },
      #     "NumericEquals": {
      #       "s3:max-keys": 10
      #     }
      #   }
      #
      # You can get access to the condition data using #[], #keys,
      # #operators, and #values -- for example:
      #
      #   conditions["DateEquals"]["aws:CurrentTime"].values
      #     # => ["2010-10-12", "2011-01-02"]
      #
      # You can also perform more sophisticated queries, like this
      # one:
      #
      #   conditions[:is].each do |equality_conditions|
      #     equality_conditions.keys.each do |key|
      #       puts("#{key} may be any of: " +
      #            equality_conditions[key].values.join(" ")
      #     end
      #   end
      #
      # This would print the following lines:
      #
      #   s3:prefix may be any of: photos/index.html
      #   aws:CurrentTime may be any of: 2010-10-12 2011-01-02
      #   s3:max-keys may be any of: 10
      #
      class ConditionBlock

        # @api private
        def initialize(conditions = {})
          # filter makes a copy
          @conditions = filter_conditions(conditions)
        end

        # Adds a condition to the block.  This method defines a
        # convenient set of abbreviations for operators based on the
        # type of value passed in.  For example:
        #
        #   conditions.add(:is, :secure_transport, true)
        #
        # Maps to:
        #
        #   { "Bool": { "aws:SecureTransport": true } }
        #
        # While:
        #
        #   conditions.add(:is, :s3_prefix, "photos/")
        #
        # Maps to:
        #
        #   { "StringEquals": { "s3:prefix": "photos/" } }
        #
        # The following list shows which operators are accepted as
        # symbols and how they are represented in the JSON policy:
        #
        # * `:is` (StringEquals, NumericEquals, DateEquals, or Bool)
        # * `:like` (StringLike)
        # * `:not_like` (StringNotLike)
        # * `:not` (StringNotEquals, NumericNotEquals, or DateNotEquals)
        # * `:greater_than`, `:gt` (NumericGreaterThan or DateGreaterThan)
        # * `:greater_than_equals`, `:gte`
        #   (NumericGreaterThanEquals or DateGreaterThanEquals)
        # * `:less_than`, `:lt` (NumericLessThan or DateLessThan)
        # * `:less_than_equals`, `:lte`
        #   (NumericLessThanEquals or DateLessThanEquals)
        # * `:is_ip_address` (IpAddress)
        # * `:not_ip_address` (NotIpAddress)
        # * `:is_arn` (ArnEquals)
        # * `:not_arn` (ArnNotEquals)
        # * `:is_arn_like` (ArnLike)
        # * `:not_arn_like` (ArnNotLike)
        #
        # @param [Symbol or String] operator The operator used to
        #   compare the key with the value.  See above for valid
        #   values and their interpretations.
        #
        # @param [Symbol or String] key The key to compare.  Symbol
        #   keys are inflected to match AWS conventions.  By
        #   default, the key is assumed to be in the "aws"
        #   namespace, but if you prefix the symbol name with "s3_"
        #   it will be sent in the "s3" namespace.  For example,
        #   `:s3_prefix` is sent as "s3:prefix" while
        #   `:secure_transport` is sent as "aws:SecureTransport".
        #   See
        #   http://docs.amazonwebservices.com/AmazonS3/latest/dev/UsingResOpsConditions.html
        #   for a list of the available keys for each action in S3.
        #
        # @param [Mixed] values The value to compare against.
        #   This can be:
        #   * a String
        #   * a number
        #   * a Date, DateTime, or Time
        #   * a boolean value
        #   This method does not attempt to validate that the values
        #   are valid for the operators or keys they are used with.
        #
        def add(operator, key, *values)
          if operator.kind_of?(Symbol)
            converted_values = values.map { |v| convert_value(v) }
          else
            converted_values = values
          end
          operator = translate_operator(operator, values.first)
          op = (@conditions[operator] ||= {})
          raise "duplicate #{operator} conditions for #{key}" if op[key]
          op[translate_key(key)] = converted_values
        end

        # @api private
        def to_h
          @conditions
        end

        # Filters the conditions described in the block, returning a
        # new ConditionBlock that contains only the matching
        # conditions.  Each argument is matched against either the
        # keys or the operators in the block, and you can specify
        # the key or operator in any way that's valid for the #add
        # method.  Some examples:
        #
        #   # all conditions using the StringLike operator
        #   conditions["StringLike"]
        #
        #   # all conditions using StringEquals, DateEquals, NumericEquals, or Bool
        #   conditions[:is]
        #
        #   # all conditions on the s3:prefix key
        #   conditions["s3:prefix"]
        #
        #   # all conditions on the aws:CurrentTime key
        #   conditions[:current_time]
        #
        # Multiple conditions are ANDed together, so the following
        # are equivalent:
        #
        #   conditions[:s3_prefix][:is]
        #   conditions[:is][:s3_prefix]
        #   conditions[:s3_prefix, :is]
        #
        # @see #add
        # @return [ConditionBlock] A new set of conditions filtered by the
        #   given conditions.
        def [](*args)
          filtered = @conditions
          args.each do |filter|
            type = valid_operator?(filter) ? nil : :key
            filtered = filter_conditions(filtered) do |op, key, value|
              (match, type) = match_triple(filter, type, op, key, value)
              match
            end
          end
          self.class.new(filtered)
        end

        # @return [Array] Returns an array of operators used in this block.
        def operators
          @conditions.keys
        end

        # @return [Array] Returns an array of unique keys used in the block.
        def keys
          @conditions.values.map do |keys|
            keys.keys if keys
          end.compact.flatten.uniq
        end

        # Returns all values used in the block.  Note that the
        # values may not all be from the same condition; for example:
        #
        #   conditions.add(:like, :user_agent, "mozilla", "explorer")
        #   conditions.add(:lt, :s3_max_keys, 12)
        #   conditions.values # => ["mozilla", "explorer", 12]
        #
        # @return [Array] Returns an array of values used in this condition block.
        def values
          @conditions.values.map do |keys|
            keys.values
          end.compact.flatten
        end

        # @api private
        protected
        def match_triple(filter, type, op, key, value)
          value = [value].flatten.first
          if type
            target = (type == :operator ? op : key)
            match = send("match_#{type}", filter, target, value)
          else
            if match_operator(filter, op, value)
              match = true
              type = :operator
            elsif match_key(filter, key)
              match = true
              type = :key
            else
              match = false
            end
          end
          [match, type]
        end

        # @api private
        protected
        def match_operator(filter, op, value)
          # dates are the only values that don't come back as native types in JSON
          # but where we use the type as a cue to the operator translation
          value = Date.today if op =~ /^Date/
          translate_operator(filter, value) == op
        end

        # @api private
        protected
        def match_key(filter, key, value = nil)
          translate_key(filter) == key
        end

        # @api private
        protected
        def filter_conditions(conditions = @conditions)
          conditions.inject({}) do |m, (op, keys)|
            m[op] = keys.inject({}) do |m2, (key, value)|
              m2[key] = value if !block_given? or yield(op, key, value)
              m2
            end
            m.delete(op) if m[op].empty?
            m
          end
        end

        # @api private
        protected
        def translate_key(key)
          if key.kind_of?(Symbol)
            if key.to_s =~ /^s3_(.*)$/
              s3_name = $1
              if s3_name == "version_id" or
                  s3_name == "location_constraint"
                s3_name = Inflection.class_name(s3_name)
              else
                s3_name.tr!('_', '-')
              end
              "s3:#{s3_name}"
            else
              "aws:#{Inflection.class_name(key.to_s)}"
            end
          else
            key
          end
        end

        # @api private
        MODIFIERS = {
          /_ignoring_case$/ => "IgnoreCase",
          /_equals$/ => "Equals"
        }

        # @api private
        protected
        def valid_operator?(operator)
          translate_operator(operator, "")
          true
        rescue ArgumentError => e
          false
        end

        # @api private
        protected
        def translate_operator(operator, example_value)
          return operator if operator.kind_of?(String)

          original_operator = operator
          (operator, opts) = strip_modifiers(operator)

          raise ArgumentError.new("unrecognized operator #{original_operator}") unless
            respond_to?("translate_#{operator}", true)
          send("translate_#{operator}", example_value, opts)
        end

        # @api private
        protected
        def translate_is(example, opts)
          return "Bool" if type_notation(example) == "Bool"
          base_translate(example, "Equals", opts[:ignore_case])
        end

        # @api private
        protected
        def translate_not(example, opts)
          base_translate(example, "NotEquals", opts[:ignore_case])
        end

        # @api private
        protected
        def translate_like(example, opts)
          base_translate(example, "Like")
        end

        # @api private
        protected
        def translate_not_like(example, opts)
          base_translate(example, "NotLike")
        end

        # @api private
        protected
        def translate_less_than(example, opts)
          base_translate(example, "LessThan", opts[:equals])
        end
        alias_method :translate_lt, :translate_less_than

        # @api private
        protected
        def translate_lte(example, opts)
          translate_less_than(example, { :equals => "Equals" })
        end

        # @api private
        protected
        def translate_greater_than(example, opts)
          base_translate(example, "GreaterThan", opts[:equals])
        end
        alias_method :translate_gt, :translate_greater_than

        # @api private
        protected
        def translate_gte(example, opts)
          translate_greater_than(example, { :equals => "Equals" })
        end

        # @api private
        protected
        def translate_is_ip_address(example, opts)
          "IpAddress"
        end

        # @api private
        protected
        def translate_not_ip_address(example, opts)
          "NotIpAddress"
        end

        # @api private
        protected
        def translate_is_arn(example, opts)
          "ArnEquals"
        end

        # @api private
        protected
        def translate_not_arn(example, opts)
          "ArnNotEquals"
        end

        # @api private
        protected
        def translate_is_arn_like(example, opts)
          "ArnLike"
        end

        # @api private
        protected
        def translate_not_arn_like(example, opts)
          "ArnNotLike"
        end

        # @api private
        protected
        def base_translate(example, base_operator, *modifiers)
          "#{type_notation(example)}#{base_operator}#{modifiers.join}"
        end

        # @api private
        protected
        def type_notation(example)
          case example
          when String
            "String"
          when Numeric
            "Numeric"
          when Time, Date
            "Date"
          when true, false
            "Bool"
          end
        end

        # @api private
        protected
        def convert_value(value)
          case value
          when DateTime, Time
            Time.parse(value.to_s).iso8601
          when Date
            value.strftime("%Y-%m-%d")
          else
            value
          end
        end

        # @api private
        protected
        def strip_modifiers(operator)
          opts = {}
          MODIFIERS.each do |(regex, mod)|
            ruby_name = Inflection.ruby_name(mod).to_sym
            opts[ruby_name] = ""
            if operator.to_s =~ regex
              opts[ruby_name] = mod
              operator = operator.to_s.sub(regex, '').to_sym
            end
          end
          [operator, opts]
        end

      end

      # Represents a statement in a policy.
      #
      # @see Policy#allow
      # @see Policy#deny
      class Statement

        # @return [String] Returns the statement id
        attr_accessor :sid

        # @return [String] Returns the statement effect, either "Allow" or
        #   "Deny"
        attr_accessor :effect

        # @return [Array] Returns an array of principals.
        attr_accessor :principals

        # @return [Array] Returns an array of statement actions included
        #   by this policy statement.
        attr_accessor :actions

        # @return [Array] Returns an array of actions excluded by this
        #   policy statement.
        attr_accessor :excluded_actions

        # @return [Array] Returns an array of resources affected by this
        #   policy statement.
        attr_accessor :resources

        # @return [Array] Returns an array of conditions for this policy.
        attr_accessor :conditions

        attr_accessor :excluded_resources

        # Constructs a new statement.
        #
        # @option opts [String] :sid The statement ID.  This is optional; if
        #   omitted, a UUID will be generated for the statement.
        # @option opts [String] :effect The statement effect, which must be either
        #   "Allow" or "Deny".
        #   @see Policy#allow
        #   @see Policy#deny
        # @option opts [String or array of strings] :principals The account(s)
        #   affected by the statement.  These should be AWS account IDs.
        # @option opts :actions The action or actions affected by
        #   the statement.  These can be symbols or strings.  If
        #   they are strings, you can use wildcard character "*"
        #   to match zero or more characters in the action name.
        #   Symbols are expected to match methods of S3::Client.
        # @option opts :excluded_actions Action or actions which are
        #   explicitly not affected by this statement.  As with
        #   `:actions`, these may be symbols or strings.
        # @option opts [String or array of strings] :resources The
        #   resource(s) affected by the statement.  These can be
        #   expressed as ARNs (e.g. `arn:aws:s3:::mybucket/mykey`)
        #   or you may omit the `arn:aws:s3:::` prefix and just give
        #   the path as `bucket_name/key`.  You may use the wildcard
        #   character "*" to match zero or more characters in the
        #   resource name.
        # @option opts [ConditionBlock or Hash] :conditions
        #   Additional conditions that narrow the effect of the
        #   statement.  It's typically more convenient to use the
        #   ConditionBuilder instance returned from Policy#allow or
        #   Policy#deny to add conditions to a statement.
        # @see S3::Client
        def initialize(opts = {})
          self.sid = SecureRandom.uuid.tr('-','')
          self.conditions = ConditionBlock.new

          parse_options(opts)

          yield(self) if block_given?
        end

        # Convenience method to add to the list of actions affected
        # by this statement.
        def include_actions(*actions)
          self.actions ||= []
          self.actions.push(*actions)
        end
        alias_method :include_action, :include_actions

        # Convenience method to add to the list of actions
        # explicitly not affected by this statement.
        def exclude_actions(*actions)
          self.excluded_actions ||= []
          self.excluded_actions.push(*actions)
        end
        alias_method :exclude_action, :exclude_actions

        # @api private
        def to_h
          stmt = {
            "Sid" => sid,
            "Effect" => Inflection.class_name(effect.to_s),
            "Principal" => principals_hash,
            "Resource" => (resource_arns if resource_arns),
            "NotResource" => (excluded_resource_arns if excluded_resource_arns),
            "Condition" => (conditions.to_h if conditions)
          }
          stmt.delete("Condition") if !conditions || conditions.to_h.empty?
          stmt.delete("Principal") unless principals_hash
          stmt.delete("Resource") unless resource_arns
          stmt.delete("NotResource") unless excluded_resource_arns
          if !translated_actions || translated_actions.empty?
            stmt["NotAction"] = translated_excluded_actions
          else
            stmt["Action"] = translated_actions
          end
          stmt
        end

        protected
        def parse_options(options)
          options.each do |name, value|
            name = Inflection.ruby_name(name.to_s)
            name.sub!(/s$/,'')
            send("parse_#{name}_option", value) if
              respond_to?("parse_#{name}_option", true)
          end
        end

        protected
        def parse_effect_option(value)
          self.effect = value
        end

        protected
        def parse_sid_option(value)
          self.sid = value
        end

        protected
        def parse_action_option(value)
          coerce_array_option(:actions, value)
        end

        protected
        def parse_not_action_option(value)
          coerce_array_option(:excluded_actions, value)
        end
        alias_method :parse_excluded_action_option, :parse_not_action_option

        protected
        def parse_principal_option(value)
          if value and value.kind_of?(Hash)
            value = value["AWS"] || []
          end

          coerce_array_option(:principals, value)
        end

        protected
        def parse_resource_option(value)
          coerce_array_option(:resources, value)
        end

        def parse_not_resource_option(value)
          coerce_array_option(:excluded_resources, value)
        end
        alias_method :parse_excluded_resource_option, :parse_not_resource_option

        protected
        def parse_condition_option(value)
          self.conditions = ConditionBlock.new(value)
        end

        protected
        def coerce_array_option(attr, value)
          if value.kind_of?(Array)
            send("#{attr}=", value)
          else
            send("#{attr}=", [value])
          end
        end

        protected
        def principals_hash
          return nil unless principals
          { "AWS" =>
            principals.map do |principal|
              principal == :any ? "*" : principal
            end }
        end

        protected
        def translate_action(action)
          case action
          when String then action
          when :any   then '*'
          when Symbol

            if self.class == Core::Policy::Statement
              msg = 'symbolized action names are only accepted by service ' +
              'specific policies (e.g. AWS::S3::Policy)'
              raise ArgumentError, msg
            end

            unless self.class::ACTION_MAPPING.has_key?(action)
              raise ArgumentError, "unrecognized action: #{action}"
            end

            self.class::ACTION_MAPPING[action]

          end
        end

        protected
        def translated_actions
          return nil unless actions
          actions.map do |action|
            translate_action(action)
          end
        end

        protected
        def translated_excluded_actions
          return nil unless excluded_actions
          excluded_actions.map { |a| translate_action(a) }
        end

        protected
        def resource_arns
          return nil unless resources
          resources.map do |resource|
            case resource
            when :any    then "*"
            else resource_arn(resource)
            end
          end
        end

        protected
        def resource_arn resource
          resource.to_s
        end

        protected
        def excluded_resource_arns
          return nil unless excluded_resources
          excluded_resources.map do |excluded_resource|
            case excluded_resource
            when :any    then "*"
            else excluded_resource_arn(excluded_resource)
            end
          end
        end

        protected
        def excluded_resource_arn excluded_resource
          excluded_resource.to_s
        end

      end

    end
  end
end
