Skip to content

Commit

Permalink
feat: create impersonated service credentials
Browse files Browse the repository at this point in the history
  • Loading branch information
viacheslav-rostovtsev committed Nov 5, 2024
1 parent 0a750de commit 3a8238c
Show file tree
Hide file tree
Showing 14 changed files with 812 additions and 9 deletions.
34 changes: 34 additions & 0 deletions lib/googleauth/compute_engine.rb
Original file line number Diff line number Diff line change
Expand Up @@ -93,6 +93,24 @@ def initialize options = {}
super options
end

# Creates a duplicate of these credentials
# without the Signet::OAuth2::Client-specific
# transient state (e.g. cached tokens)
#
# @param options [Hash] Overrides for the credentials parameters.
# The following keys are recognized in addition to keys in the
# Signet::OAuth2::Client
# * `:universe_domain_overridden` Whether the universe domain was
# overriden during credentials creation
def duplicate options = {}
options = deep_hash_normalize options
super(
{
universe_domain_overridden: @universe_domain_overridden,
}.merge(options)
)
end

# Overrides the super class method to change how access tokens are
# fetched.
def fetch_access_token _options = {}
Expand Down Expand Up @@ -123,6 +141,22 @@ def fetch_access_token _options = {}
end
end

# Destructively updates these credentials
#
# @param options [Hash] Overrides for the credentials parameters.
# The following keys are recognized in addition to keys in the
# Signet::OAuth2::Client
# * `:universe_domain_overridden` Whether the universe domain was
# overriden during credentials creation
def update! options = {}
# Normalize all keys to symbols to allow indifferent access.
options = deep_hash_normalize options

@universe_domain_overridden = options[:universe_domain_overridden] if options.key? :universe_domain_overridden

super(options)
end

private

def build_token_hash body, content_type, retrieval_time
Expand Down
1 change: 1 addition & 0 deletions lib/googleauth/default_credentials.rb
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,7 @@
require "googleauth/service_account"
require "googleauth/user_refresh"
require "googleauth/external_account"
require "googleauth/impersonated_service_account"

module Google
# Module Auth provides classes that provide Google-specific authorization
Expand Down
210 changes: 210 additions & 0 deletions lib/googleauth/impersonated_service_account.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,210 @@
# Copyright 2024 Google, Inc.
#
# Licensed under the Apache License, Version 2.0 (the "License");
# you may not use this file except in compliance with the License.
# You may obtain a copy of the License at
#
# http://www.apache.org/licenses/LICENSE-2.0
#
# Unless required by applicable law or agreed to in writing, software
# distributed under the License is distributed on an "AS IS" BASIS,
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
# See the License for the specific language governing permissions and
# limitations under the License.

require "googleauth/signet"
require "googleauth/base_client"
require "googleauth/helpers/connection"

module Google
module Auth
# Authenticates requests using impersonation from base credentials.
# This is a two-step process: first authentication claim from the base credentials is created
# and then that claim is exchanged for a short-lived token at an IAMCredentials endpoint.
# The short-lived token and its expiration time are cached.
class ImpersonatedServiceAccountCredentials
ERROR_SUFFIX = <<~ERROR.freeze
trying to get security access token
from IAM Credentials endpoint using the credentials provided.
ERROR

IAM_SCOPE = ["https://www.googleapis.com/auth/iam".freeze].freeze

include Google::Auth::BaseClient
include Helpers::Connection

attr_reader :base_credentials, :source_credentials, :impersonation_url, :scope
attr_reader :access_token, :expires_at

# Create a ImpersonatedServiceAccountCredentials
# When you use service account impersonation, you start with an authenticated principal
# (e.g. your user account or a service account)
# and request short-lived credentials for a service account
# that has the authorization that your use case requires.
#
# @param base_credentials [Object] the authenticated principal that will be used
# to fetch short-lived impersionation access token
# @param impersonation_url [String] the URL to use to impersonate the service account.
# This URL should be in the format:
# https://iamcredentials.{universe_domain}/v1/projects/-/serviceAccounts/{source_sa_email}:generateAccessToken
# where:
# * {universe_domain} is the domain of the IAMCredentials API endpoint (e.g. 'googleapis.com')
# * {source_sa_email} is the email address of the service account to impersonate
# @param scope [Array, String] the scope(s) to access.
# Note that these are NOT the scopes that the authenticated principal should have, but
# the scopes that the short-lived impersonation access token should have.
def self.make_creds options = {}
new(options)
end

def initialize options = {}
@base_credentials, @impersonation_url, @scope =
options.values_at :base_credentials,
:impersonation_url,
:scope

# Some credentials (all Signet-based ones and this one) include scope and a bunch of transient state (e.g. refresh status) as part of themselves
# so a copy needs to be created with the scope overriden and transient state dropped
@source_credentials = if @base_credentials.respond_to? :duplicate
@base_credentials.duplicate({
scope: IAM_SCOPE
})
else
@base_credentials
end
end

# Whether the current access token expires before a given
# amount of seconds is elapsed
def expires_within? seconds
# This method is needed for BaseClient
@expires_at && @expires_at - Time.now.utc < seconds
end

def universe_domain
@source_credentials.universe_domain
end

# Calls the source credentials to fetch an access token first,
# then exchanges that access token for an impersonation token
# at the @impersonation_url
def make_token!
auth_header = {}
auth_header = @source_credentials.apply! auth_header

resp = connection.post @impersonation_url do |req|
req.headers.merge! auth_header
req.headers["Content-Type"] = "application/json"
req.body = MultiJson.dump({ scope: @scope })
end

case resp.status
when 200
response = MultiJson.load(resp.body)
self.expires_at = response["expireTime"]
self.access_token = response["accessToken"]
self.access_token
when 403, 500
msg = "Unexpected error code #{resp.status} #{ERROR_SUFFIX}"
raise Signet::UnexpectedStatusError, msg
else
msg = "Unexpected error code #{resp.status} #{ERROR_SUFFIX}"
raise Signet::AuthorizationError, msg
end
end

# Returns a clone of a_hash updated with the authoriation header
def apply! a_hash, opts = {}
token = make_token!
a_hash[AUTH_METADATA_KEY] = "Bearer #{token}"
a_hash
end

# Creates a duplicate of these credentials without transient token state
#
# @param options [Hash] Overrides for the credentials parameters.
# The following keys are recognized
# * `base_credentials` the base credentials used to initialize the impersonation
# * `source_credentials` the authenticated credentials which usually would be
# base credentias with scope overridden to IAM_SCOPE
# * `impersonation_url` the URL to use to make an impersonation token exchange
# * `scope` the scope(s) to access
def duplicate options = {}
options = deep_hash_normalize options

options = {
base_credentials: @base_credentials,
source_credentials: @source_credentials,
impersonation_url: @impersonation_url,
scope: @scope,
}.merge(options)

new_client = self.class.new options
new_client.update!(options)
end

# Destructively updates these credentials
#
# @param options [Hash] Overrides for the credentials parameters.
# The following keys are recognized
# * `base_credentials` the base credentials used to initialize the impersonation
# * `source_credentials` the authenticated credentials which usually would be
# base credentias with scope overridden to IAM_SCOPE
# * `impersonation_url` the URL to use to make an impersonation token exchange
# * `scope` the scope(s) to access
def update! options = {}
# Normalize all keys to symbols to allow indifferent access.
options = deep_hash_normalize options

@base_credentials = options[:base_credentials] if options.key? :base_credentials
@source_credentials = options[:source_credentials] if options.key? :source_credentials
@impersonation_url = options[:impersonation_url] if options.key? :impersonation_url
@scope = options[:scope] if options.key? :scope

self
end

private

# Setter for the expires_at value that makes sure it is converted
def expires_at= new_expires_at
@expires_at = normalize_timestamp new_expires_at
end

attr_writer :access_token

def token_type
# This method is needed for BaseClient
:access_token
end

def normalize_timestamp time
case time
when NilClass
nil
when Time
time
when String
Time.parse time
else
raise "Invalid time value #{time}"
end
end

# Convert all keys in this hash (nested) to symbols for uniform retrieval
def recursive_hash_normalize_keys val
if val.is_a? Hash
deep_hash_normalize val
else
val
end
end

def deep_hash_normalize old_hash
sym_hash = {}
old_hash&.each { |k, v| sym_hash[k.to_sym] = recursive_hash_normalize_keys v }
sym_hash
end
end
end
end
Loading

0 comments on commit 3a8238c

Please sign in to comment.