Skip to content

Commit

Permalink
Merge pull request #42723 from RRethy/gcs-iam-url-signing
Browse files Browse the repository at this point in the history
Use IAM for URL signing with GCS when no credentials are provided
  • Loading branch information
rafaelfranca committed Jul 13, 2021
2 parents a8a54a1 + dfd19e1 commit ac87404
Show file tree
Hide file tree
Showing 5 changed files with 133 additions and 7 deletions.
10 changes: 10 additions & 0 deletions activestorage/CHANGELOG.md
@@ -1,3 +1,13 @@
* Allow using [IAM](https://cloud.google.com/storage/docs/access-control/signed-urls) when signing URLs with GCS.

```yaml
gcs:
service: GCS
...
iam: true
```
*RRethy*

* OpenSSL constants are now used for Digest computations.

*Dirkjan Bussink*
Expand Down
75 changes: 68 additions & 7 deletions activestorage/lib/active_storage/service/gcs_service.rb
@@ -1,12 +1,16 @@
# frozen_string_literal: true

gem "google-cloud-storage", "~> 1.11"
require "google/apis/iamcredentials_v1"
require "google/cloud/storage"

module ActiveStorage
# Wraps the Google Cloud Storage as an Active Storage service. See ActiveStorage::Service for the generic API
# documentation that applies to all services.
class Service::GCSService < Service
class MetadataServerError < ActiveStorage::Error; end
class MetadataServerNotFoundError < ActiveStorage::Error; end

def initialize(public: false, **config)
@config = config
@public = public
Expand Down Expand Up @@ -95,13 +99,20 @@ def url_for_direct_upload(key, expires_in:, checksum:, **)
version = :v4
end

generated_url = bucket.signed_url(key,
args = {
content_md5: checksum,
expires: expires_in,
headers: headers,
method: "PUT",
version: version
)
version: version,
}

if @config[:iam]
args[:issuer] = issuer
args[:signer] = signer
end

generated_url = bucket.signed_url(key, **args)

payload[:url] = generated_url

Expand All @@ -123,10 +134,20 @@ def headers_for_direct_upload(key, checksum:, filename: nil, disposition: nil, *

private
def private_url(key, expires_in:, filename:, content_type:, disposition:, **)
file_for(key).signed_url expires: expires_in, query: {
"response-content-disposition" => content_disposition_with(type: disposition, filename: filename),
"response-content-type" => content_type
args = {
expires: expires_in,
query: {
"response-content-disposition" => content_disposition_with(type: disposition, filename: filename),
"response-content-type" => content_type
}
}

if @config[:iam]
args[:issuer] = issuer
args[:signer] = signer
end

file_for(key).signed_url(**args)
end

def public_url(key, **)
Expand Down Expand Up @@ -160,7 +181,47 @@ def bucket
end

def client
@client ||= Google::Cloud::Storage.new(**config.except(:bucket, :cache_control))
@client ||= Google::Cloud::Storage.new(**config.except(:bucket, :cache_control, :iam, :gsa_email))
end

def issuer
@issuer ||= if @config[:gsa_email]
@config[:gsa_email]
else
uri = URI.parse("http://metadata.google.internal/computeMetadata/v1/instance/service-accounts/default/email")
http = Net::HTTP.new(uri.host, uri.port)
request = Net::HTTP::Get.new(uri.request_uri)
request["Metadata-Flavor"] = "Google"

begin
response = http.request(request)
rescue SocketError
raise MetadataServerNotFoundError
end

if response.is_a?(Net::HTTPSuccess)
response.body
else
raise MetadataServerError
end
end
end

def signer
# https://googleapis.dev/ruby/google-cloud-storage/latest/Google/Cloud/Storage/Project.html#signed_url-instance_method
lambda do |string_to_sign|
iam_client = Google::Apis::IamcredentialsV1::IAMCredentialsService.new

scopes = ["https://www.googleapis.com/auth/iam"]
iam_client.authorization = Google::Auth.get_application_default(scopes)

request = Google::Apis::IamcredentialsV1::SignBlobRequest.new(
payload: string_to_sign
)
resource = "projects/-/serviceAccounts/#{issuer}"
response = iam_client.sign_service_account_blob(resource, request)
response.signed_blob
end
end
end
end
2 changes: 2 additions & 0 deletions activestorage/test/service/configurations.example.yml
Expand Up @@ -21,6 +21,8 @@
# }
# project:
# bucket:
# iam: false
# gsa_email: "foobar@baz.iam.gserviceaccount.com"
#
# azure:
# service: AzureStorage
Expand Down
34 changes: 34 additions & 0 deletions activestorage/test/service/gcs_service_test.rb
Expand Up @@ -151,6 +151,40 @@ class ActiveStorage::Service::GCSServiceTest < ActiveSupport::TestCase
assert_match(/storage\.googleapis\.com\/.*response-content-disposition=inline.*test\.txt.*response-content-type=text%2Fplain/,
@service.url(@key, expires_in: 2.minutes, disposition: :inline, filename: ActiveStorage::Filename.new("test.txt"), content_type: "text/plain"))
end

if SERVICE_CONFIGURATIONS[:gcs].key?(:gsa_email)
test "direct upload with IAM signing" do
config_with_iam = { gcs: SERVICE_CONFIGURATIONS[:gcs].merge({ iam: true }) }
service = ActiveStorage::Service.configure(:gcs, config_with_iam)

key = SecureRandom.base58(24)
data = "Some text"
checksum = Digest::MD5.base64digest(data)
url = service.url_for_direct_upload(key, expires_in: 5.minutes, content_type: "text/plain", content_length: data.size, checksum: checksum)

uri = URI.parse(url)
request = Net::HTTP::Put.new(uri.request_uri)
request.body = data
request.add_field("Content-Type", "")
request.add_field("Content-MD5", checksum)
Net::HTTP.start(uri.host, uri.port, use_ssl: true) do |http|
http.request request
end

assert_equal data, service.download(key)
ensure
service.delete key
end

test "url with IAM signing" do
config_with_iam = { gcs: SERVICE_CONFIGURATIONS[:gcs].merge({ iam: true }) }
service = ActiveStorage::Service.configure(:gcs, config_with_iam)

key = SecureRandom.base58(24)
assert_match(/storage\.googleapis\.com\/.*response-content-disposition=inline.*test\.txt.*response-content-type=text%2Fplain/,
service.url(key, expires_in: 2.minutes, disposition: :inline, filename: ActiveStorage::Filename.new("test.txt"), content_type: "text/plain"))
end
end
end
else
puts "Skipping GCS Service tests because no GCS configuration was supplied"
Expand Down
19 changes: 19 additions & 0 deletions guides/source/active_storage_overview.md
Expand Up @@ -264,6 +264,25 @@ google:
cache_control: "public, max-age=3600"
```

Optionally use [IAM](https://cloud.google.com/storage/docs/access-control/signed-urls#signing-iam) instead of the `credentials` when signing URLs. This is useful if you are authenticating your GKE applications with Workload Identity, see [this Google Cloud blog post](https://cloud.google.com/blog/products/containers-kubernetes/introducing-workload-identity-better-authentication-for-your-gke-applications) for more information.

```yaml
google:
service: GCS
...
iam: true
```

Optionally use a specific GSA when signing URLs. When using IAM, the [metadata server](https://cloud.google.com/compute/docs/storing-retrieving-metadata) will be contacted to get the GSA email, but this metadata server is not always present (e.g. local tests) and you may wish to use a non-default GSA.

```yaml
google:
service: GCS
...
iam: true
gsa_email: "foobar@baz.iam.gserviceaccount.com"
```

Add the [`google-cloud-storage`](https://github.com/GoogleCloudPlatform/google-cloud-ruby/tree/master/google-cloud-storage) gem to your `Gemfile`:

```ruby
Expand Down

0 comments on commit ac87404

Please sign in to comment.