From a8a01151777f250ad9c71a79715d928af71e5ddb Mon Sep 17 00:00:00 2001
From: Clemens Wolff <clewolff@microsoft.com>
Date: Thu, 23 Jan 2020 16:13:18 -0500
Subject: [PATCH] Implement SAS with user delegation key

---
 Gemfile                                       |  1 +
 .../auth/shared_access_signature_generator.rb | 55 +++++++++++++-
 common/lib/azure/storage/common/default.rb    |  2 +-
 ...gation_key_shared_access_signature_test.rb | 76 +++++++++++++++++++
 .../core/auth/shared_access_signature_test.rb |  1 +
 5 files changed, 130 insertions(+), 5 deletions(-)
 create mode 100644 test/integration/auth/user_delegation_key_shared_access_signature_test.rb

diff --git a/Gemfile b/Gemfile
index e8d2bca..e934acd 100644
--- a/Gemfile
+++ b/Gemfile
@@ -28,6 +28,7 @@ source "https://rubygems.org" do
   gem "faraday_middleware", :require => false
   gem "nokogiri",            "~> 1.10.4", :require => false
 
+  gem "adal",                "~> 1.0", :require => false
   gem "dotenv",              "~> 2.0", :require => false
   gem "minitest",            "~> 5", :require => false
   gem "minitest-reporters",  "~> 1", :require => false
diff --git a/common/lib/azure/storage/common/core/auth/shared_access_signature_generator.rb b/common/lib/azure/storage/common/core/auth/shared_access_signature_generator.rb
index 0381574..adbd00e 100644
--- a/common/lib/azure/storage/common/core/auth/shared_access_signature_generator.rb
+++ b/common/lib/azure/storage/common/core/auth/shared_access_signature_generator.rb
@@ -67,8 +67,18 @@ module Azure::Storage::Common::Core
         ip_range:             :sip
       }
 
+      USER_DELEGATION_KEY_MAPPINGS = {
+        signed_oid:           :skoid,
+        signed_tid:           :sktid,
+        signed_start:         :skt,
+        signed_expiry:        :ske,
+        signed_service:       :sks,
+        signed_version:       :skv
+      }
+
       BLOB_KEY_MAPPINGS = {
         resource:             :sr,
+        timestamp:            :snapshot,
         cache_control:        :rscc,
         content_disposition:  :rscd,
         content_encoding:     :rsce,
@@ -103,13 +113,20 @@ module Azure::Storage::Common::Core
       #
       # @param account_name [String] The account name. Defaults to the one in the global configuration.
       # @param access_key [String]   The access_key encoded in Base64. Defaults to the one in the global configuration.
-      def initialize(account_name = "", access_key = "")
+      # @param user_delegation_key [Azure::Storage::Common::UserDelegationKey] The user delegation key obtained from
+      # calling get_user_delegation_key after authenticating with an Azure Active Directory entity. If present, the
+      # SAS is signed with the user delegation key instead of the access key.
+      def initialize(account_name = "", access_key = "", user_delegation_key = nil)
+        if access_key.empty? && !user_delegation_key.nil?
+          access_key = user_delegation_key.value
+        end
         if account_name.empty? || access_key.empty?
           client = Azure::Storage::Common::Client.create_from_env
           account_name = client.storage_account_name if account_name.empty?
           access_key = client.storage_access_key if access_key.empty?
         end
         @account_name = account_name
+        @user_delegation_key = user_delegation_key
         @signer = Azure::Core::Auth::Signer.new(access_key)
       end
 
@@ -131,10 +148,12 @@ module Azure::Storage::Common::Core
       # * +:start+               - String. Optional. UTC Date/Time in ISO8601 format.
       # * +:expiry+              - String. Optional. UTC Date/Time in ISO8601 format. Default now + 30 minutes.
       # * +:identifier+          - String. Optional. Identifier for stored access policy.
+      #                                              This option must be omitted if a user delegation key has been provided.
       # * +:protocol+            - String. Optional. Permitted protocols.
       # * +:ip_range+            - String. Optional. An IP address or a range of IP addresses from which to accept requests.
       #
       # Below options for blob serivce only
+      # * +:snapshot+            - String. Optional. UTC Date/Time in ISO8601 format. The blob snapshot to grant permission.
       # * +:cache_control+       - String. Optional. Response header override.
       # * +:content_disposition+ - String. Optional. Response header override.
       # * +:content_encoding+    - String. Optional. Response header override.
@@ -175,12 +194,20 @@ module Azure::Storage::Common::Core
           valid_mappings.merge!(FILE_KEY_MAPPINGS)
         end
 
+        service_key_mappings = SERVICE_KEY_MAPPINGS
+        unless @user_delegation_key.nil?
+          valid_mappings.delete(:identifier)
+          USER_DELEGATION_KEY_MAPPINGS.each { |k, _| options[k] = @user_delegation_key.send(k) }
+          valid_mappings.merge!(USER_DELEGATION_KEY_MAPPINGS)
+          service_key_mappings = service_key_mappings.merge(USER_DELEGATION_KEY_MAPPINGS)
+        end
+
         invalid_options = options.reject { |k, _| valid_mappings.key?(k) }
         raise Azure::Storage::Common::InvalidOptionsError, "invalid options #{invalid_options} provided for SAS token generate" if invalid_options.length > 0
 
         canonicalize_time(options)
 
-        query_hash = Hash[options.map { |k, v| [SERVICE_KEY_MAPPINGS[k], v] }]
+        query_hash = Hash[options.map { |k, v| [service_key_mappings[k], v] }]
         .reject { |k, v| SERVICE_OPTIONAL_QUERY_PARAMS.include?(k) && v.to_s == "" }
         .merge(sig: @signer.sign(signable_string_for_service(service_type, path, options)))
 
@@ -197,13 +224,33 @@ module Azure::Storage::Common::Core
           options[:permissions],
           options[:start],
           options[:expiry],
-          canonicalized_resource(service_type, path),
-          options[:identifier],
+          canonicalized_resource(service_type, path)
+        ]
+
+        if @user_delegation_key.nil?
+          signable_fields.push(options[:identifier])
+        else
+          signable_fields.concat [
+            @user_delegation_key.signed_oid,
+            @user_delegation_key.signed_tid,
+            @user_delegation_key.signed_start,
+            @user_delegation_key.signed_expiry,
+            @user_delegation_key.signed_service,
+            @user_delegation_key.signed_version
+          ]
+        end
+
+        signable_fields.concat [
           options[:ip_range],
           options[:protocol],
           Azure::Storage::Common::Default::STG_VERSION
         ]
 
+        signable_fields.concat [
+          options[:resource],
+          options[:timestamp]
+        ] if service_type == Azure::Storage::Common::ServiceType::BLOB
+
         signable_fields.concat [
           options[:cache_control],
           options[:content_disposition],
diff --git a/common/lib/azure/storage/common/default.rb b/common/lib/azure/storage/common/default.rb
index 4e0d3e1..197d3c4 100644
--- a/common/lib/azure/storage/common/default.rb
+++ b/common/lib/azure/storage/common/default.rb
@@ -30,7 +30,7 @@ require "azure/storage/common/version"
 module Azure::Storage::Common
   module Default
     # Default REST service (STG) version number. This is used only for SAS generator.
-    STG_VERSION = "2017-11-09"
+    STG_VERSION = "2018-11-09"
 
     # The number of default concurrent requests for parallel operation.
     DEFAULT_PARALLEL_OPERATION_THREAD_COUNT = 1
diff --git a/test/integration/auth/user_delegation_key_shared_access_signature_test.rb b/test/integration/auth/user_delegation_key_shared_access_signature_test.rb
new file mode 100644
index 0000000..d0fefa1
--- /dev/null
+++ b/test/integration/auth/user_delegation_key_shared_access_signature_test.rb
@@ -0,0 +1,76 @@
+#-------------------------------------------------------------------------
+# # Copyright (c) Microsoft and contributors. All rights reserved.
+#
+# The MIT License(MIT)
+
+# Permission is hereby granted, free of charge, to any person obtaining a copy
+# of this software and associated documentation files(the "Software"), to deal
+# in the Software without restriction, including without limitation the rights
+# to use, copy, modify, merge, publish, distribute, sublicense, and / or sell
+# copies of the Software, and to permit persons to whom the Software is
+# furnished to do so, subject to the following conditions :
+
+# The above copyright notice and this permission notice shall be included in
+# all copies or substantial portions of the Software.
+
+# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
+# IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
+# FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT.IN NO EVENT SHALL THE
+# AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
+# LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
+# OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
+# THE SOFTWARE.
+#--------------------------------------------------------------------------
+require "adal"
+require "azure/storage/common"
+require "azure/storage/common/core/auth/shared_access_signature"
+require "integration/test_helper"
+require "net/http"
+
+describe Azure::Storage::Common::Core::Auth::SharedAccessSignature do
+  subject {
+    tenant_id = ENV.fetch("AZURE_TENANT_ID", nil)
+    client_id = ENV.fetch("AZURE_CLIENT_ID", nil)
+    client_secret = ENV.fetch("AZURE_CLIENT_SECRET", nil)
+    storage_account_name = SERVICE_CREATE_OPTIONS()[:storage_account_name]
+
+    if tenant_id.nil? || client_id.nil? || client_secret.nil?
+      skip "AAD credentials not provided"
+    end
+
+    auth_ctx = ADAL::AuthenticationContext.new("login.microsoftonline.com", tenant_id)
+    client_cred = ADAL::ClientCredential.new(client_id, client_secret)
+    token = auth_ctx.acquire_token_for_client("https://storage.azure.com/", client_cred)
+    access_token = token.access_token
+
+    token_credential = Azure::Storage::Common::Core::TokenCredential.new access_token
+    token_signer = Azure::Storage::Common::Core::Auth::TokenSigner.new token_credential
+    client = Azure::Storage::Common::Client::create(storage_account_name: storage_account_name, signer: token_signer)
+    Azure::Storage::Blob::BlobService.new(api_version: "2018-11-09", client: client)
+  }
+
+  let(:generator) {
+    user_delegation_key = subject.get_user_delegation_key(Time.now - 60 * 5, Time.now + 60 * 15)
+    storage_account_name = SERVICE_CREATE_OPTIONS()[:storage_account_name]
+
+    Azure::Storage::Common::Core::Auth::SharedAccessSignature.new(storage_account_name, "", user_delegation_key)
+  }
+
+  describe "#blob_service_sas_for_container" do
+    let(:container_name) { ContainerNameHelper.name }
+    let(:block_blob_name) { BlobNameHelper.name }
+    let(:content) { content = ""; 512.times.each { |i| content << "@" }; content }
+
+    before {
+      subject.create_container container_name
+      subject.create_block_blob container_name, block_blob_name, content
+    }
+
+    after { ContainerNameHelper.clean }
+
+    it "fetches blob from sas uri" do
+      uri = generator.signed_uri(subject.generate_uri("#{container_name}/#{block_blob_name}"), false, service: "b", permissions: "r", expiry: (Time.now.utc + 60 * 10).iso8601)
+      _(Net::HTTP.get(uri)).must_equal content
+    end
+  end
+end
diff --git a/test/unit/core/auth/shared_access_signature_test.rb b/test/unit/core/auth/shared_access_signature_test.rb
index db99a1c..e8c396c 100644
--- a/test/unit/core/auth/shared_access_signature_test.rb
+++ b/test/unit/core/auth/shared_access_signature_test.rb
@@ -66,6 +66,7 @@ describe Azure::Storage::Common::Core::Auth::SharedAccessSignature do
       _(subject.signable_string_for_service(service_type, path, service_options)).must_equal(
         "rwd\n#{Time.parse('2020-12-10T00:00:00Z').utc.iso8601}\n#{Time.parse('2020-12-11T00:00:00Z').utc.iso8601}\n" +
         "/blob/account-name/example/path\n\n168.1.5.60-168.1.5.70\nhttps,http\n#{Azure::Storage::Common::Default::STG_VERSION}\n" +
+        "b\n\n" +
         "public\ninline, filename=nyan.cat\ngzip\nEnglish\nbinary"
       )
     end
-- 
GitLab