Class: ActiveStorageEncryption::EncryptedS3Service

Inherits:
ActiveStorage::Service::S3Service
  • Object
show all
Includes:
PrivateUrlPolicy
Defined in:
lib/active_storage_encryption/encrypted_s3_service.rb

Constant Summary

Constants included from PrivateUrlPolicy

PrivateUrlPolicy::DEFAULT_POLICY

Instance Method Summary collapse

Methods included from PrivateUrlPolicy

#private_url_for_streaming_via_controller, #private_url_policy, #private_url_policy=

Constructor Details

#initialize(public: false, **options_for_s3_service_and_private_url_policy) ⇒ EncryptedS3Service

Returns a new instance of EncryptedS3Service.

Raises:

  • (ArgumentError)


9
10
11
12
# File 'lib/active_storage_encryption/encrypted_s3_service.rb', line 9

def initialize(public: false, **options_for_s3_service_and_private_url_policy)
  raise ArgumentError, "encrypted files cannot be served via a public URL or a CDN" if public
  super
end

Instance Method Details

#compose(source_keys, destination_key, source_encryption_keys:, encryption_key:, filename: nil, content_type: nil, disposition: nil, custom_metadata: {}) ⇒ Object



109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
# File 'lib/active_storage_encryption/encrypted_s3_service.rb', line 109

def compose(source_keys, destination_key, source_encryption_keys:, encryption_key:, filename: nil, content_type: nil, disposition: nil, custom_metadata: {})
  if source_keys.length != source_encryption_keys.length
    raise ArgumentError, "With #{source_keys.length} keys to compose there should be exactly as many source_encryption_keys, but got #{source_encryption_keys.length}"
  end
  content_disposition = content_disposition_with(type: disposition, filename: filename) if disposition && filename
  upload_options_for_compose = upload_options.merge(sse_options(encryption_key))
  object_for(destination_key).upload_stream(
    content_type: content_type,
    content_disposition: content_disposition,
    part_size: MINIMUM_UPLOAD_PART_SIZE,
    metadata: ,
    **upload_options_for_compose
  ) do |s3_multipart_io|
    s3_multipart_io.binmode
    source_keys.zip(source_encryption_keys).each do |(source_key, source_encryption_key)|
      stream(source_key, encryption_key: source_encryption_key) do |chunk|
        s3_multipart_io.write(chunk)
      end
    end
  end
end

#download(key, encryption_key:, &block) ⇒ Object



87
88
89
90
91
92
93
94
95
96
97
98
99
# File 'lib/active_storage_encryption/encrypted_s3_service.rb', line 87

def download(key, encryption_key:, &block)
  if block_given?
    instrument :streaming_download, key: key do
      stream(key, encryption_key: encryption_key, &block)
    end
  else
    instrument :download, key: key do
      object_for(key).get(**sse_options(encryption_key)).body.string.force_encoding(Encoding::BINARY)
    rescue Aws::S3::Errors::NoSuchKey
      raise ActiveStorage::FileNotFoundError
    end
  end
end

#download_chunk(key, range, encryption_key:) ⇒ Object



101
102
103
104
105
106
107
# File 'lib/active_storage_encryption/encrypted_s3_service.rb', line 101

def download_chunk(key, range, encryption_key:)
  instrument :download_chunk, key: key, range: range do
    object_for(key).get(range: "bytes=#{range.begin}-#{range.exclude_end? ? range.end - 1 : range.end}", **sse_options(encryption_key)).body.string.force_encoding(Encoding::BINARY)
  rescue Aws::S3::Errors::NoSuchKey
    raise ActiveStorage::FileNotFoundError
  end
end

#encrypted?Boolean

Returns:

  • (Boolean)


7
# File 'lib/active_storage_encryption/encrypted_s3_service.rb', line 7

def encrypted? = true

#exist?(key) ⇒ Boolean

Returns:

  • (Boolean)


30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
# File 'lib/active_storage_encryption/encrypted_s3_service.rb', line 30

def exist?(key)
  # The stock S3Service uses S3::Object#exists? here. That method does
  # a HEAD request to the S3 bucket under the hood. But there is a problem
  # with that approach: to get all the metadata attributes of an object on S3
  # (which is what the HEAD request should return to you) you need the encryption key.
  # The interface of the ActiveStorage services does not provide for extra arguments
  # for `Service#exist?`, so all we would get using that SDK call would be an error.
  #
  # But we don't need the object metadata - we need to know is whether the object exists
  # at all. And this can be done with a GET request instead. We ask S3 to give us the first byte of the
  # object. S3 will then raise an exception - the exception will be different
  # depending on whether the object does not exist _or_ the object does exist, but
  # is encrypted. We can use the distinction between those exceptions to tell
  # whether the object is there or not.
  #
  # There is also a case where the object is not encrypted - in that situation
  # our single-byte GET request will actually succeed. This also means that the
  # object exists in the bucket.
  object_for(key).get(range: "bytes=0-0")
  # If we get here without an exception - the object exists in the bucket,
  # but is not encrypted. For example, it was stored using a stock S3Service.
  true
rescue Aws::S3::Errors::InvalidRequest
  # With this exception S3 tells us that the object exists but we have to furnish
  # the encryption key (the exception will have a message with "object was stored
  # using a form of Server Side Encryption...").
  true
rescue Aws::S3::Errors::NoSuchKey
  # And this truly means the object is not present
  false
end

#headers_for_direct_upload(key, encryption_key:, **options_for_super) ⇒ Object



20
21
22
23
24
25
26
27
28
# File 'lib/active_storage_encryption/encrypted_s3_service.rb', line 20

def headers_for_direct_upload(key, encryption_key:, **options_for_super)
  # See https://docs.aws.amazon.com/AmazonS3/latest/userguide/ServerSideEncryptionCustomerKeys.html#specifying-s3-c-encryption
  # This is the same as sse_options but expressed with raw header names
  sdk_sse_options = sse_options(encryption_key)
  super(key, **options_for_super).merge!({
    "x-amz-server-side-encryption-customer-key" => Base64.strict_encode64(sdk_sse_options.fetch(:sse_customer_key)),
    "x-amz-server-side-encryption-customer-key-MD5" => Digest::MD5.base64digest(sdk_sse_options.fetch(:sse_customer_key))
  })
end

#headers_for_private_download(key, encryption_key:) ⇒ Object



62
63
64
65
66
67
# File 'lib/active_storage_encryption/encrypted_s3_service.rb', line 62

def headers_for_private_download(key, encryption_key:, **)
  sdk_sse_options = sse_options(encryption_key)
  {
    "x-amz-server-side-encryption-customer-key" => Base64.strict_encode64(sdk_sse_options.fetch(:sse_customer_key))
  }
end

#service_nameObject



14
15
16
17
18
# File 'lib/active_storage_encryption/encrypted_s3_service.rb', line 14

def service_name
  # ActiveStorage::Service::DiskService => Disk
  # Overridden because in Rails 8 this is "self.class.name.split("::").third.remove("Service")"
  self.class.name.split("::").last.remove("Service")
end

#upload(*args, encryption_key:, **kwargs) ⇒ Object



81
82
83
84
85
# File 'lib/active_storage_encryption/encrypted_s3_service.rb', line 81

def upload(*args, encryption_key:, **kwargs)
  with_upload_options_for_customer_key(sse_options(encryption_key)) do
    super(*args, **kwargs)
  end
end

#url_for_direct_upload(key, encryption_key:, **options_for_super) ⇒ Object



69
70
71
72
73
74
75
76
77
78
79
# File 'lib/active_storage_encryption/encrypted_s3_service.rb', line 69

def url_for_direct_upload(key, encryption_key:, **options_for_super)
  # With direct upload we need to remove the encryption key itself from
  # the SDK parameters. Otherwise it does get included in the URL, but that
  # does not make S3 actually _use_ the value - _and_ it leaks the key.
  # We _do_ need the key MD5 to be in the signed header params, so that the client can't use an encryption key
  # it invents by itself - it must use the one we issue it.
  sse_options_without_key = sse_options(encryption_key).without(:sse_customer_key)
  with_upload_options_for_customer_key(sse_options_without_key) do
    super(key, **options_for_super)
  end
end