gitlab-fog-azure-rm 2.5.0 → 2.5.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (35) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +9 -0
  3. data/lib/fog/azurerm/identity/base_client.rb +14 -4
  4. data/lib/fog/azurerm/requests/storage/acquire_blob_lease.rb +1 -1
  5. data/lib/fog/azurerm/requests/storage/acquire_container_lease.rb +1 -1
  6. data/lib/fog/azurerm/requests/storage/commit_blob_blocks.rb +1 -1
  7. data/lib/fog/azurerm/requests/storage/copy_blob.rb +1 -1
  8. data/lib/fog/azurerm/requests/storage/copy_blob_from_uri.rb +1 -1
  9. data/lib/fog/azurerm/requests/storage/create_block_blob.rb +1 -1
  10. data/lib/fog/azurerm/requests/storage/create_container.rb +1 -1
  11. data/lib/fog/azurerm/requests/storage/create_page_blob.rb +1 -1
  12. data/lib/fog/azurerm/requests/storage/delete_blob.rb +1 -1
  13. data/lib/fog/azurerm/requests/storage/delete_container.rb +1 -1
  14. data/lib/fog/azurerm/requests/storage/get_blob.rb +3 -3
  15. data/lib/fog/azurerm/requests/storage/get_blob_properties.rb +1 -1
  16. data/lib/fog/azurerm/requests/storage/get_container_acl.rb +1 -1
  17. data/lib/fog/azurerm/requests/storage/get_container_properties.rb +1 -1
  18. data/lib/fog/azurerm/requests/storage/list_blobs.rb +1 -1
  19. data/lib/fog/azurerm/requests/storage/list_containers.rb +1 -1
  20. data/lib/fog/azurerm/requests/storage/put_blob_block.rb +1 -1
  21. data/lib/fog/azurerm/requests/storage/put_blob_metadata.rb +1 -1
  22. data/lib/fog/azurerm/requests/storage/put_blob_pages.rb +1 -1
  23. data/lib/fog/azurerm/requests/storage/put_blob_properties.rb +1 -1
  24. data/lib/fog/azurerm/requests/storage/put_container_acl.rb +1 -1
  25. data/lib/fog/azurerm/requests/storage/put_container_metadata.rb +1 -1
  26. data/lib/fog/azurerm/requests/storage/release_blob_lease.rb +1 -1
  27. data/lib/fog/azurerm/requests/storage/release_container_lease.rb +1 -1
  28. data/lib/fog/azurerm/storage.rb +134 -47
  29. data/lib/fog/azurerm/version.rb +1 -1
  30. data/test/requests/storage/test_credential_refresh_on_blob_requests.rb +85 -0
  31. data/test/requests/storage/test_get_blob_https_url.rb +54 -48
  32. data/test/requests/storage/test_storage_thread_safety.rb +138 -0
  33. data/test/test_helper.rb +15 -0
  34. data/test/unit/test_workflow_identity_client.rb +30 -0
  35. metadata +4 -2
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: f3c8efd2c5ce76c59fe96a72faceb7470f8a1bc0e75ffdef6808434583108cdf
4
- data.tar.gz: 72fce32424077553bb8c4b6e8a1a6ffa56307261352a801355b1a93878628715
3
+ metadata.gz: 82912caef21af9f05608fd07eb07dd1c1a5d2693a805562a33448997f7897177
4
+ data.tar.gz: 245ab6fc1a0f8f64d775872c622696debcd1594cb72e75d7a533eef689d19e27
5
5
  SHA512:
6
- metadata.gz: 6cf9691fa2a0423b6da2f5b0874447364207d0a1e10852a11f3e14cd5fc213fc20a8de8987263e70d0f04ac59d5c044779ced3c8bb8451d13622c608f1e73f29
7
- data.tar.gz: e92ddc401cdf2b5e606afd6342ef6a136e7d6dae56590fa27338cdfb42dd6a6b2f563f1cde3290274182e3d1370355cc94aefeb1b67261bb555ff031df631b8d
6
+ metadata.gz: a28a7bc76b170937760e54e276386b6b555077ad01d5b2e0259841c70d93247904b94ea899ad2ecbde12799bdc4786091879919695b00dbe4d83977678a82172
7
+ data.tar.gz: deb8fdf6983ae0e8ca2350cf35c4ca334e08cfd1779c1fd463fee5f6c338ed1db364ecae5b05f3b4b9816409d466c8115faa1ba6462f737bece557ad7756c0c6
data/CHANGELOG.md CHANGED
@@ -1,5 +1,14 @@
1
1
  ## Unreleased
2
2
 
3
+ ## 2.5.2
4
+
5
+ - Refresh credentials on all blob storage requests !67
6
+ - Parse expires_in from OAuth token responses so token expiry is honored !67
7
+
8
+ ## 2.5.1
9
+
10
+ - Fix thread-safety race in SAS signer credential refresh !64
11
+
3
12
  ## 2.5.0
4
13
 
5
14
  - Support Ruby 4 !61
@@ -43,12 +43,22 @@ module Fog
43
43
 
44
44
  return unless access_token
45
45
 
46
- expires_at = ::Time.now
46
+ Credentials.new(access_token, token_expires_at(body))
47
+ rescue ::JSON::ParserError # rubocop:disable Lint/SuppressedException
48
+ end
49
+
50
+ # IMDS returns an absolute expires_on (epoch seconds), while the OAuth
51
+ # v2.0 token endpoint used by workload identity returns a relative
52
+ # expires_in. Handle both so refresh_needed? has a real expiry; otherwise
53
+ # it defaults to now and forces a token fetch on every request.
54
+ def token_expires_at(body)
47
55
  expires_on = body['expires_on']
48
- expires_at = ::Time.at(expires_on.to_i) if expires_on
56
+ return ::Time.at(expires_on.to_i) if expires_on
49
57
 
50
- Credentials.new(access_token, expires_at)
51
- rescue ::JSON::ParserError # rubocop:disable Lint/SuppressedException
58
+ expires_in = body['expires_in']
59
+ return ::Time.now + expires_in.to_i if expires_in
60
+
61
+ ::Time.now
52
62
  end
53
63
 
54
64
  private
@@ -9,7 +9,7 @@ module Fog
9
9
  Fog::Logger.debug msg
10
10
 
11
11
  begin
12
- lease_id = @blob_client.acquire_blob_lease(container_name, name, options)
12
+ lease_id = blob_client.acquire_blob_lease(container_name, name, options)
13
13
  rescue Azure::Core::Http::HTTPError => ex
14
14
  raise_azure_exception(ex, msg)
15
15
  end
@@ -9,7 +9,7 @@ module Fog
9
9
  Fog::Logger.debug msg
10
10
 
11
11
  begin
12
- lease_id = @blob_client.acquire_container_lease(name, options)
12
+ lease_id = blob_client.acquire_container_lease(name, options)
13
13
  rescue Azure::Core::Http::HTTPError => ex
14
14
  raise_azure_exception(ex, msg)
15
15
  end
@@ -9,7 +9,7 @@ module Fog
9
9
  Fog::Logger.debug msg
10
10
 
11
11
  begin
12
- @blob_client.commit_blob_blocks(container_name, blob_name, blocks, options)
12
+ blob_client.commit_blob_blocks(container_name, blob_name, blocks, options)
13
13
  rescue Azure::Core::Http::HTTPError => ex
14
14
  raise_azure_exception(ex, msg)
15
15
  end
@@ -9,7 +9,7 @@ module Fog
9
9
  Fog::Logger.debug msg
10
10
 
11
11
  begin
12
- copy_id, copy_status = @blob_client.copy_blob(destination_container, destination_blob, source_container, source_blob, options)
12
+ copy_id, copy_status = blob_client.copy_blob(destination_container, destination_blob, source_container, source_blob, options)
13
13
  rescue Azure::Core::Http::HTTPError => ex
14
14
  raise_azure_exception(ex, msg)
15
15
  end
@@ -9,7 +9,7 @@ module Fog
9
9
  Fog::Logger.debug msg
10
10
 
11
11
  begin
12
- copy_id, copy_status = @blob_client.copy_blob_from_uri(destination_container, destination_blob, source_blob_uri, options)
12
+ copy_id, copy_status = blob_client.copy_blob_from_uri(destination_container, destination_blob, source_blob_uri, options)
13
13
  rescue Azure::Core::Http::HTTPError => ex
14
14
  raise_azure_exception(ex, msg)
15
15
  end
@@ -8,7 +8,7 @@ module Fog
8
8
  msg = "create_block_blob #{blob_name} to the container #{container_name}. options: #{options}"
9
9
  Fog::Logger.debug msg
10
10
 
11
- @blob_client.create_block_blob(container_name, blob_name, body, options)
11
+ blob_client.create_block_blob(container_name, blob_name, body, options)
12
12
  rescue Azure::Core::Http::HTTPError => ex
13
13
  raise_azure_exception(ex, msg)
14
14
  end
@@ -9,7 +9,7 @@ module Fog
9
9
  Fog::Logger.debug msg
10
10
 
11
11
  begin
12
- container = @blob_client.create_container(name, options)
12
+ container = blob_client.create_container(name, options)
13
13
  rescue Azure::Core::Http::HTTPError => ex
14
14
  raise_azure_exception(ex, msg)
15
15
  end
@@ -9,7 +9,7 @@ module Fog
9
9
  Fog::Logger.debug msg
10
10
 
11
11
  begin
12
- @blob_client.create_page_blob(container_name, blob_name, blob_size, options)
12
+ blob_client.create_page_blob(container_name, blob_name, blob_size, options)
13
13
  rescue Azure::Core::Http::HTTPError => ex
14
14
  raise_azure_exception(ex, msg)
15
15
  end
@@ -9,7 +9,7 @@ module Fog
9
9
  Fog::Logger.debug msg
10
10
 
11
11
  begin
12
- @blob_client.delete_blob(container_name, blob_name, options)
12
+ blob_client.delete_blob(container_name, blob_name, options)
13
13
  rescue Azure::Core::Http::HTTPError => ex
14
14
  return true if ex.message.include?('(404)')
15
15
  raise_azure_exception(ex, msg)
@@ -9,7 +9,7 @@ module Fog
9
9
  Fog::Logger.debug msg
10
10
 
11
11
  begin
12
- @blob_client.delete_container(name, options)
12
+ blob_client.delete_container(name, options)
13
13
  rescue Azure::Core::Http::HTTPError => ex
14
14
  return true if ex.message.include?('(404)')
15
15
  raise_azure_exception(ex, msg)
@@ -11,7 +11,7 @@ module Fog
11
11
  Fog::Logger.debug msg
12
12
 
13
13
  begin
14
- blob = @blob_client.get_blob_properties(container_name, blob_name, options)
14
+ blob = blob_client.get_blob_properties(container_name, blob_name, options)
15
15
  rescue Azure::Core::Http::HTTPError => ex
16
16
  raise 'NotFound' if ex.message.include?('(404)')
17
17
  raise_azure_exception(ex, msg)
@@ -49,7 +49,7 @@ module Fog
49
49
  begin
50
50
  msg = "get_blob_with_block_given: blob #{blob_name} in the container #{container_name}. options: #{params}"
51
51
  Fog::Logger.debug msg
52
- _, content = @blob_client.get_blob(container_name, blob_name, params)
52
+ _, content = blob_client.get_blob(container_name, blob_name, params)
53
53
  rescue Azure::Core::Http::HTTPError => ex
54
54
  raise 'NotFound' if ex.message.include?('(404)')
55
55
  raise_azure_exception(ex, msg)
@@ -71,7 +71,7 @@ module Fog
71
71
  Fog::Logger.debug msg
72
72
 
73
73
  begin
74
- blob, content = @blob_client.get_blob(container_name, blob_name, options)
74
+ blob, content = blob_client.get_blob(container_name, blob_name, options)
75
75
  Fog::Logger.debug "Get blob #{blob_name} successfully."
76
76
  [blob, content]
77
77
  rescue Azure::Core::Http::HTTPError => ex
@@ -8,7 +8,7 @@ module Fog
8
8
  msg = "Get Blob #{name} properties in container #{container_name}, options: #{options}."
9
9
  Fog::Logger.debug msg
10
10
  begin
11
- blob = @blob_client.get_blob_properties(container_name, name, options)
11
+ blob = blob_client.get_blob_properties(container_name, name, options)
12
12
  rescue Azure::Core::Http::HTTPError => ex
13
13
  raise 'NotFound' if ex.message.include?('(404)')
14
14
  raise_azure_exception(ex, msg)
@@ -9,7 +9,7 @@ module Fog
9
9
  Fog::Logger.debug msg
10
10
 
11
11
  begin
12
- container, signed_identifiers = @blob_client.get_container_acl(container_name, options)
12
+ container, signed_identifiers = blob_client.get_container_acl(container_name, options)
13
13
  rescue Azure::Core::Http::HTTPError => ex
14
14
  raise_azure_exception(ex, msg)
15
15
  end
@@ -9,7 +9,7 @@ module Fog
9
9
  Fog::Logger.debug msg
10
10
 
11
11
  begin
12
- container = @blob_client.get_container_properties(name, options)
12
+ container = blob_client.get_container_properties(name, options)
13
13
  rescue Azure::Core::Http::HTTPError => ex
14
14
  raise 'NotFound' if ex.message.include?('(404)')
15
15
  raise_azure_exception(ex, msg)
@@ -19,7 +19,7 @@ module Fog
19
19
  options[:request_id] = SecureRandom.uuid
20
20
  msg = "Listing blobs in container: #{container_name}, options: #{options}"
21
21
  Fog::Logger.debug msg
22
- temp = @blob_client.list_blobs(container_name, options)
22
+ temp = blob_client.list_blobs(container_name, options)
23
23
  # Workaround for the issue https://github.com/Azure/azure-storage-ruby/issues/37
24
24
  raise temp unless temp.instance_of?(Azure::Storage::Common::Service::EnumerationResults)
25
25
 
@@ -14,7 +14,7 @@ module Fog
14
14
  options[:request_id] = SecureRandom.uuid
15
15
  msg = "Listing containers. options: #{options}"
16
16
  Fog::Logger.debug msg
17
- temp = @blob_client.list_containers(options)
17
+ temp = blob_client.list_containers(options)
18
18
  # Workaround for the issue https://github.com/Azure/azure-storage-ruby/issues/37
19
19
  raise temp unless temp.instance_of?(Azure::Storage::Common::Service::EnumerationResults)
20
20
 
@@ -9,7 +9,7 @@ module Fog
9
9
  Fog::Logger.debug msg
10
10
 
11
11
  begin
12
- @blob_client.put_blob_block(container_name, blob_name, block_id, data, options)
12
+ blob_client.put_blob_block(container_name, blob_name, block_id, data, options)
13
13
  rescue Azure::Core::Http::HTTPError => ex
14
14
  raise_azure_exception(ex, msg)
15
15
  end
@@ -9,7 +9,7 @@ module Fog
9
9
  Fog::Logger.debug msg
10
10
 
11
11
  begin
12
- @blob_client.set_blob_metadata(container_name, name, metadata, options)
12
+ blob_client.set_blob_metadata(container_name, name, metadata, options)
13
13
  rescue Azure::Core::Http::HTTPError => ex
14
14
  raise_azure_exception(ex, msg)
15
15
  end
@@ -9,7 +9,7 @@ module Fog
9
9
  Fog::Logger.debug msg
10
10
 
11
11
  begin
12
- @blob_client.put_blob_pages(container_name, blob_name, start_range, end_range, data, options)
12
+ blob_client.put_blob_pages(container_name, blob_name, start_range, end_range, data, options)
13
13
  rescue Azure::Core::Http::HTTPError => ex
14
14
  raise_azure_exception(ex, msg)
15
15
  end
@@ -9,7 +9,7 @@ module Fog
9
9
  Fog::Logger.debug msg
10
10
 
11
11
  begin
12
- @blob_client.set_blob_properties(container_name, name, options)
12
+ blob_client.set_blob_properties(container_name, name, options)
13
13
  rescue Azure::Core::Http::HTTPError => ex
14
14
  raise_azure_exception(ex, msg)
15
15
  end
@@ -9,7 +9,7 @@ module Fog
9
9
  Fog::Logger.debug msg
10
10
 
11
11
  begin
12
- @blob_client.set_container_acl(name, acl, options)
12
+ blob_client.set_container_acl(name, acl, options)
13
13
  rescue Azure::Core::Http::HTTPError => ex
14
14
  raise_azure_exception(ex, msg)
15
15
  end
@@ -9,7 +9,7 @@ module Fog
9
9
  Fog::Logger.debug msg
10
10
 
11
11
  begin
12
- @blob_client.set_container_metadata(name, metadata, options)
12
+ blob_client.set_container_metadata(name, metadata, options)
13
13
  rescue Azure::Core::Http::HTTPError => ex
14
14
  raise_azure_exception(ex, msg)
15
15
  end
@@ -9,7 +9,7 @@ module Fog
9
9
  Fog::Logger.debug msg
10
10
 
11
11
  begin
12
- @blob_client.release_blob_lease(container_name, name, lease_id, options)
12
+ blob_client.release_blob_lease(container_name, name, lease_id, options)
13
13
  rescue Azure::Core::Http::HTTPError => ex
14
14
  raise_azure_exception(ex, msg)
15
15
  end
@@ -9,7 +9,7 @@ module Fog
9
9
  Fog::Logger.debug msg
10
10
 
11
11
  begin
12
- @blob_client.release_container_lease(name, lease_id, options)
12
+ blob_client.release_container_lease(name, lease_id, options)
13
13
  rescue Azure::Core::Http::HTTPError => ex
14
14
  raise_azure_exception(ex, msg)
15
15
  end
@@ -1,6 +1,7 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  require 'json'
4
+ require 'time'
4
5
 
5
6
  module Fog
6
7
  module AzureRM
@@ -87,6 +88,25 @@ module Fog
87
88
 
88
89
  attr_accessor :options
89
90
 
91
+ # Immutable snapshot of a built SharedAccessSignature and the delegation
92
+ # key expiry it is valid until. It is published by a single reference
93
+ # assignment so signature_client can read it on a lock-free fast path.
94
+ # A nil expiry means the signature never expires (shared-key auth, which
95
+ # signs with the access key rather than a user delegation key).
96
+ class SignatureState
97
+ attr_reader :client, :expiry
98
+
99
+ def initialize(client, expiry)
100
+ @client = client
101
+ @expiry = expiry
102
+ freeze
103
+ end
104
+
105
+ def covers?(requested_expiry)
106
+ expiry.nil? || expiry >= requested_expiry
107
+ end
108
+ end
109
+
90
110
  def initialize(options)
91
111
  begin
92
112
  require 'azure/storage/common'
@@ -100,8 +120,8 @@ module Fog
100
120
  raise e.message
101
121
  end
102
122
 
103
- @user_delegation_key_mutex = Mutex.new
104
- @blob_client_mutex = Mutex.new
123
+ @credential_mutex = Mutex.new
124
+ @signature_mutex = Mutex.new
105
125
 
106
126
  options[:environment] = options[:environment] || ENV['AZURE_ENVIRONMENT'] || ENVIRONMENT_AZURE_CLOUD
107
127
  @environment = options[:environment]
@@ -117,7 +137,7 @@ module Fog
117
137
  @azure_storage_endpoint = options[:azure_storage_endpoint]
118
138
  @azure_storage_domain = options[:azure_storage_domain]
119
139
 
120
- refresh_blob_client
140
+ @blob_client = build_blob_client(@azure_storage_token_signer)
121
141
  end
122
142
 
123
143
  private
@@ -142,62 +162,129 @@ module Fog
142
162
  Azure::Storage::Common::Core::Auth::TokenSigner.new(cred)
143
163
  end
144
164
 
165
+ # Returns a SharedAccessSignature for signing a URL that expires at
166
+ # requested_expiry.
167
+ #
168
+ # A user delegation key is valid for up to a week
169
+ # (MAX_USER_DELEGATION_KEY_SECONDS), so the built signature is cached in
170
+ # a frozen @signature_state snapshot and reused on a lock-free fast path.
171
+ # This keeps signing off the lock in the common case, so an in-flight
172
+ # credential or delegation-key refresh on another thread never stalls it.
173
+ #
174
+ # Only the slow path (no cached signature, or the cached delegation key
175
+ # no longer covers the requested expiry) takes @signature_mutex, where
176
+ # the delegation-key fetch and the snapshot rebuild happen atomically.
177
+ # Holding the lock there also collapses a burst of concurrent refetches
178
+ # into a single fetch.
145
179
  def signature_client(requested_expiry)
146
- access_key = @azure_storage_access_key.to_s
147
- user_delegation_key = user_delegation_key(requested_expiry)
148
-
149
- # invalidate cache when the delegation key changes
150
- unless @signature_client_delegation_key == user_delegation_key
151
- @signature_client_delegation_key = user_delegation_key
152
- @signature_client = nil
180
+ # Keep the blob client's credentials fresh without blocking signing;
181
+ # see maybe_refresh_credentials.
182
+ maybe_refresh_credentials
183
+
184
+ state = @signature_state
185
+ return state.client if state&.covers?(requested_expiry)
186
+
187
+ @signature_mutex.synchronize do
188
+ # Re-check: another thread may have rebuilt the snapshot while we
189
+ # were waiting for the lock.
190
+ state = @signature_state
191
+ return state.client if state&.covers?(requested_expiry)
192
+
193
+ delegation_key = fetch_user_delegation_key(requested_expiry)
194
+ client = Azure::Storage::Common::Core::Auth::SharedAccessSignature.new(
195
+ @azure_storage_account_name,
196
+ @azure_storage_access_key.to_s,
197
+ delegation_key
198
+ )
199
+ @signature_state = SignatureState.new(client, @user_delegation_key_expiry)
200
+ client
153
201
  end
154
-
155
- @signature_client ||= Azure::Storage::Common::Core::Auth::SharedAccessSignature.new(
156
- @azure_storage_account_name,
157
- access_key,
158
- user_delegation_key
159
- )
160
202
  end
161
203
 
162
- def user_delegation_key(requested_expiry)
163
- return nil unless @azure_storage_token_signer
204
+ # Refreshes the credential-derived state (@credentials, the token signer
205
+ # and the blob client) when the access token is near expiry. The SAS URL
206
+ # requests reach this through signature_client, and the data-plane
207
+ # requests reach it through the blob_client reader, so both keep their
208
+ # token fresh across rotation.
209
+ #
210
+ # The refresh is best-effort and non-blocking: a token fetch can be slow
211
+ # (or hang), so only one thread performs it at a time via a try-lock, and
212
+ # every other thread proceeds with the current, still-valid credentials
213
+ # rather than waiting. This keeps a slow token endpoint from stalling all
214
+ # callers that share the instance.
215
+ #
216
+ # The new signer and client are built into locals first and @credentials
217
+ # is published last. If building raises, @credentials is unchanged so
218
+ # refresh_needed? stays true and the next call retries, rather than
219
+ # leaving the swap half-applied with a stale signer or client.
220
+ def maybe_refresh_credentials
221
+ return unless @credential_client
222
+
223
+ current = @credentials
224
+ return unless current.nil? || current.refresh_needed?
225
+ return unless @credential_mutex.try_lock
226
+
227
+ begin
228
+ return unless @credentials.nil? || @credentials.refresh_needed?
164
229
 
165
- if @credential_client
166
230
  new_credentials = @credential_client.fetch_credentials_if_needed
167
- changed = new_credentials != @credentials
231
+ return if new_credentials.nil? || new_credentials == @credentials
232
+
233
+ signer = access_token_signer(new_credentials.token)
234
+ client = build_blob_client(signer)
168
235
 
169
- if changed
170
- @credentials = new_credentials
171
- @azure_storage_token_signer = token_signer
172
- refresh_blob_client
173
- end
236
+ @azure_storage_token_signer = signer
237
+ @blob_client = client
238
+ @credentials = new_credentials
239
+ ensure
240
+ @credential_mutex.unlock
174
241
  end
242
+ end
243
+
244
+ # Reader for the data-plane blob client. It refreshes an expiring access
245
+ # token first, so operations that read the client directly (blob
246
+ # properties, downloads, uploads, deletes) stay authenticated across
247
+ # token rotation, not only the SAS URL path. See
248
+ # https://gitlab.com/gitlab-org/ruby/gems/gitlab-fog-azure-rm/-/issues/11
249
+ def blob_client
250
+ maybe_refresh_credentials
251
+ @blob_client
252
+ end
175
253
 
176
- @user_delegation_key_mutex.synchronize do
177
- if @user_delegation_key_expiry.nil? || @user_delegation_key_expiry < requested_expiry
178
- start = Time.now
179
- expiry = start + Azure::Storage::Blob::BlobConstants::MAX_USER_DELEGATION_KEY_SECONDS
180
-
181
- @user_delegation_key = @blob_client.get_user_delegation_key(
182
- start,
183
- expiry
184
- )
185
- @user_delegation_key_expiry = expiry
186
- end
254
+ # Returns the cached user delegation key, fetching a new one when the
255
+ # cache is empty or no longer covers requested_expiry. Must be called
256
+ # while holding @signature_mutex. Returns nil for shared-key auth, where
257
+ # the SAS is signed with the access key instead.
258
+ def fetch_user_delegation_key(requested_expiry)
259
+ return nil unless @azure_storage_token_signer
260
+
261
+ if @user_delegation_key_expiry.nil? || @user_delegation_key_expiry < requested_expiry
262
+ start = Time.now
263
+ expiry = start + Azure::Storage::Blob::BlobConstants::MAX_USER_DELEGATION_KEY_SECONDS
264
+
265
+ @user_delegation_key = @blob_client.get_user_delegation_key(start, expiry)
266
+ # Cache against the expiry Azure granted (signed_expiry), not the one
267
+ # we requested: Azure uses its own clock and may return an earlier
268
+ # time, and keying on the request would over-claim validity and emit
269
+ # SAS URLs that Azure rejects.
270
+ @user_delegation_key_expiry = Time.parse(@user_delegation_key.signed_expiry)
187
271
  end
188
272
 
189
273
  @user_delegation_key
190
274
  end
191
275
 
192
- def refresh_blob_client
193
- @blob_client_mutex.synchronize do
194
- azure_client = create_azure_client
195
- azure_client.storage_blob_host = storage_blob_host
196
- @blob_client = Azure::Storage::Blob::BlobService.new(client: azure_client, api_version: @api_version)
197
- @blob_client.with_filter(Fog::AzureRM::IdentityEncodingFilter.new)
198
- @blob_client.with_filter(Azure::Storage::Common::Core::Filter::ExponentialRetryPolicyFilter.new)
199
- @blob_client.with_filter(Azure::Core::Http::DebugFilter.new) if @debug
200
- end
276
+ # Builds and returns a fully configured blob client. The caller publishes
277
+ # it with a single reference assignment (see initialize and
278
+ # maybe_refresh_credentials), so a concurrent reader of @blob_client never
279
+ # observes a client that is missing its filters mid-build.
280
+ def build_blob_client(signer)
281
+ azure_client = create_azure_client(signer)
282
+ azure_client.storage_blob_host = storage_blob_host
283
+ client = Azure::Storage::Blob::BlobService.new(client: azure_client, api_version: @api_version)
284
+ client.with_filter(Fog::AzureRM::IdentityEncodingFilter.new)
285
+ client.with_filter(Azure::Storage::Common::Core::Filter::ExponentialRetryPolicyFilter.new)
286
+ client.with_filter(Azure::Core::Http::DebugFilter.new) if @debug
287
+ client
201
288
  end
202
289
 
203
290
  def storage_blob_host
@@ -210,11 +297,11 @@ module Fog
210
297
  end
211
298
  end
212
299
 
213
- def create_azure_client
300
+ def create_azure_client(signer)
214
301
  Azure::Storage::Common::Client.create({
215
302
  storage_account_name: @azure_storage_account_name,
216
303
  storage_access_key: @azure_storage_access_key,
217
- signer: @azure_storage_token_signer
304
+ signer: signer
218
305
  }.compact)
219
306
  end
220
307
  end
@@ -1,5 +1,5 @@
1
1
  module Fog
2
2
  module AzureRM
3
- VERSION = '2.5.0'.freeze
3
+ VERSION = '2.5.2'.freeze
4
4
  end
5
5
  end
@@ -0,0 +1,85 @@
1
+ require File.expand_path '../../test_helper', __dir__
2
+ require 'azure/storage/common'
3
+ require 'azure/storage/blob'
4
+ require 'azure/core/auth/signer'
5
+
6
+ # Regression tests for
7
+ # https://gitlab.com/gitlab-org/ruby/gems/gitlab-fog-azure-rm/-/issues/11
8
+ #
9
+ # Data-plane requests (get_blob_properties, downloads, uploads, ...) read the
10
+ # blob client directly. They must refresh an expiring access token the same way
11
+ # the SAS URL path does, otherwise a long-lived process keeps signing with a
12
+ # stale token and Azure starts returning 401 after the token lifetime.
13
+ class TestCredentialRefreshOnBlobRequests < Minitest::Test
14
+ CONTAINER = 'test_container'.freeze
15
+ BLOB = 'test_blob'.freeze
16
+
17
+ # A blob client stand-in that needs no network and records the token it was
18
+ # built with, so a test can tell the refreshed client from the stale one.
19
+ class FakeBlobClient
20
+ attr_reader :token
21
+
22
+ def initialize(token)
23
+ @token = token
24
+ end
25
+
26
+ def with_filter(*)
27
+ self
28
+ end
29
+
30
+ def get_blob_properties(*)
31
+ MockBlob.new { |b| b.name = @token }
32
+ end
33
+ end
34
+
35
+ def setup
36
+ @service = Fog::AzureRM::Storage.new(storage_account_credentials_with_token_signer)
37
+ end
38
+
39
+ def credentials(token, expires_at)
40
+ Fog::AzureRM::Identity::Credentials.new(token, expires_at)
41
+ end
42
+
43
+ def credential_client(&fetch)
44
+ client = Object.new
45
+ client.define_singleton_method(:fetch_credentials_if_needed, &fetch)
46
+ client
47
+ end
48
+
49
+ # An expiring token is refreshed before the request runs, and the request uses
50
+ # the rebuilt client rather than the stale one.
51
+ def test_get_blob_properties_refreshes_an_expiring_token
52
+ stale = FakeBlobClient.new('stale-token')
53
+ fresh = FakeBlobClient.new('fresh-token')
54
+ fresh_credentials = credentials('fresh-token', Time.now + 3600)
55
+
56
+ @service.instance_variable_set(:@blob_client, stale)
57
+ @service.instance_variable_set(:@credentials, credentials('stale-token', Time.now - 1))
58
+ @service.instance_variable_set(:@credential_client, credential_client { fresh_credentials })
59
+
60
+ Azure::Storage::Blob::BlobService.stub(:new, ->(*, **) { fresh }) do
61
+ blob = @service.get_blob_properties(CONTAINER, BLOB)
62
+ assert_equal 'fresh-token', blob.name
63
+ end
64
+
65
+ assert_same fresh, @service.instance_variable_get(:@blob_client)
66
+ end
67
+
68
+ # A token that is still valid is not rebuilt, so a healthy request does not pay
69
+ # for a token fetch or a client rebuild on every call.
70
+ def test_get_blob_properties_does_not_rebuild_a_valid_token
71
+ current = FakeBlobClient.new('current-token')
72
+
73
+ @service.instance_variable_set(:@blob_client, current)
74
+ @service.instance_variable_set(:@credentials, credentials('current-token', Time.now + 3600))
75
+ @service.instance_variable_set(:@credential_client,
76
+ credential_client { raise 'should not refresh a valid token' })
77
+
78
+ Azure::Storage::Blob::BlobService.stub(:new, ->(*, **) { raise 'should not rebuild the client' }) do
79
+ blob = @service.get_blob_properties(CONTAINER, BLOB)
80
+ assert_equal 'current-token', blob.name
81
+ end
82
+
83
+ assert_same current, @service.instance_variable_get(:@blob_client)
84
+ end
85
+ end
@@ -118,18 +118,7 @@ class TestGetBlobHttpsUrl < Minitest::Test
118
118
 
119
119
  requested_expiry = Time.now + 60
120
120
 
121
- response = <<~MSG
122
- <UserDelegationKey>
123
- <SignedOid>f81d4fae-7dec-11d0-a765-00a0c91e6bf6</SignedOid>
124
- <SignedTid>72f988bf-86f1-41af-91ab-2d7cd011db47</SignedTid>
125
- <SignedStart>2024-09-19T00:00:00Z</SignedStart>
126
- <SignedExpiry>2024-09-26T00:00:00Z</SignedExpiry>
127
- <SignedService>b</SignedService>
128
- <SignedVersion>2020-02-10</SignedVersion>
129
- <Value>UDELEGATIONKEYXYZ....</Value>
130
- <SignedKey>rL7...ABC</SignedKey>
131
- </UserDelegationKey>
132
- MSG
121
+ response = user_delegation_key_response
133
122
 
134
123
  stub_request(:post, 'https://mockaccount.blob.core.windows.net?comp=userdelegationkey&restype=service')
135
124
  .to_return(status: 200, headers: { 'Content-Type': 'application/xml' }, body: response)
@@ -159,29 +148,19 @@ class TestGetBlobHttpsUrl < Minitest::Test
159
148
 
160
149
  ref_time = Time.now
161
150
 
162
- stubbed_times = []
163
- requested_expiries = []
164
- expected_user_delegation_key_starts = []
151
+ # Each entry: [Time.now offset from ref_time, requested-expiry offset from
152
+ # that time, whether a fresh delegation key is expected]. A delegation key
153
+ # is valid for one WEEK from its start.
154
+ plan = [
155
+ [0, 1 * HOUR, true], # initial request
156
+ [5.5 * DAY, 1 * DAY, false], # second request during expiry window
157
+ [6.5 * DAY, 1 * DAY, true], # request extending past current expiry
158
+ [10.5 * DAY, 1 * DAY, false] # second request within new expiry
159
+ ]
165
160
 
166
- # initial request
167
- stubbed_times << ref_time
168
- requested_expiries << stubbed_times.last + 1 * HOUR
169
- expected_user_delegation_key_starts << stubbed_times.last
170
-
171
- # second request during expiry window
172
- stubbed_times << ref_time + 5.5 * DAY
173
- requested_expiries << stubbed_times.last + 1 * DAY
174
- # no additonal expected_user_delegation_key_starts
175
-
176
- # request extending past current expiry
177
- stubbed_times << ref_time + 6.5 * DAY
178
- requested_expiries << stubbed_times.last + 1 * DAY
179
- expected_user_delegation_key_starts << stubbed_times.last
180
-
181
- # second request within new expiry
182
- stubbed_times << ref_time + 10.5 * DAY
183
- requested_expiries << stubbed_times.last + 1 * DAY
184
- # no additional expected_user_delegation_key_starts
161
+ stubbed_times = plan.map { |now_offset,| ref_time + now_offset }
162
+ requested_expiries = stubbed_times.each_with_index.map { |time, i| time + plan[i][1] }
163
+ expected_user_delegation_key_starts = stubbed_times.select.with_index { |_, i| plan[i][2] }
185
164
 
186
165
  user_delegation_key_starts = []
187
166
  mock_user_delegation_key = lambda do |start, expiry|
@@ -190,7 +169,7 @@ class TestGetBlobHttpsUrl < Minitest::Test
190
169
 
191
170
  key = Azure::Storage::Common::Service::UserDelegationKey.new
192
171
  key.signed_start = "start-#{start.to_i}"
193
- key.signed_expiry = 'test-expiry'
172
+ key.signed_expiry = expiry.utc.iso8601 # Azure grants the full requested window here
194
173
  key.value = 'delegation-key'
195
174
  key
196
175
  end
@@ -201,7 +180,7 @@ class TestGetBlobHttpsUrl < Minitest::Test
201
180
  end
202
181
 
203
182
  requested_expiries.each do
204
- mock_token_signer.expect(:sign, 'test-signature', [/\Ar\n.+test_blob\n.+\nstart-\d+\ntest-expiry/m])
183
+ mock_token_signer.expect(:sign, 'test-signature', [/\Ar\n.+test_blob\n.+\nstart-\d+\n\S+/m])
205
184
  end
206
185
 
207
186
  Time.stub :now, -> { stubbed_times.first } do
@@ -220,6 +199,42 @@ class TestGetBlobHttpsUrl < Minitest::Test
220
199
  assert_equal expected_user_delegation_key_starts, user_delegation_key_starts
221
200
  end
222
201
 
202
+ # Regression: the delegation-key cache keys on the expiry Azure granted
203
+ # (signed_expiry), not the requested one. Keying on the request would serve a
204
+ # cached key for a window Azure already ended, so this asserts a refetch.
205
+ def test_delegation_key_cache_uses_signed_expiry_not_requested
206
+ service = Fog::AzureRM::Storage.new(storage_account_credentials_with_token_signer)
207
+ blob_client = service.instance_variable_get(:@blob_client)
208
+
209
+ ref_time = Time.now
210
+ granted_expiry = (ref_time + 2 * DAY).utc # Azure grants far less than the 1-week request
211
+
212
+ starts = []
213
+ mock_key = lambda do |start, _expiry|
214
+ starts << start
215
+ key = Azure::Storage::Common::Service::UserDelegationKey.new
216
+ key.signed_start = "start-#{start.to_i}"
217
+ key.signed_expiry = granted_expiry.iso8601
218
+ key.value = 'delegation-key'
219
+ key
220
+ end
221
+ mock_new_signer = ->(_token) { mock_token_signer }
222
+ 2.times { mock_token_signer.expect(:sign, 'test-signature', [String]) }
223
+
224
+ Time.stub :now, -> { ref_time } do
225
+ blob_client.stub :get_user_delegation_key, mock_key do
226
+ Azure::Core::Auth::Signer.stub :new, mock_new_signer do
227
+ service.get_blob_https_url('test_container', 'test_blob', ref_time + 1 * HOUR)
228
+ # Requested expiry is past Azure's granted window but within start+MAX.
229
+ service.get_blob_https_url('test_container', 'test_blob', ref_time + 3 * DAY)
230
+ end
231
+ end
232
+ end
233
+
234
+ assert_equal 2, starts.length,
235
+ 'expected a refetch once the requested expiry exceeded the granted signed_expiry'
236
+ end
237
+
223
238
  def test_get_blob_https_url_mock
224
239
  assert_equal "#{@url}?#{@token}", @mock_service.get_blob_https_url('test_container', 'test_blob', Time.now.utc + 3600)
225
240
  assert_equal "#{@url}?#{@token}", @mock_service.get_object_url('test_container', 'test_blob', Time.now.utc + 3600)
@@ -247,18 +262,9 @@ class TestGetBlobHttpsUrl < Minitest::Test
247
262
  )
248
263
 
249
264
  # Setup user delegation key responses
250
- delegation_response = <<~MSG
251
- <UserDelegationKey>
252
- <SignedOid>f81d4fae-7dec-11d0-a765-00a0c91e6bf6</SignedOid>
253
- <SignedTid>72f988bf-86f1-41af-91ab-2d7cd011db47</SignedTid>
254
- <SignedStart>2024-09-19T00:00:00Z</SignedStart>
255
- <SignedExpiry>2024-09-26T00:00:00Z</SignedExpiry>
256
- <SignedService>b</SignedService>
257
- <SignedVersion>2020-02-10</SignedVersion>
258
- <Value>UDELEGATIONKEY_INITIAL</Value>
259
- <SignedKey>rL7...INITIAL</SignedKey>
260
- </UserDelegationKey>
261
- MSG
265
+ delegation_response = user_delegation_key_response(
266
+ value: 'UDELEGATIONKEY_INITIAL', signed_key: 'rL7...INITIAL'
267
+ )
262
268
 
263
269
  stub_request(:post, 'https://mockaccount.blob.core.windows.net?comp=userdelegationkey&restype=service')
264
270
  .to_return(
@@ -0,0 +1,138 @@
1
+ require File.expand_path '../../test_helper', __dir__
2
+ require 'uri'
3
+ require 'azure/storage/common'
4
+ require 'azure/storage/blob'
5
+ require 'azure/core/auth/signer'
6
+
7
+ # Tests for the concurrency guarantees of SAS signing. Each drives the public
8
+ # get_blob_https_url (which calls signature_client and maybe_refresh_credentials)
9
+ # and checks one property in isolation, so no private methods or stress loops
10
+ # are needed.
11
+ class TestStorageThreadSafety < Minitest::Test
12
+ CONTAINER = 'test_container'.freeze
13
+ BLOB = 'test_blob'.freeze
14
+
15
+ # A blob client stand-in that needs no network. Optionally parks on its first
16
+ # with_filter call so a test can inspect state while a rebuild is in progress.
17
+ class FakeBlobClient
18
+ def initialize(&on_first_filter)
19
+ @filters = []
20
+ @on_first_filter = on_first_filter
21
+ end
22
+
23
+ def with_filter(filter)
24
+ @on_first_filter.call if @filters.empty? && @on_first_filter
25
+ @filters << filter
26
+ self
27
+ end
28
+
29
+ def generate_uri(path, *)
30
+ URI.parse("https://mockaccount.blob.core.windows.net/#{path}")
31
+ end
32
+
33
+ def get_user_delegation_key(*)
34
+ key = Azure::Storage::Common::Service::UserDelegationKey.new
35
+ key.value = 'ZGVsZWdhdGlvbg==' # any string; Base64.decode64 is lenient
36
+ key.signed_expiry = '2999-12-31T23:59:59Z' # far future so the cached key covers the request
37
+ key
38
+ end
39
+ end
40
+
41
+ # Stands in for @signature_mutex and fails the test if the lock is taken.
42
+ class RaisingMutex
43
+ def synchronize
44
+ raise 'signature_client took the lock when it should have hit the cache'
45
+ end
46
+ end
47
+
48
+ def setup
49
+ @service = Fog::AzureRM::Storage.new(storage_account_credentials_with_token_signer)
50
+ end
51
+
52
+ def in_one_hour
53
+ Time.now.utc + 3600
54
+ end
55
+
56
+ def credentials(expires_at)
57
+ Fog::AzureRM::Identity::Credentials.new('token', expires_at)
58
+ end
59
+
60
+ def credential_client(&fetch)
61
+ client = Object.new
62
+ client.define_singleton_method(:fetch_credentials_if_needed, &fetch)
63
+ client
64
+ end
65
+
66
+ # A cached signature that still covers the requested expiry is returned
67
+ # without taking @signature_mutex, so signing never waits on a refresh.
68
+ def test_cached_signature_is_returned_without_locking
69
+ @service.instance_variable_set(:@blob_client, FakeBlobClient.new)
70
+ @service.get_blob_https_url(CONTAINER, BLOB, in_one_hour) # builds and caches it
71
+
72
+ @service.instance_variable_set(:@signature_mutex, RaisingMutex.new)
73
+
74
+ url = @service.get_blob_https_url(CONTAINER, BLOB, in_one_hour)
75
+ assert_match %r{\Ahttps://mockaccount\.blob\.core\.windows\.net/#{CONTAINER}/#{BLOB}\?}, url
76
+ end
77
+
78
+ # While one thread is stuck in a slow token fetch, another caller must still
79
+ # return instead of blocking on the credential lock.
80
+ def test_a_slow_credential_refresh_does_not_block_other_callers
81
+ entered_fetch = Queue.new
82
+ finish_fetch = Queue.new
83
+ fresh = credentials(Time.now + 3600)
84
+
85
+ slow_client = credential_client do
86
+ entered_fetch << true
87
+ finish_fetch.pop # simulate a slow or hanging token endpoint
88
+ fresh
89
+ end
90
+
91
+ Azure::Storage::Blob::BlobService.stub(:new, ->(*, **) { FakeBlobClient.new }) do
92
+ @service.instance_variable_set(:@blob_client, FakeBlobClient.new)
93
+ @service.instance_variable_set(:@credential_client, slow_client)
94
+ @service.instance_variable_set(:@credentials, credentials(Time.now - 1))
95
+
96
+ refreshing = Thread.new { @service.get_blob_https_url(CONTAINER, BLOB, in_one_hour) }
97
+ entered_fetch.pop # the refresh now holds @credential_mutex inside the fetch
98
+
99
+ other = Thread.new { @service.get_blob_https_url(CONTAINER, BLOB, in_one_hour) }
100
+ returned = other.join(5)
101
+
102
+ begin
103
+ refute_nil returned, 'a second caller blocked while one thread was refreshing credentials'
104
+ ensure
105
+ finish_fetch << true
106
+ [refreshing, other].each(&:join)
107
+ end
108
+ end
109
+ end
110
+
111
+ # A rebuilt blob client is published only once it is fully built, so a
112
+ # concurrent reader never sees one that is missing its filters.
113
+ def test_rebuilt_blob_client_is_published_only_when_fully_built
114
+ original = @service.instance_variable_get(:@blob_client)
115
+ fresh = credentials(Time.now + 3600)
116
+ @service.instance_variable_set(:@credential_client, credential_client { fresh })
117
+ @service.instance_variable_set(:@credentials, credentials(Time.now - 1))
118
+
119
+ mid_build = Queue.new
120
+ resume = Queue.new
121
+ half_built = FakeBlobClient.new do
122
+ mid_build << true
123
+ resume.pop
124
+ end
125
+
126
+ Azure::Storage::Blob::BlobService.stub(:new, ->(*, **) { half_built }) do
127
+ builder = Thread.new { @service.get_blob_https_url(CONTAINER, BLOB, in_one_hour) }
128
+ mid_build.pop # the new client exists but its filters are not applied yet
129
+ observed = @service.instance_variable_get(:@blob_client)
130
+ resume << true
131
+ builder.join
132
+
133
+ refute_same half_built, observed, 'the half-built client was published before its filters were applied'
134
+ assert_same original, observed
135
+ assert_same half_built, @service.instance_variable_get(:@blob_client)
136
+ end
137
+ end
138
+ end
data/test/test_helper.rb CHANGED
@@ -79,6 +79,21 @@ def mock_token_signer
79
79
  @mock_token_signer ||= Minitest::Mock.new(Azure::Core::Auth::Signer.new('access-token'))
80
80
  end
81
81
 
82
+ def user_delegation_key_response(value: 'UDELEGATIONKEYXYZ....', signed_key: 'rL7...ABC')
83
+ <<~MSG
84
+ <UserDelegationKey>
85
+ <SignedOid>f81d4fae-7dec-11d0-a765-00a0c91e6bf6</SignedOid>
86
+ <SignedTid>72f988bf-86f1-41af-91ab-2d7cd011db47</SignedTid>
87
+ <SignedStart>2024-09-19T00:00:00Z</SignedStart>
88
+ <SignedExpiry>2024-09-26T00:00:00Z</SignedExpiry>
89
+ <SignedService>b</SignedService>
90
+ <SignedVersion>2020-02-10</SignedVersion>
91
+ <Value>#{value}</Value>
92
+ <SignedKey>#{signed_key}</SignedKey>
93
+ </UserDelegationKey>
94
+ MSG
95
+ end
96
+
82
97
  # Mock Class for Blob
83
98
  class MockBlob
84
99
  def initialize
@@ -47,6 +47,36 @@ class TestWorkflowIdentityClient < Minitest::Test
47
47
  end
48
48
  end
49
49
 
50
+ # The OAuth v2.0 token endpoint returns expires_in, not expires_on. A fresh
51
+ # token must therefore report a real future expiry so refresh_needed? stays
52
+ # false until it is near expiry, rather than forcing a fetch on every request.
53
+ def test_fetch_credentials_uses_expires_in
54
+ ENV['AZURE_TENANT_ID'] = 'fake_tenant_id'
55
+ ENV['AZURE_CLIENT_ID'] = 'fake_client_id'
56
+ ENV['AZURE_FEDERATED_TOKEN_FILE'] = 'fake_token_file'
57
+
58
+ @client = Fog::AzureRM::Identity::WorkflowIdentityClient.new(@options)
59
+
60
+ token_response = {
61
+ 'access_token' => 'fake_access_token',
62
+ 'expires_in' => 3600
63
+ }
64
+
65
+ File.stub :exist?, true do
66
+ File.stub :readable?, true do
67
+ File.stub :read, 'fake_oidc_token' do
68
+ stub_request(:post, "#{@client.authority}/fake_tenant_id/oauth2/v2.0/token")
69
+ .to_return(status: 200, body: token_response.to_json)
70
+
71
+ credentials = @client.fetch_credentials
72
+
73
+ assert_in_delta (Time.now + 3600).to_f, credentials.expires_at.to_f, 5
74
+ refute credentials.refresh_needed?
75
+ end
76
+ end
77
+ end
78
+ end
79
+
50
80
  def test_fetch_credentials_failure
51
81
  ENV['AZURE_TENANT_ID'] = 'fake_tenant_id'
52
82
  ENV['AZURE_CLIENT_ID'] = 'fake_client_id'
metadata CHANGED
@@ -1,7 +1,7 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: gitlab-fog-azure-rm
3
3
  version: !ruby/object:Gem::Version
4
- version: 2.5.0
4
+ version: 2.5.2
5
5
  platform: ruby
6
6
  authors:
7
7
  - Shaffan Chaudhry
@@ -18,7 +18,7 @@ authors:
18
18
  autorequire:
19
19
  bindir: bin
20
20
  cert_chain: []
21
- date: 2026-03-31 00:00:00.000000000 Z
21
+ date: 2026-09-23 00:00:00.000000000 Z
22
22
  dependencies:
23
23
  - !ruby/object:Gem::Dependency
24
24
  name: codeclimate-test-reporter
@@ -449,6 +449,7 @@ files:
449
449
  - test/requests/storage/test_create_block_blob.rb
450
450
  - test/requests/storage/test_create_container.rb
451
451
  - test/requests/storage/test_create_page_blob.rb
452
+ - test/requests/storage/test_credential_refresh_on_blob_requests.rb
452
453
  - test/requests/storage/test_delete_blob.rb
453
454
  - test/requests/storage/test_delete_blob_https_url.rb
454
455
  - test/requests/storage/test_delete_container.rb
@@ -476,6 +477,7 @@ files:
476
477
  - test/requests/storage/test_release_blob_lease.rb
477
478
  - test/requests/storage/test_release_container_lease.rb
478
479
  - test/requests/storage/test_save_page_blob.rb
480
+ - test/requests/storage/test_storage_thread_safety.rb
479
481
  - test/requests/storage/test_wait_blob_copy_operation_to_finish.rb
480
482
  - test/test_helper.rb
481
483
  - test/unit/test_credentials.rb