# Copyright 2011 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 'aws/inflection'
require 'uuidtools'
require 'date'

module AWS

  # Represents an access policy for AWS operations and resources.  For example:
  #
  #   policy = Policy.new do |policy|
  #     policy.allow(:actions => ['s3:PutObject'],
  #                  :resources => "arn:aws:s3:::mybucket/mykey/*",
  #                  :principals => :any
  #     ).where(:acl).is("public-read")
  #   end
  #
  #   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 = UUIDTools::UUID.timestamp_create.to_s.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?(AWS::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
    # @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

      # @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

      # @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

      # @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 value 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

      # @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

      # @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

      # @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

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

      # @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

      # @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

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

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

      # @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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

      # @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

      # @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

      # 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 = UUIDTools::UUID.timestamp_create.to_s.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

      # @private
      def to_h
        stmt = {
          "Sid" => sid,
          "Effect" => Inflection.class_name(effect.to_s),
          "Principal" => principals_hash,
          "Resource" => resource_arns,
          "Condition" => (conditions.to_h if conditions)
        }
        stmt.delete("Condition") if !conditions || conditions.to_h.empty?
        stmt.delete("Principal") unless principals_hash
        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

      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 == AWS::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

    end

  end

end