Skip to content

Commit

Permalink
Add more operations to Access Grants (#3069)
Browse files Browse the repository at this point in the history
  • Loading branch information
mullermp committed Jul 31, 2024
1 parent 10452d6 commit d313084
Show file tree
Hide file tree
Showing 4 changed files with 134 additions and 7 deletions.
2 changes: 2 additions & 0 deletions gems/aws-sdk-s3/CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -1,6 +1,8 @@
Unreleased Changes
------------------

* Feature - Support `head_bucket`, `get_object_attributes`, `delete_objects`, and `copy_object` for Access Grants.

1.156.0 (2024-07-02)
------------------

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -47,6 +47,8 @@ def initialize(options = {})
@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
Expand Down Expand Up @@ -195,9 +197,16 @@ def cached_bucket_region_for(bucket)
end

def new_bucket_region_for(bucket)
@s3_client.head_bucket(bucket: bucket).bucket_region
rescue Aws::S3::Errors::Http301Error => e
e.data.region
@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
Expand Down
72 changes: 68 additions & 4 deletions gems/aws-sdk-s3/lib/aws-sdk-s3/plugins/access_grants.rb
Original file line number Diff line number Diff line change
Expand Up @@ -44,25 +44,47 @@ class Handler < Seahorse::Client::Handler
list_objects_v2: 'READ',
list_object_versions: 'READ',
list_parts: 'READ',
head_bucket: 'READ',
get_object_attributes: 'READ',
put_object: 'WRITE',
put_object_acl: 'WRITE',
delete_object: 'WRITE',
abort_multipart_upload: 'WRITE',
create_multipart_upload: 'WRITE',
upload_part: 'WRITE',
complete_multipart_upload: 'WRITE'
complete_multipart_upload: 'WRITE',
delete_objects: 'WRITE',
copy_object: 'READWRITE'
}.freeze

def call(context)
provider = context.config.access_grants_credentials_provider

if access_grants_operation?(context) &&
!s3_express_endpoint?(context)
!s3_express_endpoint?(context) &&
!credentials_head_bucket_call?(provider)
params = context[:endpoint_params]
permission = PERMISSION_MAP[context.operation_name]

provider = context.config.access_grants_credentials_provider
key =
case context.operation_name
when :delete_objects
delete_params = context.params[:delete]
common_prefixes(delete_params[:objects].map { |o| o[:key] })
when :copy_object
source_bucket, source_key = params[:copy_source].split('/', 2)
if params[:bucket] != source_bucket
raise ArgumentError,
'source and destination bucket must be the same'
end
common_prefixes([params[:key], source_key])
else
params[:key]
end

credentials = provider.access_grants_credentials_for(
bucket: params[:bucket],
key: params[:key],
key: key,
prefix: params[:prefix],
permission: permission
)
Expand All @@ -80,6 +102,12 @@ def with_metric(credentials, &block)
Aws::Plugins::UserAgent.metric('S3_ACCESS_GRANTS', &block)
end

# HeadBucket is a supported call. When fetching credentials,
# this plugin is executed again, and becomes recursive.
def credentials_head_bucket_call?(provider)
provider.instance_variable_get(:@head_bucket_call)
end

def access_grants_operation?(context)
params = context[:endpoint_params]
params[:bucket] && PERMISSION_MAP[context.operation_name]
Expand All @@ -88,6 +116,42 @@ def access_grants_operation?(context)
def s3_express_endpoint?(context)
context[:endpoint_properties]['backend'] == 'S3Express'
end

# Return the common prefix of the keys, regardless of the delimiter.
# For example, given keys ['foo/bar', 'foo/baz'], the common prefix
# is 'foo/ba'.
def common_prefixes(keys)
return '' if keys.empty?

first_key = keys[0]
common_ancestor = first_key
last_prefix = ''
keys.each do |k|
until common_ancestor.empty?
break if k.start_with?(common_ancestor)

last_index = common_ancestor.rindex('/')
return '' if last_index.nil?

last_prefix = common_ancestor[(last_index + 1)..-1]
common_ancestor = common_ancestor[0...last_index]
end
end
new_common_ancestor = "#{common_ancestor}/#{last_prefix}"
keys.each do |k|
until last_prefix.empty?
break if k.start_with?(new_common_ancestor)

last_prefix = last_prefix[0...-1]
new_common_ancestor = "#{common_ancestor}/#{last_prefix}"
end
end
if new_common_ancestor == "#{first_key}/"
first_key
else
new_common_ancestor
end
end
end

def add_handlers(handlers, config)
Expand Down
52 changes: 52 additions & 0 deletions gems/aws-sdk-s3/spec/plugins/access_grants_spec.rb
Original file line number Diff line number Diff line change
Expand Up @@ -108,6 +108,58 @@ module S3
expect(provider.s3_client).to be_nil
end
end

context 'delete_objects' do
it 'key with no common ancestor' do
keys = %w[A/log.txt B/log.txt C/log.txt]
expect_any_instance_of(Aws::S3::AccessGrantsCredentialsProvider)
.to receive(:access_grants_credentials_for)
.with(bucket: 'bucket', key: '', permission: 'WRITE', prefix: nil)
delete = { objects: keys.map { |key| { key: key } } }
client.delete_objects(bucket: 'bucket', delete: delete)
end

it 'key with root common ancestor' do
keys = %w[A/A/log.txt A/B/log.txt A/C/log.txt]
expect_any_instance_of(Aws::S3::AccessGrantsCredentialsProvider)
.to receive(:access_grants_credentials_for)
.with(bucket: 'bucket', key: 'A/', permission: 'WRITE', prefix: nil)
delete = { objects: keys.map { |key| { key: key } } }
client.delete_objects(bucket: 'bucket', delete: delete)
end

it 'key with level next to root common ancestor' do
keys = %w[A/path12/log.txt A/path34/B/log.txt A/path56/C/log.txt]
expect_any_instance_of(Aws::S3::AccessGrantsCredentialsProvider)
.to receive(:access_grants_credentials_for)
.with(bucket: 'bucket', key: 'A/path', permission: 'WRITE', prefix: nil)
delete = { objects: keys.map { |key| { key: key } } }
client.delete_objects(bucket: 'bucket', delete: delete)
end
end

context 'copy_source' do
it 'key with no common ancestor' do
expect_any_instance_of(Aws::S3::AccessGrantsCredentialsProvider)
.to receive(:access_grants_credentials_for)
.with(bucket: 'bucket', key: '', permission: 'READWRITE', prefix: nil)
client.copy_object(bucket: 'bucket', key: 'A/log.txt', copy_source: 'bucket/B/log.txt')
end

it 'key with root common ancestor' do
expect_any_instance_of(Aws::S3::AccessGrantsCredentialsProvider)
.to receive(:access_grants_credentials_for)
.with(bucket: 'bucket', key: 'A/', permission: 'READWRITE', prefix: nil)
client.copy_object(bucket: 'bucket', key: 'A/A/log.txt', copy_source: 'bucket/A/B/log.txt')
end

it 'key with level next to root common ancestor' do
expect_any_instance_of(Aws::S3::AccessGrantsCredentialsProvider)
.to receive(:access_grants_credentials_for)
.with(bucket: 'bucket', key: 'A/path', permission: 'READWRITE', prefix: nil)
client.copy_object(bucket: 'bucket', key: 'A/path12/log.txt', copy_source: 'bucket/A/path34/log.txt')
end
end
end
end
end

0 comments on commit d313084

Please sign in to comment.