# frozen_string_literal: true
module Aws
module S3
# @api private
def self.access_grants_credentials_cache
@access_grants_credentials_cache ||= LRUCache.new(max_entries: 100)
end
# @api private
def self.access_grants_account_id_cache
@access_grants_account_id_cache ||= LRUCache.new(
max_entries: 100,
expiration: 60 * 10
)
end
# Returns Credentials class for S3 Access Grants. Accepts GetDataAccess
# params and other configuration as options. See
# {Aws::S3Control::Client#get_data_access} for details.
class AccessGrantsCredentialsProvider
# @param [Hash] options
# @option options [Hash] :s3_control_client_options The S3 Control
# client options used to create regional S3 Control clients to
# create the session. Region will be set to the region of the
# bucket.
# @option options [Aws::STS::Client] :sts_client The STS client used for
# fetching the Account ID for the credentials if credentials do not
# include an Account ID.
# @option options [Aws::S3::Client] :s3_client The S3 client used for
# fetching the location of the bucket so that a regional S3 Control
# client can be created. Defaults to the S3 client from the access
# grants plugin.
# @option options [String] :privilege ('Default') The privilege to use
# when requesting credentials. (see: {Aws::S3Control::Client#get_data_access})
# @option options [Boolean] :fallback (false) When true, if access is
# denied, the provider will fall back to the configured credentials.
# @option options [Boolean] :caching (true) When true, credentials and
# bucket account ids will be cached.
# @option options [Callable] :before_refresh Proc called before
# credentials are refreshed.
def initialize(options = {})
@s3_control_options = options.delete(:s3_control_client_options) || {}
@s3_client = options.delete(:s3_client)
@sts_client = options.delete(:sts_client)
@fallback = options.delete(:fallback) || false
@caching = options.delete(:caching) != false
@s3_control_clients = {}
@bucket_region_cache = Aws::S3.bucket_region_cache
@head_bucket_mutex = Mutex.new
@head_bucket_call = false
return unless @caching
@credentials_cache = Aws::S3.access_grants_credentials_cache
@account_id_cache = Aws::S3.access_grants_account_id_cache
end
def access_grants_credentials_for(options = {})
target = target_prefix(
options[:bucket],
options[:key],
options[:prefix]
)
credentials = s3_client.config.credentials.credentials # resolves
if @caching
cached_credentials_for(target, options[:permission], credentials)
else
new_credentials_for(target, options[:permission], credentials)
end
rescue Aws::S3Control::Errors::AccessDenied
raise unless @fallback
warn 'Access denied for S3 Access Grants. Falling back to ' \
'configured credentials.'
s3_client.config.credentials
end
attr_accessor :s3_client
private
def s3_control_client(bucket_region)
@s3_control_clients[bucket_region] ||= begin
credentials = s3_client.config.credentials
config = { credentials: credentials }.merge(@s3_control_options)
Aws::S3Control::Client.new(config.merge(
region: bucket_region,
use_fips_endpoint: s3_client.config.use_fips_endpoint,
use_dualstack_endpoint: s3_client.config.use_dualstack_endpoint
))
end
end
def cached_credentials_for(target, permission, credentials)
cached_creds = broad_search_credentials_cache_prefix(target, permission, credentials)
return cached_creds if cached_creds
if %w[READ WRITE].include?(permission)
cached_creds = broad_search_credentials_cache_prefix(target, 'READWRITE', credentials)
return cached_creds if cached_creds
end
cached_creds = broad_search_credentials_cache_characters(target, permission, credentials)
return cached_creds if cached_creds
if %w[READ WRITE].include?(permission)
cached_creds = broad_search_credentials_cache_characters(target, 'READWRITE', credentials)
return cached_creds if cached_creds
end
creds = new_credentials_for(target, permission, credentials)
if creds.matched_grant_target.end_with?('*')
# remove /* from the end of the target
key = credentials_cache_key(creds.matched_grant_target[0...-2], permission, credentials)
@credentials_cache[key] = creds
end
creds
end
def broad_search_credentials_cache_prefix(target, permission, credentials)
prefix = target
while prefix != 's3:'
key = credentials_cache_key(prefix, permission, credentials)
return @credentials_cache[key] if @credentials_cache.key?(key)
prefix = prefix.split('/', -1)[0..-2].join('/')
end
nil
end
def broad_search_credentials_cache_characters(target, permission, credentials)
prefix = target
while prefix != 's3://'
key = credentials_cache_key("#{prefix}*", permission, credentials)
return @credentials_cache[key] if @credentials_cache.key?(key)
prefix = prefix[0..-2]
end
nil
end
def new_credentials_for(target, permission, credentials)
bucket_region = bucket_region_for_access_grants(target)
client = s3_control_client(bucket_region)
AccessGrantsCredentials.new(
target: target,
account_id: account_id_for_access_grants(target, credentials),
permission: permission,
client: client
)
end
def account_id_for_access_grants(target, credentials)
if @caching
cached_account_id_for(target, credentials)
else
new_account_id_for(target, credentials)
end
end
def cached_account_id_for(target, credentials)
bucket = bucket_name_from(target)
if @account_id_cache.key?(bucket)
@account_id_cache[bucket]
else
@account_id_cache[bucket] = new_account_id_for(target, credentials)
end
end
# returns the account id associated with the access grants instance
def new_account_id_for(target, credentials)
bucket_region = bucket_region_for_access_grants(target)
s3_control_client = s3_control_client(bucket_region)
resp = s3_control_client.get_access_grants_instance_for_prefix(
s3_prefix: target,
account_id: account_id_for_credentials(bucket_region, credentials)
)
ARNParser.parse(resp.access_grants_instance_arn).account_id
end
def bucket_region_for_access_grants(target)
bucket = bucket_name_from(target)
# regardless of caching option, bucket region cache is always shared
cached_bucket_region_for(bucket)
end
def cached_bucket_region_for(bucket)
if @bucket_region_cache.key?(bucket)
@bucket_region_cache[bucket]
else
@bucket_region_cache[bucket] = new_bucket_region_for(bucket)
end
end
def new_bucket_region_for(bucket)
@head_bucket_mutex.synchronize do
begin
@head_bucket_call = true
@s3_client.head_bucket(bucket: bucket).bucket_region
rescue Aws::S3::Errors::Http301Error => e
e.data.region
ensure
@head_bucket_call = false
end
end
end
# returns the account id for the configured credentials
def account_id_for_credentials(region, credentials)
# use resolved credentials to check for account id
if credentials.respond_to?(:account_id) && credentials.account_id &&
!credentials.account_id.empty?
credentials.account_id
else
@sts_client ||= Aws::STS::Client.new(
credentials: s3_client.config.credentials,
region: region,
use_fips_endpoint: s3_client.config.use_fips_endpoint,
use_dualstack_endpoint: s3_client.config.use_dualstack_endpoint
)
@sts_client.get_caller_identity.account
end
end
def target_prefix(bucket, key, prefix)
if key && !key.empty?
"s3://#{bucket}/#{key}"
elsif prefix && !prefix.empty?
"s3://#{bucket}/#{prefix}"
else
"s3://#{bucket}/*"
end
end
def credentials_cache_key(target, permission, credentials)
"#{credentials.access_key_id}-#{credentials.secret_access_key}" \
"-#{permission}-#{target}"
end
# extracts bucket name from target prefix
def bucket_name_from(target)
URI(target).host
end
end
end
end