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.
- checksums.yaml +4 -4
- data/CHANGELOG.md +9 -0
- data/lib/fog/azurerm/identity/base_client.rb +14 -4
- data/lib/fog/azurerm/requests/storage/acquire_blob_lease.rb +1 -1
- data/lib/fog/azurerm/requests/storage/acquire_container_lease.rb +1 -1
- data/lib/fog/azurerm/requests/storage/commit_blob_blocks.rb +1 -1
- data/lib/fog/azurerm/requests/storage/copy_blob.rb +1 -1
- data/lib/fog/azurerm/requests/storage/copy_blob_from_uri.rb +1 -1
- data/lib/fog/azurerm/requests/storage/create_block_blob.rb +1 -1
- data/lib/fog/azurerm/requests/storage/create_container.rb +1 -1
- data/lib/fog/azurerm/requests/storage/create_page_blob.rb +1 -1
- data/lib/fog/azurerm/requests/storage/delete_blob.rb +1 -1
- data/lib/fog/azurerm/requests/storage/delete_container.rb +1 -1
- data/lib/fog/azurerm/requests/storage/get_blob.rb +3 -3
- data/lib/fog/azurerm/requests/storage/get_blob_properties.rb +1 -1
- data/lib/fog/azurerm/requests/storage/get_container_acl.rb +1 -1
- data/lib/fog/azurerm/requests/storage/get_container_properties.rb +1 -1
- data/lib/fog/azurerm/requests/storage/list_blobs.rb +1 -1
- data/lib/fog/azurerm/requests/storage/list_containers.rb +1 -1
- data/lib/fog/azurerm/requests/storage/put_blob_block.rb +1 -1
- data/lib/fog/azurerm/requests/storage/put_blob_metadata.rb +1 -1
- data/lib/fog/azurerm/requests/storage/put_blob_pages.rb +1 -1
- data/lib/fog/azurerm/requests/storage/put_blob_properties.rb +1 -1
- data/lib/fog/azurerm/requests/storage/put_container_acl.rb +1 -1
- data/lib/fog/azurerm/requests/storage/put_container_metadata.rb +1 -1
- data/lib/fog/azurerm/requests/storage/release_blob_lease.rb +1 -1
- data/lib/fog/azurerm/requests/storage/release_container_lease.rb +1 -1
- data/lib/fog/azurerm/storage.rb +134 -47
- data/lib/fog/azurerm/version.rb +1 -1
- data/test/requests/storage/test_credential_refresh_on_blob_requests.rb +85 -0
- data/test/requests/storage/test_get_blob_https_url.rb +54 -48
- data/test/requests/storage/test_storage_thread_safety.rb +138 -0
- data/test/test_helper.rb +15 -0
- data/test/unit/test_workflow_identity_client.rb +30 -0
- metadata +4 -2
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: 82912caef21af9f05608fd07eb07dd1c1a5d2693a805562a33448997f7897177
|
|
4
|
+
data.tar.gz: 245ab6fc1a0f8f64d775872c622696debcd1594cb72e75d7a533eef689d19e27
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
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
|
-
|
|
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
|
-
|
|
56
|
+
return ::Time.at(expires_on.to_i) if expires_on
|
|
49
57
|
|
|
50
|
-
|
|
51
|
-
|
|
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 =
|
|
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 =
|
|
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
|
-
|
|
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 =
|
|
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 =
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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 =
|
|
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 =
|
|
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 =
|
|
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 =
|
|
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 =
|
|
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 =
|
|
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 =
|
|
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 =
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
data/lib/fog/azurerm/storage.rb
CHANGED
|
@@ -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
|
-
@
|
|
104
|
-
@
|
|
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
|
-
|
|
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
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
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
|
-
|
|
163
|
-
|
|
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
|
-
|
|
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
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
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
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
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
|
-
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
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:
|
|
304
|
+
signer: signer
|
|
218
305
|
}.compact)
|
|
219
306
|
end
|
|
220
307
|
end
|
data/lib/fog/azurerm/version.rb
CHANGED
|
@@ -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 =
|
|
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
|
-
|
|
163
|
-
|
|
164
|
-
|
|
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
|
-
|
|
167
|
-
stubbed_times
|
|
168
|
-
|
|
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 =
|
|
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+\
|
|
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 =
|
|
251
|
-
|
|
252
|
-
|
|
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.
|
|
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-
|
|
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
|