# 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 'uuidtools'
require 'date'
module AWS
module Core
# 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?(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
# @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 == 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
end
end
end
end