Impersonation and Domain Wide Delegation with Google Cloud Client Libraries

2021-12-15

Impersonation and Delegation is often used interchangeably but on Google CLoud, these are two distinct mechanism. GCP uses Impersonation to mean service account impersonation while Delegation means acting as a workspace through Domain-wide delegation.

  • Impersonation: user|service_account_A acts as service_account_B –> access GCP Resource
  • Delegation: service_account_A acts as user –> access GCP or Workspace resource

This article focuses primarily on Delegation and how to construct cloud sdk clients to authenticate as users. For impersonation, see Using ImpersonatedCredentials for Google Cloud APIs and IDTokens and the corresponding git repoo Using ImpersonatedCredentials for Google Cloud APIs and IDTokens

The following set describes how to enable domain delegation in various cloud client libraries.

Background on Domain Delegation

Domain wide Delegation (dwd) is performed when a Service Account that is enabled for dwd issues a specially constructed JWT using its private key. This JWT has a subject (sub:) claim that specifies which users token to return. Once this JWT is sent to Google, the signature is checked along with the scopes (another field in the jwt), then finally the user’s access_token is returned.

See the section in the link below titled additional claims

"In some enterprise cases, an application can use domain-wide delegation to act on behalf of a particular user in an organization. Permission to perform this type of impersonation must be granted before an application can impersonate a user, and is usually handled by a super administrator. For more information, see Control API access with domain-wide delegation.

To obtain an access token that grants an application delegated access to a resource, include the email address of the user in the JWT claim set as the value of the sub field.

Name	Description
sub	The email address of the user for which the application is requesting delegated access.
If an application does not have permission to impersonate a user, the response to an access token request that includes the sub field will be an error.

An example of a JWT claim set that includes the sub field is shown below:

{
  "iss": "761326798069-r5mljlln1rd4lrbhg75efgigp36m78j5@developer.gserviceaccount.com",

  "sub": "user@domain.com",

  "scope": "https://www.googleapis.com/auth/prediction",
  "aud": "https://oauth2.googleapis.com/token",
  "exp": 1328554385,
  "iat": 1328550785
}

Critically, the service account requires some way sign() the JWT in the first place which means the application needs direct or indirect access to the private key for that service account.

Direct access is easy: download the service_account key and use the default flow shown in the samples below

Indirect requires the identity that running the application IAM permissions to sign() bytes on behalf of the service account without the key being present

The challenge is how a service account can sign without a local key. On GCP, this is achieved by allowing the identity to use an API to sign with a key that resides only within Google. That is use iamcredentials.signBlob() API and pass the bytes for the constructed JWT.

In other words, if an source identity has the iam.serviceAccounts.signBlob permission (granted by Role roles/iam.serviceAccountTokenCreator) on the service account authorized to perform domain delegation, the source identity can generate a JWT with the appropriate sub: field, ask IAM API to sign the JWT and finally exchange that signedJWT for the subject’s access_token

To be clear, we are getting a users token (delegated) though impersonation:

Assume service_2 has domain delegation rights and user_1 or service_1 has permissions to impersonate service_2, the flow becomes:

  • user_1|service_1 -> impersonate(service_2) -> delegate(user_2) -> gcp|workspace resource

One common scenario is if service_1 is a static service account on GCE|GKE (which does NOT have a signer), in that scenario, service_1 will need “self-impersonation” permissions (i.,e the tokenCreator role granted on itself)

In terms of library support several already support this flow like though

  • In golang, its by far the easiest: if you can can acquire an impersonation credential, simply issue a call to get the delegated credential through impersonate.CredentialsTokenSource()

In other languages, the flow must be done manually:

The iam.serviceAccountTokenCreator role includes concentric set of permission (see Concentric IAMCredentials Permissions: The secret life of signBlob. If you just need to generate an access_token or an id_token, consider generating a custom role with that specific capability.

Setup

Sample Domain-Wide Delegation Setup:

  1. Create Service Account Enable domain-wide delegation and note the clientID:

  2. Assign GSuite scopes to client_id On the gsuite admin console under “Security > Advanced Settings > Manage API client access”, enter the client_id and the scopes this service account should be allowed to use when requesting a users credentials:

  3. Specify the scopes intended to be used as using comma seprated values. We are using read-only on the user just to test https://www.googleapis.com/auth/cloud-platform,https://www.googleapis.com/auth/admin.directory.user.readonly

images/dwdscopes.png

Once the steps above are done, the service account itself can assume the identity of any user’s account in the domain and act as that user. Note that we are using a service_account key for these samples for convenience Using a key has security implications. Please be careful with the key (or remove it entirely)


Domain-wide delegation

project='your-project-id'

import google.auth
from google.oauth2 import service_account

target_scopes = ["https://www.googleapis.com/auth/cloud-platform",
                "https://www.googleapis.com/auth/admin.directory.user.readonly"]

credentials = service_account.Credentials.from_service_account_file(
    '/path/to/svc_account.json',
    scopes=target_scopes,
    subject='user@domain.com')

from google.cloud import storage
client = storage.Client(project=project, credentials=credentials)
for b in client.list_buckets():
   print(b.name)


from google.cloud import pubsub_v1
from google.auth.transport.requests import AuthorizedSession
project_path = f"projects/{project}"
authed_session = AuthorizedSession(credentials)
response = authed_session.request('GET', 'https://pubsub.googleapis.com/v1/{}/topics'.format(project_path))
print(response.json())

## bug: https://github.com/googleapis/google-auth-library-python/issues/929
publisher = pubsub_v1.PublisherClient(credentials=credentials)
for topic in publisher.list_topics(request={"project": project_path}):
  print(topic.name)

Please note that the snippet above uses a service_account key file which is not recommended. The

In go, domain delegation is enabled if just one parameter is added into the constructor here

package main

import (
	"fmt"
	"io/ioutil"

	pubsub "cloud.google.com/go/pubsub"
	storage "cloud.google.com/go/storage"
	"golang.org/x/net/context"
	"golang.org/x/oauth2/google"
	"google.golang.org/api/iterator"
	"google.golang.org/api/option"
)

const (
	projectID = "your-project-id"
)

func main() {

	ctx := context.Background()

	data, err := ioutil.ReadFile("/path/to/svc_account.json")
	if err != nil {
		fmt.Println(err)
		return
	}

	credentials, err := google.CredentialsFromJSONWithParams(ctx, data, google.CredentialsParams{
		Scopes:  []string{"https://www.googleapis.com/auth/cloud-platform", "https://www.googleapis.com/auth/admin.directory.user.readonly"},
		Subject: "user@domain.com",
	})
	if err != nil {
		fmt.Printf("error getting credentials: %v", err)
		return
	}
	storageClient, err := storage.NewClient(ctx, option.WithCredentials(credentials))
	if err != nil {
		fmt.Printf("storage.NewClient: %v", err)
		return
	}
	defer storageClient.Close()

	it := storageClient.Buckets(ctx, projectID)
	for {
		battrs, err := it.Next()
		if err == iterator.Done {
			break
		}
		if err != nil {
			fmt.Printf("storage.Iterating error: %v", err)
			return
		}
		fmt.Printf("Bucket Name: %s\n", battrs.Name)
	}

	// *******************************************
	pubsubClient, err := pubsub.NewClient(ctx, projectID, option.WithCredentials(credentials))
	if err != nil {
		fmt.Printf("pubsub.NewClient: %v", err)
		return
	}
	defer pubsubClient.Close()

	pit := pubsubClient.Topics(ctx)
	for {
		topic, err := pit.Next()
		if err == iterator.Done {
			break
		}
		if err != nil {
			fmt.Printf("pubssub.Iterating error: %v", err)
			return
		}
		fmt.Printf("Topic Name: %s\n", topic.ID())
	}
}

Alternatively, if running in an environment where impersonated credentials could be use,

ts, err := impersonate.CredentialsTokenSource(ctx, impersonate.CredentialsConfig{
        TargetPrincipal: "foo@project-id.iam.gserviceaccount.com",
        Scopes:          []string{"https://www.googleapis.com/auth/cloud-platform"},
        Subject: "admin@example.com",
})

In the snippet above, foo@project-id.iam.gserviceaccount.com is a service account that will be impersonated but that service account has permissions to do domain delegation to "admin@example.com". The resulting tokensource can be used to make GCP api calls as if its by “admin@example.com

package com.test;

import java.io.File;
import java.io.FileInputStream;
import java.util.ArrayList;
import java.util.List;

import com.google.api.gax.core.FixedCredentialsProvider;
import com.google.auth.oauth2.DomainDelegatedCredentials;
import com.google.auth.oauth2.GoogleCredentials;
import com.google.auth.oauth2.ServiceAccountCredentials;
import com.google.cloud.pubsub.v1.TopicAdminClient;
import com.google.cloud.pubsub.v1.TopicAdminClient.ListTopicsPagedResponse;
import com.google.cloud.pubsub.v1.TopicAdminSettings;
import com.google.cloud.storage.Bucket;
import com.google.cloud.storage.Storage;
import com.google.cloud.storage.StorageOptions;
import com.google.pubsub.v1.ListTopicsRequest;
import com.google.pubsub.v1.ProjectName;
import com.google.pubsub.v1.Topic;

public class TestApp {
	public static void main(String[] args) {
		TestApp tc = new TestApp();
	}

	public TestApp() {
		try {

			String sub = "user@domain.com";

			List<String> scopes = new ArrayList<String>();
			scopes.add("https://www.googleapis.com/auth/admin.directory.user.readonly");
			scopes.add("https://www.googleapis.com/auth/cloud-platform");

			String keyFile = "/path/to/svc_account.json";

			GoogleCredentials sourceCredentials;

			File credentialsPath = new File(keyFile);
			try (FileInputStream serviceAccountStream = new FileInputStream(credentialsPath)) {
				sourceCredentials = ServiceAccountCredentials.fromStream(serviceAccountStream);
			}

			GoogleCredentials credentials = DomainDelegatedCredentials.create(sourceCredentials, sub, scopes);

			Storage storage_service = StorageOptions.newBuilder().setProjectId("your-project-id")
					.setCredentials(credentials).build().getService();
			for (Bucket b : storage_service.list().iterateAll()) {
				System.out.println(b);
			}

			TopicAdminClient topicClient = TopicAdminClient.create(TopicAdminSettings.newBuilder()
					.setCredentialsProvider(FixedCredentialsProvider.create(credentials)).build());

			ListTopicsRequest listTopicsRequest = ListTopicsRequest.newBuilder()
					.setProject(ProjectName.format("your-project-id"))
					.build();

			ListTopicsPagedResponse response = topicClient.listTopics(listTopicsRequest);
			Iterable<Topic> topics = response.iterateAll();
			for (Topic topic : topics)
				System.out.println(topic);

		} catch (Exception ex) {
			System.out.println("Error: " + ex);
		}
	}

}
var log4js = require("log4js");
var logger = log4js.getLogger();

const { PubSub } = require('@google-cloud/pubsub');
const { Storage } = require('@google-cloud/storage');

const { JWT, OAuth2Client } = require('google-auth-library');

let projectId = 'your-project-id'
const key = require('/path/to/svc_account.json');
const jwtClient = new JWT(
	key.client_email,
	null,
	key.private_key,
	["https://www.googleapis.com/auth/cloud-platform", "https://www.googleapis.com/auth/admin.directory.user.readonly"],
	"user@domain.com"
);

const oauth2Client = new OAuth2Client();

oauth2Client.refreshHandler = async () => {
	const refreshedAccessToken = await jwtClient.getAccessToken();
	return {
		access_token: refreshedAccessToken.token,
		expiry_date: refreshedAccessToken.expirationTime,
	};
};

const authOptions = {
	projectId,
	authClient: {
		getCredentials: async () => {
			return {
				client_email: key.client_email
			}
		},
		request: opts => {
			return oauth2Client.request(opts);
		},
		authorizeRequest: async opts => {
			opts = opts || {};
			const url = opts.url || opts.uri;
			const headers = await oauth2Client.getRequestHeaders(url);
			opts.headers = Object.assign(opts.headers || {}, headers);
			return opts;
		},
	},
};

var gcs = new Storage(authOptions);
gcs.getBuckets(function (err, buckets) {
	if (!err) {
		buckets.forEach(function (value) {
			logger.info(value.id);
		});
	}
});

var AuthClient = function (client) {
	this._client = client;
};
AuthClient.prototype.getClient = function () {
	return this._client;
};
var ac = new AuthClient(jwtClient);

const pubsub = new PubSub({
	projectId: projectId,
	auth: ac,
});
pubsub.getTopics((err, topic) => {
	if (err) {
		logger.error(err);
		return;
	}
	topic.forEach(function (entry) {
		logger.info(entry.name);
	});
});

also see google-auth-library-nodejs issu#1210

first set export GOOGLE_APPLICATION_CREDENTIALS=/path/to/svc_account.json

then

using System;

using Google.Apis.Auth.OAuth2;
using Google.Apis.Storage.v1;
using Google.Apis.Pubsub.v1;
using Google.Apis.Services;
using System.Threading.Tasks;

namespace main
{
    class Program
    {
        const string projectID = "your-project-id";
        [STAThread]
        static void Main(string[] args)
        {
            new Program().Run().Wait();
        }

        private Task Run()
        {

            string[] Scopes = { "https://www.googleapis.com/auth/cloud-platform", "https://www.googleapis.com/auth/admin.directory.user.readonly" };
            GoogleCredential credential = GoogleCredential.GetApplicationDefault().CreateWithUser("user2@domain.com").CreateScoped(Scopes);
            var storageService = new StorageService(new BaseClientService.Initializer()
            {
                HttpClientInitializer = credential,
                ApplicationName = "testclient",
            });

            var request = new BucketsResource.ListRequest(storageService, projectID);
            var requestResult = request.Execute();
            foreach (var bucket in requestResult.Items)
            {
                Console.WriteLine(bucket.Name);
            }

            ///
            var pubsubService = new PubsubService(new BaseClientService.Initializer()
            {
                HttpClientInitializer = credential,
                ApplicationName = "testclient",
            });

            var prequest = new Google.Apis.Pubsub.v1.ProjectsResource.TopicsResource.ListRequest(pubsubService, "projects/" + projectID);
            var prequestResult = prequest.Execute();
            foreach (var topic in prequestResult.Topics)
            {
                Console.WriteLine(topic.Name);
            }
            return Task.CompletedTask;
        }
    }
}

Python Impersonate and Delegate

Assume both admin@domain.com and GCE instance running as ${PROJECT_NUMBER}-compute@developer.gserviceaccount.com has the tokenCreator role granted on generic-server@${$PROJECT_ID}.iam.gserviceaccount.com which itself can perform domain-delegation.

Using user_1->impersonate(svc_1)->dwd(user_2)->service

NOTE: to use this mode, the user user_1 as must be able to impersonate svc_1.

gcloud iam service-accounts add-iam-policy-binding \
    svc_1@PROJECT_A.iam.gserviceaccount.com  \
    --member='user:user1@domain.com' \
    --role='roles/iam.serviceAccountTokenCreator'
import google.auth
import time
from google.auth import credentials
from google.cloud import iam_credentials_v1
from google.auth import impersonated_credentials
import google.auth.iam
import urllib.parse



## ******************************************************************************************

## get the source credentials first 
source_credentials, project_id = google.auth.default()

project='your-project-id'
target_sa = 'generic-server@pubsub-msg.iam.gserviceaccount.com'

# generate a JWT
sub = "user1@domain.com"
iss = target_sa
target_scopes_for_dwd = "https://www.googleapis.com/auth/cloud-platform https://www.googleapis.com/auth/admin.directory.user.readonly"
now = int(time.time())
exptime = now + 3600
claim =('{"iss":"%s",'
        '"scope":"%s",'
        '"aud":"https://accounts.google.com/o/oauth2/token",'
        '"sub":"%s",'  
        '"exp":%s,'
        '"iat":%s}') %(iss,target_scopes_for_dwd,sub,exptime,now)

# use the IAM api and the users credentials to sign the JWT
iam_client = iam_credentials_v1.IAMCredentialsClient(credentials=source_credentials)

name = iam_client.service_account_path('-', target_sa)
resp = iam_client.sign_jwt(name=name, delegates=None, payload=claim)

signed_jwt = resp.signed_jwt
print('Signed JWT '+ signed_jwt)

# now exchange the signed_jwt for an access_token representing the subject sub
url = 'https://accounts.google.com/o/oauth2/token'
data = {'grant_type' : 'assertion',
        'assertion_type' : 'http://oauth.net/grant_type/jwt/1.0/bearer',
        'assertion' : "{}".format(urllib.parse.quote(signed_jwt)) }
headers = {"Content-type": "application/x-www-form-urlencoded"}
  
resp = requests.post(url, data)

access_token=resp.json()['access_token']
expires_in=resp.json()['expires_in']
token_type=resp.json()['token_type']


# ref # ref https://github.com/googleapis/google-auth-library-python/issues/931
import google.oauth2.credentials
sc = google.oauth2.credentials.Credentials(access_token)


from google.cloud import storage
client = storage.Client(project=project, credentials=sc)
for b in client.list_buckets():
   print(b.name)


from google.cloud import pubsub_v1
from google.auth.transport.requests import AuthorizedSession
project_path = f"projects/{project}"
authed_session = AuthorizedSession(sc)
response = authed_session.request('GET', 'https://pubsub.googleapis.com/v1/{}/topics'.format(project_path))
print(response.json())


publisher = pubsub_v1.PublisherClient(credentials=sc)
for topic in publisher.list_topics(request={"project": project_path}):
  print(topic.name)

Using svc_gce->impersonate(svc_1)->dwd(user_2)->service

NOTE: to use this mode, the service account svc_gce the GCE instance runs as must be able to impersonate svc_1. Or if the GCE VM is running as svc_1, the svc_1 must be able to “self-impersonate”

gcloud iam service-accounts add-iam-policy-binding \
    svc_1@PROJECT_A.iam.gserviceaccount.com  \
    --member='serviceAccount:svc_gce@PROJECT_B.iam.gserviceaccount.com' \
    --role='roles/iam.serviceAccountTokenCreator'

then

gcloud compute ssh instance-1 --project A

$ curl "http://metadata.google.internal/computeMetadata/v1/instance/service-accounts/default/email"     --header "Metadata-Flavor: Google"
   svc_gce-compute@developer.gserviceaccount.com

The code that you would run is the same as iam_api

Same as user_1->impersonate(svc_1)->dwd(user_2)->service except this flow uses the impersonated_credential.signer()

This sample uses the same apis as the preceeding snippets in this tab. The only difference is the iamcredentials api is now wrapped inside impersonated_credentials

import google.auth
import time

from google.auth import credentials
from google.cloud import iam_credentials_v1
from google.auth import impersonated_credentials
import google.auth.iam
import base64

# see https://github.com/googleapis/google-auth-library-python/issues/931
from datetime import datetime, timedelta
from google.auth import _helpers
from google.auth import credentials
from google.auth import exceptions
import requests

# https://google-auth.readthedocs.io/en/master/reference/google.oauth2.service_account.html#domain-wide-delegation

project='your-project-id'
sa_1 = 'generic-server@pubsub-msg.iam.gserviceaccount.com'



## get the source credentials first (this is user_1)
source_credentials, project_id = google.auth.default()

# use that to get sa_1 credentials
target_scopes = ['https://www.googleapis.com/auth/cloud-platform']
sa1_credentials = impersonated_credentials.Credentials(
    source_credentials = source_credentials,
    target_principal=sa_1,
    target_scopes = target_scopes,
    delegates=[],
    lifetime=500)


### use sa1_credentials to get an token for user2
sub = "user2@domain.com"

target_scopes_for_dwd = "https://www.googleapis.com/auth/cloud-platform https://www.googleapis.com/auth/admin.directory.user.readonly"
now = int(time.time())
exptime = now + 3600
claim =('{"iss":"%s",'
        '"scope":"%s",'
        '"aud":"https://accounts.google.com/o/oauth2/token",'
        '"sub":"%s",'  
        '"exp":%s,'
        '"iat":%s}') %(sa_1,target_scopes_for_dwd,sub,exptime,now)

# use the impersonated_credentials sign the JWT (since it has a signer)
# https://google-auth.readthedocs.io/en/master/reference/google.auth.impersonated_credentials.html#google.auth.impersonated_credentials.Credentials.signer
## too bad the signer does not return the keyid thats used...

#header = ('{"alg": "RS256", "kid": "%s", "typ": "JWT"}') %("24d9257dc9fe8959728b57655339b753d0f20f09")
header = ('{"alg": "RS256",  "typ": "JWT"}')

header_claims = '{}.{}'.format(base64.urlsafe_b64encode(header.encode()).decode().rstrip('='),base64.urlsafe_b64encode(claim.encode()).decode().rstrip('='))
signed_bytes = sa1_credentials.sign_bytes(header_claims.encode())

assertion = header_claims + '.' + base64.urlsafe_b64encode(signed_bytes).decode().rstrip('=')

print('Signed JWT '+ assertion)

# now exchange the signed_jwt for an access_token representing the subject sub
url = 'https://accounts.google.com/o/oauth2/token'
data = {'grant_type' : 'assertion',
        'assertion_type' : 'http://oauth.net/grant_type/jwt/1.0/bearer',
        'assertion' : assertion }
headers = {"Content-type": "application/x-www-form-urlencoded"}
  
resp = requests.post(url, data)

##  We finally have user2's token....
access_token=resp.json()['access_token']
expires_in=resp.json()['expires_in']
token_type=resp.json()['token_type']


# ref # ref https://github.com/googleapis/google-auth-library-python/issues/931
import google.oauth2.credentials
sc = google.oauth2.credentials.Credentials(access_token)

from google.cloud import storage
client = storage.Client(project=project, credentials=sc)
for b in client.list_buckets():
   print(b.name)

from google.cloud import pubsub_v1
from google.auth.transport.requests import AuthorizedSession
project_path = f"projects/{project}"
authed_session = AuthorizedSession(sc)
response = authed_session.request('GET', 'https://pubsub.googleapis.com/v1/{}/topics'.format(project_path))
print(response.json())


publisher = pubsub_v1.PublisherClient(credentials=sc)
for topic in publisher.list_topics(request={"project": project_path}):
  print(topic.name)

Please note that the above uses a StaticCredential which does not exist in google.auth.*

References

This site supports webmentions. Send me a mention via this form.