gitlab-fog-azure-rm 2.1.0 → 2.2.0
Sign up to get free protection for your applications and to get access to all the features.
- 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
|