* feat: enhance service account key rotation with validation callback - add validation callback will enable the host service to validate the newly created key before the deleting the old key - wrapped both the call to validate permissions and validate callback in a retry because sometimes it takes some time for the newly created key to be active on GCP services. This throws an error and prevents host catalog creation
253 lines
9.5 KiB
Go
253 lines
9.5 KiB
Go
// Copyright (c) HashiCorp, Inc.
|
|
// SPDX-License-Identifier: MPL-2.0
|
|
|
|
package credential
|
|
|
|
import (
|
|
"context"
|
|
"encoding/json"
|
|
"errors"
|
|
"fmt"
|
|
"slices"
|
|
"time"
|
|
|
|
admin "cloud.google.com/go/iam/admin/apiv1"
|
|
"cloud.google.com/go/iam/admin/apiv1/adminpb"
|
|
"cloud.google.com/go/iam/apiv1/iampb"
|
|
resourcemanager "cloud.google.com/go/resourcemanager/apiv3"
|
|
"google.golang.org/api/option"
|
|
"google.golang.org/grpc/codes"
|
|
"google.golang.org/grpc/status"
|
|
)
|
|
|
|
const (
|
|
// ComputeInstancesListPermission is the IAM permission required
|
|
// to list compute instances.
|
|
ComputeInstancesListPermission = "compute.instances.list"
|
|
// IAMServiceAccountKeysCreatePermission is the IAM permission
|
|
// required to create a service account key.
|
|
// This permission is required to rotate service account keys.
|
|
IAMServiceAccountKeysCreatePermission = "iam.serviceAccountKeys.create"
|
|
// IAMServiceAccountKeysDeletePermission is the IAM permission
|
|
// required to delete a service account key.
|
|
// This permission is required to rotate service account keys.
|
|
IAMServiceAccountKeysDeletePermission = "iam.serviceAccountKeys.delete"
|
|
// validateServiceAccountKeyTimeout is the timeout for validating
|
|
// a service account key.
|
|
validateServiceAccountKeyTimeout = 5 * time.Second
|
|
)
|
|
|
|
// ServiceAccountPrivateKey represents a decoded PrivateKeyData
|
|
// from a Service Account Key.
|
|
// https://cloud.google.com/iam/docs/reference/rest/v1/projects.serviceAccounts.keys#ServiceAccountKey
|
|
type ServiceAccountPrivateKey struct {
|
|
Type string `json:"type"`
|
|
ProjectID string `json:"project_id"`
|
|
PrivateKeyID string `json:"private_key_id"`
|
|
PrivateKey string `json:"private_key"`
|
|
ClientEmail string `json:"client_email"`
|
|
ClientID string `json:"client_id"`
|
|
AuthURI string `json:"auth_uri"`
|
|
TokenURI string `json:"token_uri"`
|
|
AuthProviderX509CertURL string `json:"auth_provider_x509_cert_url"`
|
|
ClientX509CertURL string `json:"client_x509_cert_url"`
|
|
}
|
|
|
|
// ValidateCredsCallback is called to validate the credentials after rotating the service account key.
|
|
// The callback should return an error if the credentials are invalid.
|
|
type ValidateCredsCallback func(*Config, ...option.ClientOption) error
|
|
|
|
// RotateServiceAccountKey takes the private key from this credentials config
|
|
// and first creates a new private key and private key id, then deletes the
|
|
// old private key.
|
|
//
|
|
// If deletion of the old private key is successful, the new private key and
|
|
// private key id are written into the credentials config and nil is returned.
|
|
// On any error, the old credentials are not overwritten.
|
|
func (c *Config) RotateServiceAccountKey(
|
|
ctx context.Context,
|
|
permissions []string,
|
|
validateCredsCallback ValidateCredsCallback,
|
|
opts ...option.ClientOption,
|
|
) error {
|
|
if c.PrivateKey == "" {
|
|
return status.Error(codes.InvalidArgument, "cannot rotate credentials when private key is not set")
|
|
}
|
|
if c.PrivateKeyId == "" {
|
|
return status.Error(codes.InvalidArgument, "cannot rotate credentials when private key ID is not set")
|
|
}
|
|
if c.ClientEmail == "" {
|
|
return status.Error(codes.InvalidArgument, "cannot rotate credentials when client email is not set")
|
|
}
|
|
|
|
creds, err := c.GenerateCredentials(ctx)
|
|
if err != nil {
|
|
return status.Errorf(codes.Unauthenticated, "error generating credentials: %v", err)
|
|
}
|
|
if creds.TokenSource == nil {
|
|
return status.Error(codes.Unauthenticated, "error generating credentials: token source is nil")
|
|
}
|
|
|
|
clientOptions := []option.ClientOption{option.WithTokenSource(creds.TokenSource)}
|
|
clientOptions = append(clientOptions, opts...)
|
|
|
|
iamClient, err := admin.NewIamClient(ctx, clientOptions...)
|
|
if err != nil {
|
|
return status.Errorf(codes.Internal, "error creating IAM client: %v", err)
|
|
}
|
|
|
|
createServiceAccountKeyRes, err := iamClient.CreateServiceAccountKey(ctx, &adminpb.CreateServiceAccountKeyRequest{
|
|
Name: fmt.Sprintf("projects/%s/serviceAccounts/%s", c.ProjectId, c.ClientEmail),
|
|
PrivateKeyType: adminpb.ServiceAccountPrivateKeyType_TYPE_GOOGLE_CREDENTIALS_FILE,
|
|
KeyAlgorithm: adminpb.ServiceAccountKeyAlgorithm_KEY_ALG_RSA_2048,
|
|
})
|
|
if err != nil {
|
|
return status.Errorf(codes.Internal, "error creating service account key: %v", err)
|
|
}
|
|
|
|
var serviceAccountKey ServiceAccountPrivateKey
|
|
err = json.Unmarshal(createServiceAccountKeyRes.PrivateKeyData, &serviceAccountKey)
|
|
if err != nil {
|
|
return status.Errorf(codes.Internal, "error unmarshalling service account key: %v", err)
|
|
}
|
|
|
|
// Clone the config, update the private key and private key ID with the new key.
|
|
newConfig := c.clone()
|
|
newConfig.PrivateKey = serviceAccountKey.PrivateKey
|
|
newConfig.PrivateKeyId = serviceAccountKey.PrivateKeyID
|
|
|
|
newCreds, err := newConfig.GenerateCredentials(ctx)
|
|
if err != nil {
|
|
return status.Errorf(codes.Unauthenticated, "error generating credentials: %v", err)
|
|
}
|
|
if newCreds.TokenSource == nil {
|
|
return status.Error(codes.Unauthenticated, "error generating credentials: token source is nil")
|
|
}
|
|
clientOptions = []option.ClientOption{option.WithTokenSource(newCreds.TokenSource)}
|
|
clientOptions = append(clientOptions, opts...)
|
|
|
|
// Validate the new service account key
|
|
err = newConfig.ValidateServiceAccountKey(ctx, permissions, validateCredsCallback, clientOptions...)
|
|
if err != nil {
|
|
return status.Errorf(codes.PermissionDenied, "error validating rotated service account key: %v", err)
|
|
}
|
|
|
|
iamClient, err = admin.NewIamClient(ctx, clientOptions...)
|
|
if err != nil {
|
|
return status.Errorf(codes.Internal, "error creating IAM client with rotated service account key: %v", err)
|
|
}
|
|
|
|
err = iamClient.DeleteServiceAccountKey(ctx, &adminpb.DeleteServiceAccountKeyRequest{
|
|
Name: fmt.Sprintf("projects/%s/serviceAccounts/%s/keys/%s", c.ProjectId, c.ClientEmail, c.PrivateKeyId),
|
|
})
|
|
if err != nil {
|
|
return status.Errorf(codes.Internal, "error deleting service account key: %v", err)
|
|
}
|
|
|
|
c.PrivateKey = newConfig.PrivateKey
|
|
c.PrivateKeyId = newConfig.PrivateKeyId
|
|
|
|
return nil
|
|
}
|
|
|
|
// ValidateServiceAccountKey validates the service account key by checking the IAM permissions
|
|
// and calling the validation callback.
|
|
// The function will retry validation until the timeout is reached.
|
|
func (c *Config) ValidateServiceAccountKey(
|
|
ctx context.Context,
|
|
permissions []string,
|
|
validateCredsCallback ValidateCredsCallback,
|
|
opts ...option.ClientOption) error {
|
|
var validatePermissionsErr error
|
|
var validationCallbackErr error
|
|
|
|
for start := time.Now(); time.Since(start) < validateServiceAccountKeyTimeout; time.Sleep(200 * time.Millisecond) {
|
|
_, validatePermissionsErr = c.ValidateIamPermissions(ctx, permissions, opts...)
|
|
|
|
if validateCredsCallback != nil {
|
|
validationCallbackErr = validateCredsCallback(c, opts...)
|
|
}
|
|
|
|
if validatePermissionsErr == nil && validationCallbackErr == nil {
|
|
return nil
|
|
}
|
|
}
|
|
|
|
return errors.Join(validatePermissionsErr, validationCallbackErr)
|
|
}
|
|
|
|
func (c *Config) DeletePrivateKey(ctx context.Context, opts ...option.ClientOption) error {
|
|
if c.PrivateKey == "" {
|
|
return status.Error(codes.InvalidArgument, "cannot delete credentials when private key is not set")
|
|
}
|
|
if c.PrivateKeyId == "" {
|
|
return status.Error(codes.InvalidArgument, "cannot delete credentials when private key ID is not set")
|
|
}
|
|
if c.ClientEmail == "" {
|
|
return status.Error(codes.InvalidArgument, "cannot delete credentials when client email is not set")
|
|
}
|
|
|
|
creds, err := c.GenerateCredentials(ctx)
|
|
if err != nil {
|
|
return status.Errorf(codes.Unauthenticated, "error generating credentials: %v", err)
|
|
}
|
|
if creds.TokenSource == nil {
|
|
return status.Error(codes.Unauthenticated, "error generating credentials: token source is nil")
|
|
}
|
|
|
|
clientOptions := []option.ClientOption{option.WithTokenSource(creds.TokenSource)}
|
|
clientOptions = append(clientOptions, opts...)
|
|
|
|
iamClient, err := admin.NewIamClient(ctx, clientOptions...)
|
|
if err != nil {
|
|
return status.Errorf(codes.Internal, "error creating IAM client: %v", err)
|
|
}
|
|
|
|
err = iamClient.DeleteServiceAccountKey(ctx, &adminpb.DeleteServiceAccountKeyRequest{
|
|
Name: fmt.Sprintf("projects/%s/serviceAccounts/%s/keys/%s", c.ProjectId, c.ClientEmail, c.PrivateKeyId),
|
|
})
|
|
if err != nil {
|
|
return status.Errorf(codes.Unknown, "error deleting service account key: %v", err)
|
|
}
|
|
|
|
return nil
|
|
}
|
|
|
|
// ValidateIamPermissions tests the IAM permissions for the credentials.
|
|
// It returns the granted permissions if successful.
|
|
func (c *Config) ValidateIamPermissions(ctx context.Context, permissions []string, opts ...option.ClientOption) ([]string, error) {
|
|
if len(permissions) == 0 {
|
|
return nil, status.Error(codes.InvalidArgument, "permissions are required")
|
|
}
|
|
|
|
rmClient, err := resourcemanager.NewProjectsClient(ctx, opts...)
|
|
if err != nil {
|
|
return nil, status.Errorf(codes.Internal, "failed to create Resource Manager client: %v", err)
|
|
}
|
|
|
|
resp, err := rmClient.TestIamPermissions(ctx, &iampb.TestIamPermissionsRequest{
|
|
Resource: "projects/" + c.ProjectId,
|
|
Permissions: permissions,
|
|
})
|
|
if err != nil {
|
|
return nil, status.Errorf(codes.Internal, "failed to validate IAM permissions: %v", err)
|
|
}
|
|
|
|
if len(resp.Permissions) == 0 {
|
|
return nil, status.Error(codes.PermissionDenied, "no permissions granted")
|
|
}
|
|
|
|
if len(resp.Permissions) != len(permissions) {
|
|
missingPermissions := make([]string, 0, len(permissions))
|
|
for _, permission := range permissions {
|
|
found := slices.Contains(resp.Permissions, permission)
|
|
if !found {
|
|
missingPermissions = append(missingPermissions, permission)
|
|
}
|
|
}
|
|
return nil, status.Errorf(codes.PermissionDenied, "missing permissions: %v", missingPermissions)
|
|
}
|
|
|
|
return resp.Permissions, nil
|
|
}
|