lib/aws-sdk-s3/plugins/access_grants.rb



# frozen_string_literal: true

module Aws
  module S3
    module Plugins
      # @api private
      class AccessGrants < Seahorse::Client::Plugin
        @s3control =
          begin
            require 'aws-sdk-s3control'
            true
          rescue LoadError
            false
          end

        option(
          :access_grants,
          default: false,
          doc_type: 'Boolean',
          docstring: <<-DOCS)
When `true`, the S3 client will use the S3 Access Grants feature to
authenticate requests. Bucket credentials will be fetched from S3
Control using the `get_data_access` API.
          DOCS

        option(:access_grants_credentials_provider,
          doc_type: 'Aws::S3::AccessGrantsCredentialsProvider',
          rbs_type: 'untyped',
          docstring: <<-DOCS) do |_cfg|
When `access_grants` is `true`, this option can be used to provide
additional options to the credentials provider, including a privilege
setting, caching, and fallback behavior.
          DOCS
          Aws::S3::AccessGrantsCredentialsProvider.new
        end

        # @api private
        class Handler < Seahorse::Client::Handler
          PERMISSION_MAP = {
            head_object: 'READ',
            get_object: 'READ',
            get_object_acl: 'READ',
            list_multipart_uploads: 'READ',
            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',
            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) &&
               !credentials_head_bucket_call?(provider)
              params = context[:endpoint_params]
              permission = PERMISSION_MAP[context.operation_name]

              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: key,
                prefix: params[:prefix],
                permission: permission
              )
              context[:sigv4_credentials] = credentials # Sign will use this
            end

            with_metric(credentials) { @handler.call(context) }
          end

          private

          def with_metric(credentials, &block)
            return block.call unless credentials

            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]
          end

          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)
          return unless AccessGrants.s3control? && config.access_grants

          handlers.add(Handler)
        end

        def after_initialize(client)
          return unless AccessGrants.s3control? && client.config.access_grants

          provider = client.config.access_grants_credentials_provider
          provider.s3_client = client unless provider.s3_client
        end

        class << self
          def s3control?
            @s3control
          end
        end
      end
    end
  end
end