gitlab-fog-azure-rm 2.1.0 → 2.2.0
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/.rubocop.yml +4 -0
- data/CHANGELOG.md +4 -0
- data/gitlab-fog-azure-rm.gemspec +1 -0
- data/lib/fog/azurerm/identity/base_client.rb +74 -0
- data/lib/fog/azurerm/identity/credentials.rb +23 -0
- data/lib/fog/azurerm/identity/default_credentials.rb +58 -0
- data/lib/fog/azurerm/identity/managed_identity_client.rb +51 -0
- data/lib/fog/azurerm/identity/workflow_identity_client.rb +79 -0
- data/lib/fog/azurerm/identity.rb +5 -0
- data/lib/fog/azurerm/storage.rb +38 -7
- data/lib/fog/azurerm/utilities/general.rb +22 -0
- data/lib/fog/azurerm/version.rb +1 -1
- data/lib/fog/azurerm.rb +1 -0
- data/rakefile +1 -0
- data/test/models/storage/test_directory.rb +1 -1
- data/test/requests/storage/test_get_blob_https_url.rb +50 -1
- data/test/test_helper.rb +9 -0
- data/test/unit/test_default_credentials.rb +97 -0
- data/test/unit/test_managed_identity_client.rb +74 -0
- data/test/unit/test_workflow_identity_client.rb +147 -0
- metadata +26 -3
checksums.yaml
CHANGED
@@ -1,7 +1,7 @@
|
|
1
1
|
---
|
2
2
|
SHA256:
|
3
|
-
metadata.gz:
|
4
|
-
data.tar.gz:
|
3
|
+
metadata.gz: 6c0db6ddb892cbf22fdd3bd0a6aa859dc54256572f87ff5fa4af0ab478127ba1
|
4
|
+
data.tar.gz: ac3ee5c92ab35dae5050dbf71a68c5d4f74cb0549c1b2c6cd55bfb3676099141
|
5
5
|
SHA512:
|
6
|
-
metadata.gz:
|
7
|
-
data.tar.gz:
|
6
|
+
metadata.gz: a876f5d3cd330701883c5185313faba45a00403577388a102d658b51a88cdf56bb1d46130668ad5b5e54557416048830dbc21bb458f844218e8cb9777ed7aca3
|
7
|
+
data.tar.gz: a6c70245610e9ad32f587005888bdfd8da48a6d0b52c48fdf5db0a9a7d7141bf33bf9ba74bda4c6a7793cc37648f65d3cb9bcfc5033d819874a22c26a69ae25e
|
data/.rubocop.yml
CHANGED
data/CHANGELOG.md
CHANGED
data/gitlab-fog-azure-rm.gemspec
CHANGED
@@ -22,6 +22,7 @@ Gem::Specification.new do |spec|
|
|
22
22
|
spec.add_development_dependency 'rake', '~> 13.0'
|
23
23
|
spec.add_development_dependency 'rubocop', '~> 0.89.1'
|
24
24
|
spec.add_development_dependency 'simplecov'
|
25
|
+
spec.add_development_dependency 'webmock'
|
25
26
|
spec.add_development_dependency 'webrick', '~> 1.8'
|
26
27
|
|
27
28
|
spec.add_dependency 'faraday', "~> 2.0"
|
@@ -0,0 +1,74 @@
|
|
1
|
+
# frozen_string_literal: trues
|
2
|
+
|
3
|
+
require 'json'
|
4
|
+
|
5
|
+
module Fog
|
6
|
+
module AzureRM
|
7
|
+
module Identity
|
8
|
+
# BaseClient is responsible for fetching credentials and refreshing
|
9
|
+
# them when necessary.
|
10
|
+
class BaseClient
|
11
|
+
include Fog::AzureRM::Utilities::General
|
12
|
+
attr_accessor :credentials
|
13
|
+
|
14
|
+
FetchCredentialsError = Class.new(RuntimeError)
|
15
|
+
|
16
|
+
DEFAULT_TIMEOUT_S = 30
|
17
|
+
|
18
|
+
def fetch_credentials
|
19
|
+
raise NotImplementedError
|
20
|
+
end
|
21
|
+
|
22
|
+
def fetch_credentials_if_needed
|
23
|
+
@credentials = fetch_credentials if @credentials.nil? || refresh_needed?
|
24
|
+
|
25
|
+
credentials
|
26
|
+
end
|
27
|
+
|
28
|
+
def refresh_needed?
|
29
|
+
return true unless @credentials
|
30
|
+
|
31
|
+
@credentials.refresh_needed?
|
32
|
+
end
|
33
|
+
|
34
|
+
protected
|
35
|
+
|
36
|
+
def process_token_response(response)
|
37
|
+
# If we get an unauthorized error, raising an exception allows the admin
|
38
|
+
# diagnose the error with the federated credentials.
|
39
|
+
raise FetchCredentialsError, response.to_s unless response.success?
|
40
|
+
|
41
|
+
body = ::JSON.parse(response.body)
|
42
|
+
access_token = body['access_token']
|
43
|
+
|
44
|
+
return unless access_token
|
45
|
+
|
46
|
+
expires_at = ::Time.now
|
47
|
+
expires_on = body['expires_on']
|
48
|
+
expires_at = ::Time.at(expires_on.to_i) if expires_on
|
49
|
+
|
50
|
+
Credentials.new(access_token, expires_at)
|
51
|
+
rescue ::JSON::ParserError # rubocop:disable Lint/SuppressedException
|
52
|
+
end
|
53
|
+
|
54
|
+
private
|
55
|
+
|
56
|
+
def get(url, params: nil, headers: nil)
|
57
|
+
Faraday.get(url, params, headers) do |req|
|
58
|
+
req.options.timeout = DEFAULT_TIMEOUT_S
|
59
|
+
end
|
60
|
+
rescue ::Faraday::Error => e
|
61
|
+
raise FetchCredentialsError, e.to_s
|
62
|
+
end
|
63
|
+
|
64
|
+
def post(url, body: nil, headers: nil)
|
65
|
+
Faraday.post(url, body, headers) do |req|
|
66
|
+
req.options.timeout = DEFAULT_TIMEOUT_S
|
67
|
+
end
|
68
|
+
rescue ::Faraday::Error => e
|
69
|
+
raise FetchCredentialsError, e.to_s
|
70
|
+
end
|
71
|
+
end
|
72
|
+
end
|
73
|
+
end
|
74
|
+
end
|
@@ -0,0 +1,23 @@
|
|
1
|
+
module Fog
|
2
|
+
module AzureRM
|
3
|
+
module Identity
|
4
|
+
# Credentials stores the access token and its expiry.
|
5
|
+
class Credentials
|
6
|
+
attr_accessor :token, :expires_at
|
7
|
+
|
8
|
+
EXPIRATION_BUFFER = 600 # 10 minutes
|
9
|
+
|
10
|
+
def initialize(token, expires_at)
|
11
|
+
@token = token
|
12
|
+
@expires_at = expires_at
|
13
|
+
end
|
14
|
+
|
15
|
+
def refresh_needed?
|
16
|
+
return true unless expires_at
|
17
|
+
|
18
|
+
Time.now >= expires_at + EXPIRATION_BUFFER
|
19
|
+
end
|
20
|
+
end
|
21
|
+
end
|
22
|
+
end
|
23
|
+
end
|
@@ -0,0 +1,58 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
require 'json'
|
4
|
+
require_relative 'base_client'
|
5
|
+
|
6
|
+
module Fog
|
7
|
+
module AzureRM
|
8
|
+
# DefaultCredentials attempts to resolve the credentials necessary to access
|
9
|
+
# the Azure service.
|
10
|
+
class DefaultCredentials
|
11
|
+
def initialize(options)
|
12
|
+
@options = options
|
13
|
+
@credential_client = nil
|
14
|
+
@credentials = nil
|
15
|
+
end
|
16
|
+
|
17
|
+
def fetch_credentials_if_needed
|
18
|
+
return unless credential_client
|
19
|
+
|
20
|
+
credential_client.fetch_credentials_if_needed
|
21
|
+
end
|
22
|
+
|
23
|
+
private
|
24
|
+
|
25
|
+
attr_reader :options
|
26
|
+
|
27
|
+
def credential_client
|
28
|
+
return @credential_client if @credential_client
|
29
|
+
|
30
|
+
clients = [
|
31
|
+
Fog::AzureRM::Identity::WorkflowIdentityClient,
|
32
|
+
Fog::AzureRM::Identity::ManagedIdentityClient
|
33
|
+
]
|
34
|
+
|
35
|
+
credentials = nil
|
36
|
+
clients.each do |klass|
|
37
|
+
client = klass.new(options)
|
38
|
+
|
39
|
+
begin
|
40
|
+
credentials = client.fetch_credentials
|
41
|
+
rescue Fog::AzureRM::Identity::BaseClient::FetchCredentialsError
|
42
|
+
next
|
43
|
+
end
|
44
|
+
|
45
|
+
if credentials
|
46
|
+
@credential_client = client
|
47
|
+
break
|
48
|
+
end
|
49
|
+
end
|
50
|
+
|
51
|
+
return unless credentials
|
52
|
+
|
53
|
+
@credentials = credentials
|
54
|
+
@credentials
|
55
|
+
end
|
56
|
+
end
|
57
|
+
end
|
58
|
+
end
|
@@ -0,0 +1,51 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
require 'json'
|
4
|
+
require 'faraday'
|
5
|
+
|
6
|
+
module Fog
|
7
|
+
module AzureRM
|
8
|
+
module Identity
|
9
|
+
IDENTITY_ENDPOINT = 'http://169.254.169.254/metadata/identity/oauth2/token'
|
10
|
+
API_VERSION = '2018-02-01'
|
11
|
+
|
12
|
+
# ManagedIdentityClient fetches temporary credentials from the instance metadata endpoint.
|
13
|
+
class ManagedIdentityClient < BaseClient
|
14
|
+
include Fog::AzureRM::Utilities::General
|
15
|
+
|
16
|
+
attr_reader :resource
|
17
|
+
|
18
|
+
def initialize(options)
|
19
|
+
super()
|
20
|
+
@environment = options[:environment]
|
21
|
+
@resource = storage_resource(@environment)
|
22
|
+
end
|
23
|
+
|
24
|
+
# This method obtains a token via the Azure Instance Metadata Service (IMDS) endpoint:
|
25
|
+
# https://learn.microsoft.com/en-us/entra/identity/managed-identities-azure-resources/how-to-use-vm-token#get-a-token-using-http
|
26
|
+
def fetch_credentials
|
27
|
+
url = "#{identity_endpoint}?api-version=#{api_version}&resource=#{CGI.escape(resource)}"
|
28
|
+
|
29
|
+
client_id = ENV['AZURE_CLIENT_ID']
|
30
|
+
url += "&client_id=#{client_id}" if client_id
|
31
|
+
|
32
|
+
headers = { 'Metadata' => 'true' }
|
33
|
+
headers['X-IDENTITY-HEADER'] = ENV['IDENTITY_HEADER'] if ENV['IDENTITY_HEADER']
|
34
|
+
|
35
|
+
response = get(url, headers: headers)
|
36
|
+
process_token_response(response)
|
37
|
+
end
|
38
|
+
|
39
|
+
private
|
40
|
+
|
41
|
+
def identity_endpoint
|
42
|
+
ENV['IDENTITY_ENDPOINT'] || IDENTITY_ENDPOINT
|
43
|
+
end
|
44
|
+
|
45
|
+
def api_version
|
46
|
+
ENV['IDENTITY_ENDPOINT'] ? '2019-08-01' : API_VERSION
|
47
|
+
end
|
48
|
+
end
|
49
|
+
end
|
50
|
+
end
|
51
|
+
end
|
@@ -0,0 +1,79 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
require 'json'
|
4
|
+
|
5
|
+
module Fog
|
6
|
+
module AzureRM
|
7
|
+
module Identity
|
8
|
+
# WorkflowIdentityClient attempts to fetch credentials for Azure Workflow Identity
|
9
|
+
# via the following environment variables:
|
10
|
+
#
|
11
|
+
# - AZURE_AUTHORITY_HOST - This can be used to override the default authority URL.
|
12
|
+
# - AZURE_TENANT_ID
|
13
|
+
# - AZURE_CLIENT_ID
|
14
|
+
# - AZURE_FEDERATED_TOKEN_FILE - This is a filename that stores the JWT token that
|
15
|
+
# is exchanged for an OAuth2 token.
|
16
|
+
class WorkflowIdentityClient < BaseClient
|
17
|
+
include Fog::AzureRM::Utilities::General
|
18
|
+
|
19
|
+
attr_accessor :environment, :resource, :authority, :tenant_id, :client_id, :token_file
|
20
|
+
|
21
|
+
def initialize(options)
|
22
|
+
super()
|
23
|
+
@environment = options[:environment]
|
24
|
+
@resource = storage_resource(@environment)
|
25
|
+
@authority = ENV['AZURE_AUTHORITY_HOST'] || authority_url(@environment)
|
26
|
+
@tenant_id = ENV['AZURE_TENANT_ID']
|
27
|
+
@client_id = ENV['AZURE_CLIENT_ID']
|
28
|
+
@token_file = ENV['AZURE_FEDERATED_TOKEN_FILE']
|
29
|
+
|
30
|
+
normalize_authority!
|
31
|
+
end
|
32
|
+
|
33
|
+
def fetch_credentials
|
34
|
+
return unless authority && tenant_id && client_id
|
35
|
+
return unless ::File.exist?(token_file) && ::File.readable?(token_file)
|
36
|
+
|
37
|
+
oidc_token = ::File.read(token_file)
|
38
|
+
token_url = "#{authority}/#{tenant_id}/oauth2/v2.0/token"
|
39
|
+
scope = "#{storage_resource(@environment)}/.default"
|
40
|
+
|
41
|
+
data = {
|
42
|
+
client_id: client_id,
|
43
|
+
grant_type: 'client_credentials',
|
44
|
+
client_assertion_type: 'urn:ietf:params:oauth:client-assertion-type:jwt-bearer',
|
45
|
+
client_assertion: oidc_token,
|
46
|
+
scope: scope
|
47
|
+
}
|
48
|
+
|
49
|
+
response = post(token_url, body: data)
|
50
|
+
|
51
|
+
process_token_response(response)
|
52
|
+
rescue ::Faraday::Error => e
|
53
|
+
raise FetchCredentialsError, e.to_s
|
54
|
+
end
|
55
|
+
|
56
|
+
private
|
57
|
+
|
58
|
+
# The Azure Python SDK handles an authority with or without a scheme, so let's
|
59
|
+
# do this too: https://github.com/Azure/azure-sdk-for-python/pull/11050
|
60
|
+
def normalize_authority!
|
61
|
+
parsed = URI.parse(authority)
|
62
|
+
|
63
|
+
unless parsed.scheme
|
64
|
+
@authority = "https://#{authority.strip.chomp('/')}"
|
65
|
+
return
|
66
|
+
end
|
67
|
+
|
68
|
+
# rubocop:disable Style/IfUnlessModifier
|
69
|
+
unless parsed.scheme == 'https'
|
70
|
+
raise ArgumentError, "'#{authority}' is an invalid authority. The value must be a TLS protected (https) URL."
|
71
|
+
end
|
72
|
+
# rubocop:enable Style/IfUnlessModifier
|
73
|
+
|
74
|
+
@authority = authority.strip.chomp('/')
|
75
|
+
end
|
76
|
+
end
|
77
|
+
end
|
78
|
+
end
|
79
|
+
end
|
data/lib/fog/azurerm/storage.rb
CHANGED
@@ -1,3 +1,7 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
require 'json'
|
4
|
+
|
1
5
|
module Fog
|
2
6
|
module AzureRM
|
3
7
|
# This class registers models, requests and collections
|
@@ -80,6 +84,8 @@ module Fog
|
|
80
84
|
class Real
|
81
85
|
include Fog::AzureRM::Utilities::General
|
82
86
|
|
87
|
+
attr_accessor :options
|
88
|
+
|
83
89
|
def initialize(options)
|
84
90
|
begin
|
85
91
|
require 'azure/storage/common'
|
@@ -93,19 +99,19 @@ module Fog
|
|
93
99
|
raise e.message
|
94
100
|
end
|
95
101
|
|
96
|
-
|
97
|
-
|
98
|
-
|
102
|
+
options[:environment] = options[:environment] || ENV['AZURE_ENVIRONMENT'] || ENVIRONMENT_AZURE_CLOUD
|
103
|
+
@environment = options[:environment]
|
104
|
+
@options = options
|
99
105
|
|
100
106
|
@azure_storage_account_name = options[:azure_storage_account_name]
|
101
107
|
@azure_storage_access_key = options[:azure_storage_access_key]
|
102
|
-
|
108
|
+
|
109
|
+
load_credentials
|
110
|
+
|
111
|
+
@azure_storage_token_signer = token_signer
|
103
112
|
@azure_storage_endpoint = options[:azure_storage_endpoint]
|
104
113
|
@azure_storage_domain = options[:azure_storage_domain]
|
105
114
|
|
106
|
-
options[:environment] = 'AzureCloud' if options[:environment].nil?
|
107
|
-
@environment = options[:environment]
|
108
|
-
|
109
115
|
storage_blob_host =
|
110
116
|
@azure_storage_endpoint ||
|
111
117
|
if @azure_storage_domain.nil? || @azure_storage_domain.empty?
|
@@ -128,6 +134,26 @@ module Fog
|
|
128
134
|
|
129
135
|
private
|
130
136
|
|
137
|
+
def load_credentials
|
138
|
+
return options[:azure_storage_token_signer] if options[:azure_storage_token_signer]
|
139
|
+
return if @azure_storage_access_key && !@azure_storage_access_key.empty?
|
140
|
+
|
141
|
+
@credential_client = Fog::AzureRM::DefaultCredentials.new(options)
|
142
|
+
@credentials = @credential_client.fetch_credentials_if_needed
|
143
|
+
end
|
144
|
+
|
145
|
+
def token_signer
|
146
|
+
return options[:azure_storage_token_signer] if options[:azure_storage_token_signer]
|
147
|
+
return unless @credentials
|
148
|
+
|
149
|
+
access_token_signer(@credentials.token)
|
150
|
+
end
|
151
|
+
|
152
|
+
def access_token_signer(access_token)
|
153
|
+
cred = Azure::Storage::Common::Core::TokenCredential.new(access_token)
|
154
|
+
Azure::Storage::Common::Core::Auth::TokenSigner.new(cred)
|
155
|
+
end
|
156
|
+
|
131
157
|
def signature_client(requested_expiry)
|
132
158
|
access_key = @azure_storage_access_key.to_s
|
133
159
|
user_delegation_key = user_delegation_key(requested_expiry)
|
@@ -148,6 +174,11 @@ module Fog
|
|
148
174
|
def user_delegation_key(requested_expiry)
|
149
175
|
return nil unless @azure_storage_token_signer
|
150
176
|
|
177
|
+
if @credential_client
|
178
|
+
@credentials = @credential_client.fetch_credentials_if_needed
|
179
|
+
@azure_storage_token_signer = token_signer
|
180
|
+
end
|
181
|
+
|
151
182
|
@user_delegation_key_mutex ||= Mutex.new
|
152
183
|
@user_delegation_key_mutex.synchronize do
|
153
184
|
if @user_delegation_key_expiry.nil? || @user_delegation_key_expiry < requested_expiry
|
@@ -89,6 +89,28 @@ module Fog
|
|
89
89
|
end
|
90
90
|
end
|
91
91
|
|
92
|
+
# Per https://learn.microsoft.com/en-us/azure/storage/blobs/authorize-access-azure-active-directory,
|
93
|
+
# all endpoints use the same resource URL.
|
94
|
+
def storage_resource(_environment = ENVIRONMENT_AZURE_CLOUD)
|
95
|
+
'https://storage.azure.com'
|
96
|
+
end
|
97
|
+
|
98
|
+
# https://learn.microsoft.com/en-us/entra/identity-platform/authentication-national-cloud#microsoft-entra-authentication-endpoints
|
99
|
+
def authority_url(environment = ENVIRONMENT_AZURE_CLOUD)
|
100
|
+
case environment
|
101
|
+
when ENVIRONMENT_AZURE_CHINA_CLOUD
|
102
|
+
'https://login.chinacloudapi.cn'
|
103
|
+
when ENVIRONMENT_AZURE_US_GOVERNMENT
|
104
|
+
'https://login.microsoftonline.us'
|
105
|
+
# This region is deprecated:
|
106
|
+
# https://learn.microsoft.com/en-us/entra/identity-platform/authentication-national-cloud#azure-germany-microsoft-cloud-deutschland
|
107
|
+
when ENVIRONMENT_AZURE_GERMAN_CLOUD
|
108
|
+
'https://login.microsoftonline.de'
|
109
|
+
else
|
110
|
+
'https://login.microsoftonline.com'
|
111
|
+
end
|
112
|
+
end
|
113
|
+
|
92
114
|
def get_blob_endpoint(storage_account_name, enable_https = false, environment = ENVIRONMENT_AZURE_CLOUD)
|
93
115
|
protocol = enable_https ? 'https' : 'http'
|
94
116
|
"#{protocol}://#{storage_account_name}.blob#{storage_endpoint_suffix(environment)}"
|
data/lib/fog/azurerm/version.rb
CHANGED
data/lib/fog/azurerm.rb
CHANGED
data/rakefile
CHANGED
@@ -8,6 +8,7 @@ task :test do
|
|
8
8
|
Dir.glob('test/test_*.rb').each { |file| require File.expand_path file, __dir__ }
|
9
9
|
Dir.glob('test/models/**/test_*.rb').each { |file| require File.expand_path file, __dir__ }
|
10
10
|
Dir.glob('test/requests/**/test_*.rb').each { |file| require File.expand_path file, __dir__ }
|
11
|
+
Dir.glob('test/unit/**/test_*.rb').each { |file| require File.expand_path file, __dir__ }
|
11
12
|
end
|
12
13
|
|
13
14
|
task :integration do
|
@@ -112,7 +112,7 @@ class TestDirectory < Minitest::Test
|
|
112
112
|
# Set private
|
113
113
|
result = @directory.public = false
|
114
114
|
assert !result
|
115
|
-
|
115
|
+
assert_nil @directory.attributes[:acl]
|
116
116
|
end
|
117
117
|
|
118
118
|
def test_public_url_method_with_public_success
|
@@ -104,6 +104,55 @@ class TestGetBlobHttpsUrl < Minitest::Test
|
|
104
104
|
end
|
105
105
|
end
|
106
106
|
|
107
|
+
def test_get_blob_https_url_with_managed_identity
|
108
|
+
token_response = {
|
109
|
+
'access_token' => 'fake_token',
|
110
|
+
'expires_on' => (Time.now + 3600).to_i.to_s
|
111
|
+
}
|
112
|
+
|
113
|
+
stub_request(:get, "#{Fog::AzureRM::Identity::IDENTITY_ENDPOINT}?api-version=#{Fog::AzureRM::Identity::API_VERSION}&resource=https://storage.azure.com")
|
114
|
+
.with(headers: { 'Metadata' => 'true' })
|
115
|
+
.to_return(status: 200, body: token_response.to_json)
|
116
|
+
|
117
|
+
service = Fog::AzureRM::Storage.new(storage_account_managed_identity)
|
118
|
+
|
119
|
+
requested_expiry = Time.now + 60
|
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
|
133
|
+
|
134
|
+
stub_request(:post, 'https://mockaccount.blob.core.windows.net?comp=userdelegationkey&restype=service')
|
135
|
+
.to_return(status: 200, headers: { 'Content-Type': 'application/xml' }, body: response)
|
136
|
+
|
137
|
+
url = service.get_blob_https_url('test_container', 'test_blob', requested_expiry)
|
138
|
+
|
139
|
+
parsed = URI.parse(url)
|
140
|
+
|
141
|
+
assert_equal 'https', parsed.scheme
|
142
|
+
assert_equal 'mockaccount.blob.core.windows.net', parsed.host
|
143
|
+
assert_equal '/test_container/test_blob', parsed.path
|
144
|
+
|
145
|
+
params = parsed.query.split('&').to_h { |x| x.split('=') }
|
146
|
+
|
147
|
+
assert_equal 'r', params['sp']
|
148
|
+
assert_equal '2018-11-09', params['sv']
|
149
|
+
assert_equal 'b', params['sr']
|
150
|
+
assert_equal 'https', params['spr']
|
151
|
+
assert_equal '2024-09-19T00%3A00%3A00Z', params['skt']
|
152
|
+
assert_equal '2024-09-26T00%3A00%3A00Z', params['ske']
|
153
|
+
assert_equal 'b', params['sks']
|
154
|
+
end
|
155
|
+
|
107
156
|
def test_get_blob_https_url_with_token_signer_success
|
108
157
|
service = Fog::AzureRM::Storage.new(storage_account_credentials_with_token_signer)
|
109
158
|
blob_client = service.instance_variable_get(:@blob_client)
|
@@ -132,7 +181,7 @@ class TestGetBlobHttpsUrl < Minitest::Test
|
|
132
181
|
# second request within new expiry
|
133
182
|
stubbed_times << ref_time + 10.5 * DAY
|
134
183
|
requested_expiries << stubbed_times.last + 1 * DAY
|
135
|
-
# no
|
184
|
+
# no additional expected_user_delegation_key_starts
|
136
185
|
|
137
186
|
user_delegation_key_starts = []
|
138
187
|
mock_user_delegation_key = lambda do |start, expiry|
|
data/test/test_helper.rb
CHANGED
@@ -1,3 +1,6 @@
|
|
1
|
+
require 'webmock/minitest'
|
2
|
+
WebMock.disable_net_connect! allow: %w[127.0.0.1]
|
3
|
+
|
1
4
|
if ENV['COVERAGE']
|
2
5
|
require 'simplecov'
|
3
6
|
SimpleCov.start do
|
@@ -42,6 +45,12 @@ def storage_account_credentials
|
|
42
45
|
}
|
43
46
|
end
|
44
47
|
|
48
|
+
def storage_account_managed_identity
|
49
|
+
{
|
50
|
+
azure_storage_account_name: 'mockaccount'
|
51
|
+
}
|
52
|
+
end
|
53
|
+
|
45
54
|
def storage_account_credentials_with_endpoint
|
46
55
|
storage_account_credentials.merge(
|
47
56
|
{
|
@@ -0,0 +1,97 @@
|
|
1
|
+
require File.expand_path '../test_helper', __dir__
|
2
|
+
|
3
|
+
class TestDefaultCredentials < Minitest::Test
|
4
|
+
def setup
|
5
|
+
@options = { environment: 'AzureCloud' }
|
6
|
+
@default_credentials = Fog::AzureRM::DefaultCredentials.new(@options)
|
7
|
+
end
|
8
|
+
|
9
|
+
def test_initialize
|
10
|
+
assert_instance_of Fog::AzureRM::DefaultCredentials, @default_credentials
|
11
|
+
assert_nil @default_credentials.instance_variable_get(:@credential_client)
|
12
|
+
assert_nil @default_credentials.instance_variable_get(:@credentials)
|
13
|
+
end
|
14
|
+
|
15
|
+
def test_fetch_credentials_if_needed_with_no_credential_client
|
16
|
+
@default_credentials.stub(:credential_client, nil) do
|
17
|
+
assert_nil @default_credentials.fetch_credentials_if_needed
|
18
|
+
end
|
19
|
+
end
|
20
|
+
|
21
|
+
def test_fetch_credentials_if_needed_with_credential_client
|
22
|
+
mock_client = Minitest::Mock.new
|
23
|
+
mock_client.expect :fetch_credentials_if_needed, 'fake_credentials'
|
24
|
+
@default_credentials.instance_variable_set(:@credential_client, mock_client)
|
25
|
+
|
26
|
+
assert_equal 'fake_credentials', @default_credentials.fetch_credentials_if_needed
|
27
|
+
mock_client.verify
|
28
|
+
end
|
29
|
+
|
30
|
+
def test_credential_client_with_workflow_identity
|
31
|
+
mock_workflow_client = Minitest::Mock.new
|
32
|
+
mock_workflow_client.expect :fetch_credentials, 'fake_workflow_credentials'
|
33
|
+
|
34
|
+
Fog::AzureRM::Identity::WorkflowIdentityClient.stub :new, mock_workflow_client do
|
35
|
+
credentials = @default_credentials.send(:credential_client)
|
36
|
+
assert_equal 'fake_workflow_credentials', credentials
|
37
|
+
end
|
38
|
+
|
39
|
+
mock_workflow_client.verify
|
40
|
+
end
|
41
|
+
|
42
|
+
def test_credential_client_with_managed_identity
|
43
|
+
mock_workflow_client = Minitest::Mock.new
|
44
|
+
mock_workflow_client.expect :fetch_credentials, nil
|
45
|
+
|
46
|
+
mock_managed_client = Minitest::Mock.new
|
47
|
+
mock_managed_client.expect :fetch_credentials, 'fake_managed_credentials'
|
48
|
+
|
49
|
+
Fog::AzureRM::Identity::WorkflowIdentityClient.stub :new, mock_workflow_client do
|
50
|
+
Fog::AzureRM::Identity::ManagedIdentityClient.stub :new, mock_managed_client do
|
51
|
+
credentials = @default_credentials.send(:credential_client)
|
52
|
+
assert_equal 'fake_managed_credentials', credentials
|
53
|
+
end
|
54
|
+
end
|
55
|
+
|
56
|
+
mock_workflow_client.verify
|
57
|
+
mock_managed_client.verify
|
58
|
+
end
|
59
|
+
|
60
|
+
def test_credential_client_with_failed_workflow_identity
|
61
|
+
mock_workflow_client = Minitest::Mock.new
|
62
|
+
def mock_workflow_client.fetch_credentials
|
63
|
+
raise ::Fog::AzureRM::Identity::BaseClient::FetchCredentialsError, 'Failed to fetch credentials'
|
64
|
+
end
|
65
|
+
|
66
|
+
mock_managed_client = Minitest::Mock.new
|
67
|
+
mock_managed_client.expect :fetch_credentials, 'fake_managed_credentials'
|
68
|
+
|
69
|
+
Fog::AzureRM::Identity::WorkflowIdentityClient.stub :new, mock_workflow_client do
|
70
|
+
Fog::AzureRM::Identity::ManagedIdentityClient.stub :new, mock_managed_client do
|
71
|
+
credentials = @default_credentials.send(:credential_client)
|
72
|
+
assert_equal 'fake_managed_credentials', credentials
|
73
|
+
end
|
74
|
+
end
|
75
|
+
|
76
|
+
mock_workflow_client.verify
|
77
|
+
mock_managed_client.verify
|
78
|
+
end
|
79
|
+
|
80
|
+
def test_credential_client_with_no_credentials
|
81
|
+
mock_workflow_client = Minitest::Mock.new
|
82
|
+
mock_workflow_client.expect :fetch_credentials, nil
|
83
|
+
|
84
|
+
mock_managed_client = Minitest::Mock.new
|
85
|
+
mock_managed_client.expect :fetch_credentials, nil
|
86
|
+
|
87
|
+
Fog::AzureRM::Identity::WorkflowIdentityClient.stub :new, mock_workflow_client do
|
88
|
+
Fog::AzureRM::Identity::ManagedIdentityClient.stub :new, mock_managed_client do
|
89
|
+
assert_nil @default_credentials.send(:credential_client)
|
90
|
+
assert_nil @default_credentials.instance_variable_get(:@credential_client)
|
91
|
+
end
|
92
|
+
end
|
93
|
+
|
94
|
+
mock_workflow_client.verify
|
95
|
+
mock_managed_client.verify
|
96
|
+
end
|
97
|
+
end
|
@@ -0,0 +1,74 @@
|
|
1
|
+
require File.expand_path '../test_helper', __dir__
|
2
|
+
|
3
|
+
class TestManagedIdentityClient < Minitest::Test
|
4
|
+
def setup
|
5
|
+
@options = { environment: 'AzureCloud' }
|
6
|
+
@client = Fog::AzureRM::Identity::ManagedIdentityClient.new(@options)
|
7
|
+
end
|
8
|
+
|
9
|
+
def teardown
|
10
|
+
ENV.delete('AZURE_CLIENT_ID')
|
11
|
+
ENV.delete('IDENTITY_HEADER')
|
12
|
+
ENV.delete('IDENTITY_ENDPOINT')
|
13
|
+
end
|
14
|
+
|
15
|
+
def test_initialize
|
16
|
+
assert_equal 'https://storage.azure.com', @client.resource
|
17
|
+
end
|
18
|
+
|
19
|
+
def test_fetch_credentials_success
|
20
|
+
token_response = {
|
21
|
+
'access_token' => 'fake_token',
|
22
|
+
'expires_on' => (Time.now + 3600).to_i.to_s
|
23
|
+
}
|
24
|
+
|
25
|
+
stub_request(:get, "#{Fog::AzureRM::Identity::IDENTITY_ENDPOINT}?api-version=#{Fog::AzureRM::Identity::API_VERSION}&resource=#{CGI.escape(@client.resource)}")
|
26
|
+
.with(headers: { 'Metadata' => 'true' })
|
27
|
+
.to_return(status: 200, body: token_response.to_json)
|
28
|
+
|
29
|
+
credentials = @client.fetch_credentials
|
30
|
+
|
31
|
+
assert_equal 'fake_token', credentials.token
|
32
|
+
assert_instance_of Time, credentials.expires_at
|
33
|
+
end
|
34
|
+
|
35
|
+
def test_fetch_credentials_timeout
|
36
|
+
stub_request(:get, "#{Fog::AzureRM::Identity::IDENTITY_ENDPOINT}?api-version=#{Fog::AzureRM::Identity::API_VERSION}&resource=#{CGI.escape(@client.resource)}")
|
37
|
+
.with(headers: { 'Metadata' => 'true' })
|
38
|
+
.to_raise(Faraday::Error)
|
39
|
+
|
40
|
+
assert_raises(Fog::AzureRM::Identity::BaseClient::FetchCredentialsError) do
|
41
|
+
@client.fetch_credentials
|
42
|
+
end
|
43
|
+
end
|
44
|
+
|
45
|
+
def test_fetch_credentials_unauthorized
|
46
|
+
stub_request(:get, "#{Fog::AzureRM::Identity::IDENTITY_ENDPOINT}?api-version=#{Fog::AzureRM::Identity::API_VERSION}&resource=#{CGI.escape(@client.resource)}")
|
47
|
+
.with(headers: { 'Metadata' => 'true' })
|
48
|
+
.to_return(status: 401)
|
49
|
+
|
50
|
+
assert_raises(Fog::AzureRM::Identity::BaseClient::FetchCredentialsError) do
|
51
|
+
@client.fetch_credentials
|
52
|
+
end
|
53
|
+
end
|
54
|
+
|
55
|
+
def test_fetch_credentials_with_env_vars
|
56
|
+
token_response = {
|
57
|
+
'access_token' => 'fake_token',
|
58
|
+
'expires_on' => (Time.now + 3600).to_i.to_s
|
59
|
+
}
|
60
|
+
|
61
|
+
ENV['AZURE_CLIENT_ID'] = 'fake_client_id'
|
62
|
+
ENV['IDENTITY_HEADER'] = 'fake_identity_header'
|
63
|
+
ENV['IDENTITY_ENDPOINT'] = 'http://localhost:8080/metadata/identity/oauth2/token'
|
64
|
+
|
65
|
+
stub_request(:get, "http://localhost:8080/metadata/identity/oauth2/token?api-version=2019-08-01&client_id=fake_client_id&resource=#{CGI.escape(@client.resource)}")
|
66
|
+
.with(headers: { 'Metadata' => 'true', 'X-Identity-Header' => 'fake_identity_header' })
|
67
|
+
.to_return(status: 200, body: token_response.to_json)
|
68
|
+
|
69
|
+
credentials = @client.fetch_credentials
|
70
|
+
|
71
|
+
assert_equal 'fake_token', credentials.token
|
72
|
+
assert_instance_of Time, credentials.expires_at
|
73
|
+
end
|
74
|
+
end
|
@@ -0,0 +1,147 @@
|
|
1
|
+
require File.expand_path '../test_helper', __dir__
|
2
|
+
|
3
|
+
class TestWorkflowIdentityClient < Minitest::Test
|
4
|
+
def setup
|
5
|
+
@options = { environment: 'AzureCloud' }
|
6
|
+
@client = Fog::AzureRM::Identity::WorkflowIdentityClient.new(@options)
|
7
|
+
end
|
8
|
+
|
9
|
+
def teardown
|
10
|
+
ENV.delete('AZURE_TENANT_ID')
|
11
|
+
ENV.delete('AZURE_CLIENT_ID')
|
12
|
+
ENV.delete('AZURE_FEDERATED_TOKEN_FILE')
|
13
|
+
ENV.delete('AZURE_AUTHORITY_HOST')
|
14
|
+
end
|
15
|
+
|
16
|
+
def test_initialize
|
17
|
+
assert_equal 'https://storage.azure.com', @client.resource
|
18
|
+
assert_equal 'https://login.microsoftonline.com', @client.authority
|
19
|
+
assert_nil @client.tenant_id
|
20
|
+
assert_nil @client.client_id
|
21
|
+
end
|
22
|
+
|
23
|
+
def test_fetch_credentials_success
|
24
|
+
ENV['AZURE_TENANT_ID'] = 'fake_tenant_id'
|
25
|
+
ENV['AZURE_CLIENT_ID'] = 'fake_client_id'
|
26
|
+
ENV['AZURE_FEDERATED_TOKEN_FILE'] = 'fake_token_file'
|
27
|
+
|
28
|
+
@client = Fog::AzureRM::Identity::WorkflowIdentityClient.new(@options)
|
29
|
+
|
30
|
+
token_response = {
|
31
|
+
'access_token' => 'fake_access_token',
|
32
|
+
'expires_on' => (Time.now + 3600).to_i.to_s
|
33
|
+
}
|
34
|
+
|
35
|
+
File.stub :exist?, true do
|
36
|
+
File.stub :readable?, true do
|
37
|
+
File.stub :read, 'fake_oidc_token' do
|
38
|
+
stub_request(:post, "#{@client.authority}/fake_tenant_id/oauth2/v2.0/token")
|
39
|
+
.to_return(status: 200, body: token_response.to_json)
|
40
|
+
|
41
|
+
credentials = @client.fetch_credentials
|
42
|
+
|
43
|
+
assert_equal 'fake_access_token', credentials.token
|
44
|
+
assert_instance_of Time, credentials.expires_at
|
45
|
+
end
|
46
|
+
end
|
47
|
+
end
|
48
|
+
end
|
49
|
+
|
50
|
+
def test_fetch_credentials_failure
|
51
|
+
ENV['AZURE_TENANT_ID'] = 'fake_tenant_id'
|
52
|
+
ENV['AZURE_CLIENT_ID'] = 'fake_client_id'
|
53
|
+
ENV['AZURE_FEDERATED_TOKEN_FILE'] = 'fake_token_file'
|
54
|
+
|
55
|
+
@client = Fog::AzureRM::Identity::WorkflowIdentityClient.new(@options)
|
56
|
+
|
57
|
+
File.stub :exist?, true do
|
58
|
+
File.stub :readable?, true do
|
59
|
+
File.stub :read, 'fake_oidc_token' do
|
60
|
+
stub_request(:post, "#{@client.authority}/fake_tenant_id/oauth2/v2.0/token")
|
61
|
+
.to_raise(Faraday::Error)
|
62
|
+
|
63
|
+
assert_raises(Fog::AzureRM::Identity::BaseClient::FetchCredentialsError) do
|
64
|
+
@client.fetch_credentials
|
65
|
+
end
|
66
|
+
end
|
67
|
+
end
|
68
|
+
end
|
69
|
+
end
|
70
|
+
|
71
|
+
def test_fetch_credentials_unauthorized
|
72
|
+
ENV['AZURE_TENANT_ID'] = 'fake_tenant_id'
|
73
|
+
ENV['AZURE_CLIENT_ID'] = 'fake_client_id'
|
74
|
+
ENV['AZURE_FEDERATED_TOKEN_FILE'] = 'fake_token_file'
|
75
|
+
|
76
|
+
@client = Fog::AzureRM::Identity::WorkflowIdentityClient.new(@options)
|
77
|
+
|
78
|
+
File.stub :exist?, true do
|
79
|
+
File.stub :readable?, true do
|
80
|
+
File.stub :read, 'fake_oidc_token' do
|
81
|
+
stub_request(:post, "#{@client.authority}/fake_tenant_id/oauth2/v2.0/token")
|
82
|
+
.to_return(status: 403, body: '{"error":"invalid_client"}')
|
83
|
+
|
84
|
+
assert_raises(Fog::AzureRM::Identity::BaseClient::FetchCredentialsError) { @client.fetch_credentials }
|
85
|
+
end
|
86
|
+
end
|
87
|
+
end
|
88
|
+
end
|
89
|
+
|
90
|
+
def test_fetch_credentials_missing_env_vars
|
91
|
+
ENV['AZURE_TENANT_ID'] = nil
|
92
|
+
ENV['AZURE_CLIENT_ID'] = nil
|
93
|
+
ENV['AZURE_FEDERATED_TOKEN_FILE'] = nil
|
94
|
+
|
95
|
+
assert_nil @client.fetch_credentials
|
96
|
+
end
|
97
|
+
|
98
|
+
def test_fetch_credentials_missing_token_file
|
99
|
+
ENV['AZURE_TENANT_ID'] = 'fake_tenant_id'
|
100
|
+
ENV['AZURE_CLIENT_ID'] = 'fake_client_id'
|
101
|
+
ENV['AZURE_FEDERATED_TOKEN_FILE'] = 'nonexistent_file'
|
102
|
+
|
103
|
+
File.stub :exist?, false do
|
104
|
+
assert_nil @client.fetch_credentials
|
105
|
+
end
|
106
|
+
end
|
107
|
+
|
108
|
+
def test_authority
|
109
|
+
ENV['AZURE_TENANT_ID'] = 'fake_tenant_id'
|
110
|
+
ENV['AZURE_CLIENT_ID'] = 'fake_client_id'
|
111
|
+
ENV['AZURE_FEDERATED_TOKEN_FILE'] = 'fake_token_file'
|
112
|
+
|
113
|
+
# Test with default AzureCloud environment
|
114
|
+
client = Fog::AzureRM::Identity::WorkflowIdentityClient.new(@options)
|
115
|
+
assert_equal 'https://login.microsoftonline.com', client.authority
|
116
|
+
assert_equal 'https://storage.azure.com', client.resource
|
117
|
+
|
118
|
+
# Test with AzureUSGovernment environment
|
119
|
+
gov_options = { environment: 'AzureUSGovernment' }
|
120
|
+
gov_client = Fog::AzureRM::Identity::WorkflowIdentityClient.new(gov_options)
|
121
|
+
assert_equal 'https://login.microsoftonline.us', gov_client.authority
|
122
|
+
assert_equal 'https://storage.azure.com', gov_client.resource
|
123
|
+
|
124
|
+
# Test with AzureChina environment
|
125
|
+
china_options = { environment: 'AzureChinaCloud' }
|
126
|
+
china_client = Fog::AzureRM::Identity::WorkflowIdentityClient.new(china_options)
|
127
|
+
assert_equal 'https://login.chinacloudapi.cn', china_client.authority
|
128
|
+
assert_equal 'https://storage.azure.com', china_client.resource
|
129
|
+
|
130
|
+
# Test with AzureGermanCloud environment
|
131
|
+
german_options = { environment: 'AzureGermanCloud' }
|
132
|
+
german_client = Fog::AzureRM::Identity::WorkflowIdentityClient.new(german_options)
|
133
|
+
assert_equal 'https://login.microsoftonline.de', german_client.authority
|
134
|
+
assert_equal 'https://storage.azure.com', german_client.resource
|
135
|
+
end
|
136
|
+
|
137
|
+
def test_authority_normalize
|
138
|
+
ENV['AZURE_TENANT_ID'] = 'fake_tenant_id'
|
139
|
+
ENV['AZURE_CLIENT_ID'] = 'fake_client_id'
|
140
|
+
ENV['AZURE_FEDERATED_TOKEN_FILE'] = 'fake_token_file'
|
141
|
+
ENV['AZURE_AUTHORITY_HOST'] = 'login.microsoftonline.com'
|
142
|
+
|
143
|
+
# Test with default AzureCloud environment
|
144
|
+
client = Fog::AzureRM::Identity::WorkflowIdentityClient.new(@options)
|
145
|
+
assert_equal 'https://login.microsoftonline.com', client.authority
|
146
|
+
end
|
147
|
+
end
|
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.
|
4
|
+
version: 2.2.0
|
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: 2024-
|
21
|
+
date: 2024-11-21 00:00:00.000000000 Z
|
22
22
|
dependencies:
|
23
23
|
- !ruby/object:Gem::Dependency
|
24
24
|
name: codeclimate-test-reporter
|
@@ -90,6 +90,20 @@ dependencies:
|
|
90
90
|
- - ">="
|
91
91
|
- !ruby/object:Gem::Version
|
92
92
|
version: '0'
|
93
|
+
- !ruby/object:Gem::Dependency
|
94
|
+
name: webmock
|
95
|
+
requirement: !ruby/object:Gem::Requirement
|
96
|
+
requirements:
|
97
|
+
- - ">="
|
98
|
+
- !ruby/object:Gem::Version
|
99
|
+
version: '0'
|
100
|
+
type: :development
|
101
|
+
prerelease: false
|
102
|
+
version_requirements: !ruby/object:Gem::Requirement
|
103
|
+
requirements:
|
104
|
+
- - ">="
|
105
|
+
- !ruby/object:Gem::Version
|
106
|
+
version: '0'
|
93
107
|
- !ruby/object:Gem::Dependency
|
94
108
|
name: webrick
|
95
109
|
requirement: !ruby/object:Gem::Requirement
|
@@ -321,6 +335,12 @@ files:
|
|
321
335
|
- lib/fog/azurerm/custom_fog_errors.rb
|
322
336
|
- lib/fog/azurerm/docs/storage.md
|
323
337
|
- lib/fog/azurerm/docs/structure.md
|
338
|
+
- lib/fog/azurerm/identity.rb
|
339
|
+
- lib/fog/azurerm/identity/base_client.rb
|
340
|
+
- lib/fog/azurerm/identity/credentials.rb
|
341
|
+
- lib/fog/azurerm/identity/default_credentials.rb
|
342
|
+
- lib/fog/azurerm/identity/managed_identity_client.rb
|
343
|
+
- lib/fog/azurerm/identity/workflow_identity_client.rb
|
324
344
|
- lib/fog/azurerm/identity_encoding_filter.rb
|
325
345
|
- lib/fog/azurerm/models/storage/directories.rb
|
326
346
|
- lib/fog/azurerm/models/storage/directory.rb
|
@@ -430,6 +450,9 @@ files:
|
|
430
450
|
- test/requests/storage/test_save_page_blob.rb
|
431
451
|
- test/requests/storage/test_wait_blob_copy_operation_to_finish.rb
|
432
452
|
- test/test_helper.rb
|
453
|
+
- test/unit/test_default_credentials.rb
|
454
|
+
- test/unit/test_managed_identity_client.rb
|
455
|
+
- test/unit/test_workflow_identity_client.rb
|
433
456
|
homepage: https://gitlab.com/gitlab-org/gitlab-fog-azure-rm
|
434
457
|
licenses:
|
435
458
|
- MIT
|
@@ -450,7 +473,7 @@ required_rubygems_version: !ruby/object:Gem::Requirement
|
|
450
473
|
- !ruby/object:Gem::Version
|
451
474
|
version: '0'
|
452
475
|
requirements: []
|
453
|
-
rubygems_version: 3.
|
476
|
+
rubygems_version: 3.5.18
|
454
477
|
signing_key:
|
455
478
|
specification_version: 4
|
456
479
|
summary: Module for the 'fog' gem to support Azure Blob Storage with CarrierWave and
|