Repository: fluxcd/kustomize-controller

Issue:
SOPS multi-tenancy
Context:
* running on EKS, using IRSA and pod-identity-webhook
* multiple tenants should be onboarded onto the cluster
* we're enforcing `spec.serviceAccountName` 
* one tenant should not be able to decrypt secrets of other tenants

Problem:
Right now, `kustomize-controller` uses a local set of credentials that are defined by environment variables. These credentials allow the controller to make API calls against AWS. 
I want to onboard multiple tenants and they should not be able to decrypt the secrets of other tenants. However, since they share the same set of credentials they can decrypt secrets of other tenants.

With GPG we have a better tenant isolation due to the fact that `Kind=Kustomization` is namespaced and it stores a local reference to a gpg private key. (i.e. one gpg-key per namespace).

Proposed solutions:

(1) use multiple flux instances
This is meh for obvious reasons. Question: can i have multiple `kustomize-controllers` ? Would you support having sth. similar to `ingressClassName` to shard responsibilities between controllers?

(2) use serviceAccount/TokenRequest API 
Since the Kustomization refers to an `spec.serviceAccountName` we could implement sth. similar to the service account token volume projection API in Kubelet ([docs](https://kubernetes.io/docs/tasks/configure-pod-container/configure-service-account/#service-account-token-volume-projection)).
We can feed those tokens into the aws sdk to do a `sts:assumeRoleWithWebIdentity` this needs to be implemented in the sops repo, of course.
With that we basicially mimic the behavior of the eks-pod-identity-webhook but have the ability to assume multiple roles within one pod. I'm not sure yet what implications that has to elevate the privileges to use the token request api.

Hints from issue comments:
@stefanprodan: @moolen the current implementation assumes that each tenant uses OpenPGP or Age encryption keys as Kubernetes secrets are subject to RBAC and so does Kustomization objects. A tenant has no means of accessing another tenant secrets due to secretRef being a local reference, and we also isolate the keys for each reconciliation inside kustomize-controller container.

For KMS access with IRSA, ideally option 2 should be implemented in SOPS then in Flux, I don't think running one controller instance per tenant is a good solution.  @travisgroth: Before seeing this issue, I opened https://github.com/fluxcd/flux2/issues/1478.  Not sure if that should move to this repo but it seems related to the ask here.  Would something like that support your use case @moolen?

FWIW, I think currently you can run a `kustomize-controller` per namespace by setting `--watch-all-namespaces=false` for multi-tenant use cases.  I'd prefer something with less operational overhead when you're primarily worried about segmenting secrets access.@dquagebeur: We would like to use Azure Keyvault instead of pgp keys. What do you think about this proposal:
Integrate Azure Keyvault SDK in the keyservice to encrypt/decrypt using the azure crendentials stored in a secret from the current namespace (behaviour similar to current pgp implementation).
Is it compatible with how SOPS works with AKV ? (I don't know much about SOPS)@dquagebeur: Hi @stefanprodan , could you give us your feedback ? does it worth to invest in this direction ?@dquagebeur: I have made an implementation, could you give me your feedback and if it could be merged : [commit](https://github.com/dquagebeur/kustomize-controller/commit/6750532e1eabded2bd412bc1bfe9e91190e716f8)
Rules:
- if a secretRef contains a secret named *.json, the json is written to tmp folder (like .asc for pgp)
- if a secret is encrypted with an azureKeyvaultKey, I'm checking if an azure_kv.json exist, if yes I use Azure SDK and the json as credentials to decrypt, if no I let the default decrypt workflow...
 @stefanprodan: Hey @dquagebeur I see some issues your implementation, for example using `os.Setenv` which affects all tenants, if we can get rid of it then this looks good to me. Please open a PR so others can comment.

Solution patch:
diff --git a/controllers/kustomization_decryptor.go b/controllers/kustomization_decryptor.go
index d2451ad..7028de5 100644
--- a/controllers/kustomization_decryptor.go
+++ b/controllers/kustomization_decryptor.go
@@ -65,13 +65,15 @@ const (
 	// DecryptionVaultTokenFileName is the name of the file containing the
 	// Hashicorp Vault token.
 	DecryptionVaultTokenFileName = "sops.vault-token"
-	// DecryptionVaultTokenFileName is the name of the file containing the
-	// AWS KMS credentials
+	// DecryptionAWSKmsFile is the name of the file containing the AWS KMS
+	// credentials.
 	DecryptionAWSKmsFile = "sops.aws-kms"
 	// DecryptionAzureAuthFile is the name of the file containing the Azure
 	// credentials.
 	DecryptionAzureAuthFile = "sops.azure-kv"
-
+	// DecryptionGCPCredsFile is the name of the file containing the GCP
+	// credentials.
+	DecryptionGCPCredsFile = "sops.gcp-kms"
 	// maxEncryptedFileSize is the max allowed file size in bytes of an encrypted
 	// file.
 	maxEncryptedFileSize int64 = 5 << 20
@@ -139,6 +141,9 @@ type KustomizeDecryptor struct {
 	// azureToken is the Azure credential token used to authenticate towards
 	// any Azure Key Vault.
 	azureToken *azkv.Token
+	// gcpCredsJSON is the JSON credential file of the service account used to
+	// authenticate towards any GCP KMS.
+	gcpCredsJSON []byte
 
 	// keyServices are the SOPS keyservice.KeyServiceClient's available to the
 	// decryptor.
@@ -244,6 +249,10 @@ func (d *KustomizeDecryptor) ImportKeys(ctx context.Context) error {
 						return fmt.Errorf("failed to import '%s' data from %s decryption Secret '%s': %w", name, provider, secretName, err)
 					}
 				}
+			case filepath.Ext(DecryptionGCPCredsFile):
+				if name == DecryptionGCPCredsFile {
+					d.gcpCredsJSON = bytes.Trim(value, "\n")
+				}
 			}
 		}
 	}
@@ -543,6 +552,7 @@ func (d *KustomizeDecryptor) loadKeyServiceServers() {
 		intkeyservice.WithGnuPGHome(d.gnuPGHome),
 		intkeyservice.WithVaultToken(d.vaultToken),
 		intkeyservice.WithAgeIdentities(d.ageIdentities),
+		intkeyservice.WithGCPCredsJSON(d.gcpCredsJSON),
 	}
 	if d.azureToken != nil {
 		serverOpts = append(serverOpts, intkeyservice.WithAzureToken{Token: d.azureToken})
diff --git a/controllers/kustomization_generator.go b/controllers/kustomization_generator.go
index 9657cd0..3b46168 100644
--- a/controllers/kustomization_generator.go
+++ b/controllers/kustomization_generator.go
@@ -117,7 +117,6 @@ func (kg *KustomizeGenerator) WriteFile(dirPath string) error {
 	if err != nil {
 		return err
 	}
-
 	return os.WriteFile(kfile, kd, os.ModePerm)
 }
 
diff --git a/docs/spec/v1beta2/kustomization.md b/docs/spec/v1beta2/kustomization.md
index 71f55c3..e2d7f88 100644
--- a/docs/spec/v1beta2/kustomization.md
+++ b/docs/spec/v1beta2/kustomization.md
@@ -1222,6 +1222,29 @@ stringData:
     clientId: some-client-id
 ```
 
+#### GCP KMS Secret entry
+
+To specify credentials for GCP KMS in a Kubernetes Secret, append a `.data`
+entry with a fixed `sops.gcp-kms` key and the service account keys as its value.
+
+```yaml
+---
+apiVersion: v1
+kind: Secret
+metadata:
+  name: sops-keys
+  namespace: default
+stringData:
+  # Exemplary GCP Service Account credentials file
+  sops.gcp-kms: |
+    {
+      "type": "service_account",
+      "project_id": "<project-id>",
+      "private_key_id": "<private-key-id>",
+      "private_key": "<private-key>"
+    }
+```
+
 #### Hashicorp Vault Secret entry
 
 To specify credentials for Hashicorp Vault in a Kubernetes Secret, append a
diff --git a/go.mod b/go.mod
index 7e9bfb3..0a5f550 100644
--- a/go.mod
+++ b/go.mod
@@ -5,6 +5,7 @@ go 1.17
 replace github.com/fluxcd/kustomize-controller/api => ./api
 
 require (
+	cloud.google.com/go/kms v1.4.0
 	filippo.io/age v1.0.0
 	github.com/Azure/azure-sdk-for-go/sdk/azcore v0.22.0
 	github.com/Azure/azure-sdk-for-go/sdk/azidentity v0.13.2
@@ -27,6 +28,7 @@ require (
 	github.com/fluxcd/pkg/testserver v0.2.0
 	github.com/fluxcd/pkg/untar v0.1.0
 	github.com/fluxcd/source-controller/api v0.24.4
+	github.com/golang/protobuf v1.5.2
 	github.com/hashicorp/go-retryablehttp v0.7.1
 	github.com/hashicorp/vault/api v1.5.0
 	github.com/onsi/gomega v1.19.0
@@ -34,6 +36,9 @@ require (
 	github.com/spf13/pflag v1.0.5
 	go.mozilla.org/sops/v3 v3.7.3
 	golang.org/x/net v0.0.0-20220425223048-2871e0cb64e4
+	google.golang.org/api v0.74.0
+	google.golang.org/genproto v0.0.0-20220405205423-9d709892a2bf
+	google.golang.org/grpc v1.45.0
 	k8s.io/api v0.24.0
 	k8s.io/apiextensions-apiserver v0.24.0
 	k8s.io/apimachinery v0.24.0
@@ -63,7 +68,9 @@ replace github.com/opencontainers/runc => github.com/opencontainers/runc v1.0.3
 replace github.com/opencontainers/image-spec => github.com/opencontainers/image-spec v1.0.2
 
 require (
+	cloud.google.com/go v0.100.2 // indirect
 	cloud.google.com/go/compute v1.5.0 // indirect
+	cloud.google.com/go/iam v0.3.0 // indirect
 	github.com/Azure/azure-sdk-for-go v63.3.0+incompatible // indirect
 	github.com/Azure/azure-sdk-for-go/sdk/internal v0.9.1 // indirect
 	github.com/Azure/azure-sdk-for-go/sdk/keyvault/internal v0.2.1 // indirect
@@ -120,7 +127,6 @@ require (
 	github.com/golang-jwt/jwt v3.2.1+incompatible // indirect
 	github.com/golang-jwt/jwt/v4 v4.3.0 // indirect
 	github.com/golang/groupcache v0.0.0-20210331224755-41bb18bfe9da // indirect
-	github.com/golang/protobuf v1.5.2 // indirect
 	github.com/golang/snappy v0.0.4 // indirect
 	github.com/google/btree v1.0.1 // indirect
 	github.com/google/gnostic v0.5.7-v3refs // indirect
@@ -198,15 +204,12 @@ require (
 	go.uber.org/zap v1.21.0 // indirect
 	golang.org/x/crypto v0.0.0-20220411220226-7b82a4e95df4 // indirect
 	golang.org/x/oauth2 v0.0.0-20220411215720-9780585627b5 // indirect
-	golang.org/x/sys v0.0.0-20220412211240-33da011f77ad // indirect
+	golang.org/x/sys v0.0.0-20220513210249-45d2b4557a2a // indirect
 	golang.org/x/term v0.0.0-20210927222741-03fcf44c2211 // indirect
 	golang.org/x/text v0.3.7 // indirect
 	golang.org/x/time v0.0.0-20220224211638-0e9765cccd65 // indirect
 	gomodules.xyz/jsonpatch/v2 v2.2.0 // indirect
-	google.golang.org/api v0.74.0 // indirect
 	google.golang.org/appengine v1.6.7 // indirect
-	google.golang.org/genproto v0.0.0-20220405205423-9d709892a2bf // indirect
-	google.golang.org/grpc v1.45.0 // indirect
 	google.golang.org/protobuf v1.28.0 // indirect
 	gopkg.in/inf.v0 v0.9.1 // indirect
 	gopkg.in/ini.v1 v1.66.4 // indirect
diff --git a/go.sum b/go.sum
index 9232afe..0dd8328 100644
--- a/go.sum
+++ b/go.sum
@@ -27,6 +27,8 @@ cloud.google.com/go v0.93.3/go.mod h1:8utlLll2EF5XMAV15woO4lSbWQlk8rer9aLOfLh7+Y
 cloud.google.com/go v0.94.1/go.mod h1:qAlAugsXlC+JWO+Bke5vCtc9ONxjQT3drlTTnAplMW4=
 cloud.google.com/go v0.97.0/go.mod h1:GF7l59pYBVlXQIBLx3a761cZ41F9bBH3JUlihCt2Udc=
 cloud.google.com/go v0.99.0/go.mod h1:w0Xx2nLzqWJPuozYQX+hFfCSI8WioryfRDzkoI/Y2ZA=
+cloud.google.com/go v0.100.1/go.mod h1:fs4QogzfH5n2pBXBP9vRiU+eCny7lD2vmFZy79Iuw1U=
+cloud.google.com/go v0.100.2 h1:t9Iw5QH5v4XtlEQaCtUY7x6sCABps8sW0acw7e2WQ6Y=
 cloud.google.com/go v0.100.2/go.mod h1:4Xra9TjzAeYHrl5+oeLlzbM2k3mjVhZh4UqTZ//w99A=
 cloud.google.com/go/bigquery v1.0.1/go.mod h1:i/xbL2UlR5RvWAURpBYZTtm/cXjCha9lbfbpx4poX+o=
 cloud.google.com/go/bigquery v1.3.0/go.mod h1:PjpwJnslEMmckchkHFfq+HTD2DmtT67aNFKH1/VBDHE=
@@ -41,6 +43,11 @@ cloud.google.com/go/compute v1.5.0/go.mod h1:9SMHyhJlzhlkJqrPAc839t2BZFTSk6Jdj6m
 cloud.google.com/go/datastore v1.0.0/go.mod h1:LXYbyblFSglQ5pkeyhO+Qmw7ukd3C+pD7TKLgZqpHYE=
 cloud.google.com/go/datastore v1.1.0/go.mod h1:umbIZjpQpHh4hmRpGhH4tLFup+FVzqBi1b3c64qFpCk=
 cloud.google.com/go/firestore v1.1.0/go.mod h1:ulACoGHTpvq5r8rxGJ4ddJZBZqakUQqClKRT5SZwBmk=
+cloud.google.com/go/iam v0.1.0/go.mod h1:vcUNEa0pEm0qRVpmWepWaFMIAI8/hjB9mO8rNCJtF6c=
+cloud.google.com/go/iam v0.3.0 h1:exkAomrVUuzx9kWFI1wm3KI0uoDeUFPB4kKGzx6x+Gc=
+cloud.google.com/go/iam v0.3.0/go.mod h1:XzJPvDayI+9zsASAFO68Hk07u3z+f+JrT2xXNdp4bnY=
+cloud.google.com/go/kms v1.4.0 h1:iElbfoE61VeLhnZcGOltqL8HIly8Nhbe5t6JlH9GXjo=
+cloud.google.com/go/kms v1.4.0/go.mod h1:fajBHndQ+6ubNw6Ss2sSd+SWvjL26RNo/dr7uxsnnOA=
 cloud.google.com/go/pubsub v1.0.1/go.mod h1:R0Gpsv3s54REJCy4fxDixWD93lHJMoZTyQ2kNxGRt3I=
 cloud.google.com/go/pubsub v1.1.0/go.mod h1:EwwdRX2sKPjnvnqCa270oGRyludottCI76h+R3AArQw=
 cloud.google.com/go/pubsub v1.2.0/go.mod h1:jhfEVHT8odbXTkndysNHCcx0awwzvfOlguIAii9o8iA=
@@ -1094,8 +1101,8 @@ golang.org/x/sys v0.0.0-20220128215802-99c3d69c2c27/go.mod h1:oPkhp1MJrh7nUepCBc
 golang.org/x/sys v0.0.0-20220209214540-3681064d5158/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
 golang.org/x/sys v0.0.0-20220227234510-4e6760a101f9/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
 golang.org/x/sys v0.0.0-20220328115105-d36c6a25d886/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
-golang.org/x/sys v0.0.0-20220412211240-33da011f77ad h1:ntjMns5wyP/fN65tdBD4g8J5w8n015+iIIs9rtjXkY0=
-golang.org/x/sys v0.0.0-20220412211240-33da011f77ad/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
+golang.org/x/sys v0.0.0-20220513210249-45d2b4557a2a h1:N2T1jUrTQE9Re6TFF5PhvEHXHCguynGhKjWVsIUt5cY=
+golang.org/x/sys v0.0.0-20220513210249-45d2b4557a2a/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
 golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo=
 golang.org/x/term v0.0.0-20210927222741-03fcf44c2211 h1:JGgROgKl9N8DuW20oFS5gxc+lE67/N3FcwmBPMe7ArY=
 golang.org/x/term v0.0.0-20210927222741-03fcf44c2211/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8=
diff --git a/internal/sops/gcpkms/keysource.go b/internal/sops/gcpkms/keysource.go
new file mode 100644
index 0000000..575e4a3
--- /dev/null
+++ b/internal/sops/gcpkms/keysource.go
@@ -0,0 +1,181 @@
+// Copyright (C) 2022 The Flux authors
+//
+// This Source Code Form is subject to the terms of the Mozilla Public
+// License, v. 2.0. If a copy of the MPL was not distributed with this
+// file, You can obtain one at https://mozilla.org/MPL/2.0/.
+
+package gcpkms
+
+import (
+	"context"
+	"encoding/base64"
+	"fmt"
+	"regexp"
+	"time"
+
+	kms "cloud.google.com/go/kms/apiv1"
+	"google.golang.org/api/option"
+	kmspb "google.golang.org/genproto/googleapis/cloud/kms/v1"
+	"google.golang.org/grpc"
+)
+
+var (
+	// gcpkmsTTL is the duration after which a MasterKey requires rotation.
+	gcpkmsTTL = time.Hour * 24 * 30 * 6
+)
+
+// CredentialJSON is the service account keys used for authentication towards
+// GCP KMS.
+type CredentialJSON []byte
+
+// ApplyToMasterKey configures the CredentialJSON on the provided key.
+func (c CredentialJSON) ApplyToMasterKey(key *MasterKey) {
+	key.credentialJSON = c
+}
+
+// MasterKey is a GCP KMS key used to encrypt and decrypt the SOPS
+// data key.
+// Adapted from https://github.com/mozilla/sops/blob/v3.7.2/gcpkms/keysource.go
+// to be able to have fine-grain control over the credentials used to authenticate
+// towards GCP KMS.
+type MasterKey struct {
+	// ResourceID is the resource id used to refer to the gcp kms key.
+	// It can be retrieved using the `gcloud` command.
+	ResourceID string
+	// EncryptedKey is the string returned after encrypting with GCP KMS.
+	EncryptedKey string
+	// CreationDate is the creation timestamp of the MasterKey. Used
+	// for NeedsRotation.
+	CreationDate time.Time
+
+	// credentialJSON are the service account keys used to authenticate
+	// towards GCP KMS.
+	credentialJSON []byte
+	// grpcConn can be used to inject a custom GCP client connection.
+	// Mostly useful for testing at present, to wire the client to a mock
+	// server.
+	grpcConn *grpc.ClientConn
+}
+
+// MasterKeyFromResourceID creates a new MasterKey with the provided resource
+// ID.
+func MasterKeyFromResourceID(resourceID string) *MasterKey {
+	return &MasterKey{
+		ResourceID:   resourceID,
+		CreationDate: time.Now().UTC(),
+	}
+}
+
+// Encrypt takes a SOPS data key, encrypts it with GCP KMS, and stores the
+// result in the EncryptedKey field.
+func (key *MasterKey) Encrypt(datakey []byte) error {
+	cloudkmsService, err := key.newKMSClient()
+	if err != nil {
+		return err
+	}
+	defer cloudkmsService.Close()
+
+	req := &kmspb.EncryptRequest{
+		Name:      key.ResourceID,
+		Plaintext: datakey,
+	}
+	ctx := context.Background()
+	resp, err := cloudkmsService.Encrypt(ctx, req)
+	if err != nil {
+		return fmt.Errorf("failed to encrypt sops data key with GCP KMS: %w", err)
+	}
+	key.EncryptedKey = base64.StdEncoding.EncodeToString(resp.Ciphertext)
+	return nil
+}
+
+// SetEncryptedDataKey sets the encrypted data key for this master key.
+func (key *MasterKey) SetEncryptedDataKey(enc []byte) {
+	key.EncryptedKey = string(enc)
+}
+
+// EncryptedDataKey returns the encrypted data key this master key holds.
+func (key *MasterKey) EncryptedDataKey() []byte {
+	return []byte(key.EncryptedKey)
+}
+
+// EncryptIfNeeded encrypts the provided SOPS data key, if it has not been
+// encrypted yet.
+func (key *MasterKey) EncryptIfNeeded(dataKey []byte) error {
+	if key.EncryptedKey == "" {
+		return key.Encrypt(dataKey)
+	}
+	return nil
+}
+
+// Decrypt decrypts the EncryptedKey field with GCP KMS and returns
+// the result.
+func (key *MasterKey) Decrypt() ([]byte, error) {
+	service, err := key.newKMSClient()
+	if err != nil {
+		return nil, err
+	}
+	defer service.Close()
+
+	decodedCipher, err := base64.StdEncoding.DecodeString(string(key.EncryptedDataKey()))
+	if err != nil {
+		return nil, err
+	}
+	req := &kmspb.DecryptRequest{
+		Name:       key.ResourceID,
+		Ciphertext: decodedCipher,
+	}
+	ctx := context.Background()
+	resp, err := service.Decrypt(ctx, req)
+	if err != nil {
+		return nil, fmt.Errorf("failed to decrypt sops data key with GCP KMS Key: %w", err)
+	}
+
+	return resp.Plaintext, nil
+}
+
+// NeedsRotation returns whether the data key needs to be rotated or not.
+func (key *MasterKey) NeedsRotation() bool {
+	return time.Since(key.CreationDate) > (gcpkmsTTL)
+}
+
+// ToString converts the key to a string representation.
+func (key *MasterKey) ToString() string {
+	return key.ResourceID
+}
+
+// ToMap converts the MasterKey to a map for serialization purposes.
+func (key MasterKey) ToMap() map[string]interface{} {
+	out := make(map[string]interface{})
+	out["resource_id"] = key.ResourceID
+	out["created_at"] = key.CreationDate.UTC().Format(time.RFC3339)
+	out["enc"] = key.EncryptedKey
+	return out
+}
+
+// newKMSClient returns a GCP KMS client configured with the credentialJSON
+// and/or grpcConn, falling back to environmental defaults.
+// It returns an error if the ResourceID is invalid, or if the client setup
+// fails.
+func (key *MasterKey) newKMSClient() (*kms.KeyManagementClient, error) {
+	re := regexp.MustCompile(`^projects/[^/]+/locations/[^/]+/keyRings/[^/]+/cryptoKeys/[^/]+$`)
+	matches := re.FindStringSubmatch(key.ResourceID)
+	if matches == nil {
+		return nil, fmt.Errorf("no valid resourceId found in %q", key.ResourceID)
+	}
+
+	var opts []option.ClientOption
+	if key.credentialJSON != nil {
+		opts = append(opts, option.WithCredentialsJSON(key.credentialJSON))
+	}
+	if key.grpcConn != nil {
+		opts = append(opts, option.WithGRPCConn(key.grpcConn))
+	}
+
+	ctx := context.Background()
+	client, err := kms.NewKeyManagementClient(ctx, opts...)
+	if err != nil {
+		return nil, err
+	}
+
+	return client, nil
+}
diff --git a/internal/sops/keyservice/options.go b/internal/sops/keyservice/options.go
index 7508a26..ff7d5c4 100644
--- a/internal/sops/keyservice/options.go
+++ b/internal/sops/keyservice/options.go
@@ -8,6 +8,7 @@ package keyservice
 
 import (
 	extage "filippo.io/age"
+	"github.com/fluxcd/kustomize-controller/internal/sops/gcpkms"
 	"go.mozilla.org/sops/v3/keyservice"
 
 	"github.com/fluxcd/kustomize-controller/internal/sops/age"
@@ -57,6 +58,15 @@ func (o WithAWSKeys) ApplyToServer(s *Server) {
 	s.awsCredsProvider = o.CredsProvider
 }
 
+// WithGCPCredsJSON configures the GCP service account credentials JSON on the
+// Server.
+type WithGCPCredsJSON []byte
+
+// ApplyToServer applies this configuration to the given Server.
+func (o WithGCPCredsJSON) ApplyToServer(s *Server) {
+	s.gcpCredsJSON = gcpkms.CredentialJSON(o)
+}
+
 // WithAzureToken configures the Azure credential token on the Server.
 type WithAzureToken struct {
 	Token *azkv.Token
diff --git a/internal/sops/keyservice/server.go b/internal/sops/keyservice/server.go
index 8959c76..5c3190d 100644
--- a/internal/sops/keyservice/server.go
+++ b/internal/sops/keyservice/server.go
@@ -15,6 +15,7 @@ import (
 	"github.com/fluxcd/kustomize-controller/internal/sops/age"
 	"github.com/fluxcd/kustomize-controller/internal/sops/awskms"
 	"github.com/fluxcd/kustomize-controller/internal/sops/azkv"
+	"github.com/fluxcd/kustomize-controller/internal/sops/gcpkms"
 	"github.com/fluxcd/kustomize-controller/internal/sops/hcvault"
 	"github.com/fluxcd/kustomize-controller/internal/sops/pgp"
 )
@@ -51,6 +52,11 @@ type Server struct {
 	// When nil, the request will be handled by defaultServer.
 	awsCredsProvider *awskms.CredsProvider
 
+	// gcpCredsJSON is the JSON credentials used for Decrypt and Encrypt
+	// operations of GCP KMS requests. When nil, a default client with
+	// environmental runtime settings will be used.
+	gcpCredsJSON gcpkms.CredentialJSON
+
 	// defaultServer is the fallback server, used to handle any request that
 	// is not eligible to be handled by this Server.
 	defaultServer keyservice.KeyServiceServer
@@ -122,6 +128,14 @@ func (ks Server) Encrypt(ctx context.Context, req *keyservice.EncryptRequest) (*
 				Ciphertext: ciphertext,
 			}, nil
 		}
+	case *keyservice.Key_GcpKmsKey:
+		ciphertext, err := ks.encryptWithGCPKMS(k.GcpKmsKey, req.Plaintext)
+		if err != nil {
+			return nil, err
+		}
+		return &keyservice.EncryptResponse{
+			Ciphertext: ciphertext,
+		}, nil
 	case nil:
 		return nil, fmt.Errorf("must provide a key")
 	}
@@ -178,6 +192,14 @@ func (ks Server) Decrypt(ctx context.Context, req *keyservice.DecryptRequest) (*
 				Plaintext: plaintext,
 			}, nil
 		}
+	case *keyservice.Key_GcpKmsKey:
+		plaintext, err := ks.decryptWithGCPKMS(k.GcpKmsKey, req.Ciphertext)
+		if err != nil {
+			return nil, err
+		}
+		return &keyservice.DecryptResponse{
+			Plaintext: plaintext,
+		}, nil
 	case nil:
 		return nil, fmt.Errorf("must provide a key")
 	}
@@ -317,3 +339,24 @@ func (ks *Server) decryptWithAzureKeyVault(key *keyservice.AzureKeyVaultKey, cip
 	plaintext, err := azureKey.Decrypt()
 	return plaintext, err
 }
+
+func (ks *Server) encryptWithGCPKMS(key *keyservice.GcpKmsKey, plaintext []byte) ([]byte, error) {
+	gcpKey := gcpkms.MasterKey{
+		ResourceID: key.ResourceId,
+	}
+	ks.gcpCredsJSON.ApplyToMasterKey(&gcpKey)
+	if err := gcpKey.Encrypt(plaintext); err != nil {
+		return nil, err
+	}
+	return gcpKey.EncryptedDataKey(), nil
+}
+
+func (ks *Server) decryptWithGCPKMS(key *keyservice.GcpKmsKey, ciphertext []byte) ([]byte, error) {
+	gcpKey := gcpkms.MasterKey{
+		ResourceID: key.ResourceId,
+	}
+	ks.gcpCredsJSON.ApplyToMasterKey(&gcpKey)
+	gcpKey.EncryptedKey = string(ciphertext)
+	plaintext, err := gcpKey.Decrypt()
+	return plaintext, err
+}



Test patch:
diff --git a/controllers/kustomization_decryptor_test.go b/controllers/kustomization_decryptor_test.go
index 02eb414..d415e48 100644
--- a/controllers/kustomization_decryptor_test.go
+++ b/controllers/kustomization_decryptor_test.go
@@ -410,6 +410,29 @@ aws_session_token: test-token`),
 				g.Expect(decryptor.awsCredsProvider).ToNot(BeNil())
 			},
 		},
+		{
+			name: "GCP Service Account key",
+			decryption: &kustomizev1.Decryption{
+				Provider: provider,
+				SecretRef: &meta.LocalObjectReference{
+					Name: "gcpkms-secret",
+				},
+			},
+			secret: &corev1.Secret{
+				ObjectMeta: metav1.ObjectMeta{
+					Name:      "gcpkms-secret",
+					Namespace: provider,
+				},
+				Data: map[string][]byte{
+					DecryptionGCPCredsFile: []byte(`{ "client_id": "<client-id>.apps.googleusercontent.com",
+					"client_secret": "<secret>",
+				   "type": "authorized_user"}`),
+				},
+			},
+			inspectFunc: func(g *GomegaWithT, decryptor *KustomizeDecryptor) {
+				g.Expect(decryptor.gcpCredsJSON).ToNot(BeNil())
+			},
+		},
 		{
 			name: "Azure Key Vault token",
 			decryption: &kustomizev1.Decryption{
diff --git a/internal/sops/gcpkms/keysource_integration_test.go b/internal/sops/gcpkms/keysource_integration_test.go
new file mode 100644
index 0000000..e8412a4
--- /dev/null
+++ b/internal/sops/gcpkms/keysource_integration_test.go
@@ -0,0 +1,148 @@
+//go:build integration && disabled
+// +build integration,disabled
+
+// Copyright (C) 2022 The Flux authors
+//
+// This Source Code Form is subject to the terms of the Mozilla Public
+// License, v. 2.0. If a copy of the MPL was not distributed with this
+// file, You can obtain one at https://mozilla.org/MPL/2.0/.
+
+package gcpkms
+
+import (
+	"context"
+	"fmt"
+	"io/ioutil"
+	"os"
+	"testing"
+
+	. "github.com/onsi/gomega"
+	"go.mozilla.org/sops/v3/gcpkms"
+
+	"google.golang.org/api/option"
+	kmspb "google.golang.org/genproto/googleapis/cloud/kms/v1"
+	"google.golang.org/grpc/codes"
+	"google.golang.org/grpc/status"
+
+	kms "cloud.google.com/go/kms/apiv1"
+)
+
+var (
+	project       = os.Getenv("TEST_PROJECT")
+	testKeyring   = os.Getenv("TEST_KEYRING")
+	testKey       = os.Getenv("TEST_CRYPTO_KEY")
+	testCredsJSON = os.Getenv("TEST_CRED_JSON")
+	resourceID    = fmt.Sprintf("projects/%s/locations/global/keyRings/%s/cryptoKeys/%s",
+		project, testKeyring, testKey)
+)
+
+func TestMasterKey_Decrypt_SOPS_Compat(t *testing.T) {
+	g := NewWithT(t)
+	os.Setenv("GOOGLE_APPLICATION_CREDENTIALS", testCredsJSON)
+
+	g.Expect(createKMSKeyIfNotExists(resourceID)).To(Succeed())
+
+	dataKey := []byte("blue golden light")
+	encryptedKey := gcpkms.NewMasterKeyFromResourceID(resourceID)
+	g.Expect(encryptedKey.Encrypt(dataKey)).To(Succeed())
+
+	decryptionKey := MasterKeyFromResourceID(resourceID)
+	creds, err := ioutil.ReadFile(testCredsJSON)
+	g.Expect(err).ToNot(HaveOccurred())
+	decryptionKey.EncryptedKey = encryptedKey.EncryptedKey
+	decryptionKey.credentialJSON = creds
+	dec, err := decryptionKey.Decrypt()
+	g.Expect(err).ToNot(HaveOccurred())
+	g.Expect(dec).To(Equal(dataKey))
+}
+
+func TestMasterKey_Encrypt_SOPS_Compat(t *testing.T) {
+	os.Setenv("GOOGLE_APPLICATION_CREDENTIALS", testCredsJSON)
+	g := NewWithT(t)
+
+	g.Expect(createKMSKeyIfNotExists(resourceID)).To(Succeed())
+
+	dataKey := []byte("silver golden lights")
+
+	encryptionKey := MasterKeyFromResourceID(resourceID)
+	creds, err := ioutil.ReadFile(testCredsJSON)
+	g.Expect(err).ToNot(HaveOccurred())
+	encryptionKey.credentialJSON = creds
+	err = encryptionKey.Encrypt(dataKey)
+	g.Expect(err).ToNot(HaveOccurred())
+
+	decryptionKey := gcpkms.NewMasterKeyFromResourceID(resourceID)
+	decryptionKey.EncryptedKey = encryptionKey.EncryptedKey
+	dec, err := decryptionKey.Decrypt()
+	g.Expect(err).ToNot(HaveOccurred())
+	g.Expect(dec).To(Equal(dataKey))
+}
+
+func TestMasterKey_EncryptDecrypt_RoundTrip(t *testing.T) {
+	g := NewWithT(t)
+
+	g.Expect(createKMSKeyIfNotExists(resourceID)).To(Succeed())
+
+	key := MasterKeyFromResourceID(resourceID)
+	creds, err := ioutil.ReadFile(testCredsJSON)
+	g.Expect(err).ToNot(HaveOccurred())
+	key.credentialJSON = creds
+
+	datakey := []byte("a thousand splendid sons")
+	g.Expect(key.Encrypt(datakey)).To(Succeed())
+	g.Expect(key.EncryptedKey).ToNot(BeEmpty())
+
+	dec, err := key.Decrypt()
+	g.Expect(err).ToNot(HaveOccurred())
+	g.Expect(dec).To(Equal(datakey))
+}
+
+func createKMSKeyIfNotExists(resourceID string) error {
+	ctx := context.Background()
+	// check if crypto key exists if not create it
+	c, err := kms.NewKeyManagementClient(ctx, option.WithCredentialsFile(testCredsJSON))
+	if err != nil {
+		return fmt.Errorf("err creating client: %q", err)
+	}
+
+	getCryptoKeyReq := &kmspb.GetCryptoKeyRequest{
+		Name: resourceID,
+	}
+	_, err = c.GetCryptoKey(ctx, getCryptoKeyReq)
+	if err == nil {
+		return nil
+	}
+
+	e, ok := status.FromError(err)
+	if !ok || (ok && e.Code() != codes.NotFound) {
+		return fmt.Errorf("err getting crypto key: %q", err)
+	}
+
+	projectID := fmt.Sprintf("projects/%s/locations/global", project)
+	createKeyRingReq := &kmspb.CreateKeyRingRequest{
+		Parent:    projectID,
+		KeyRingId: testKeyring,
+	}
+
+	_, err = c.CreateKeyRing(ctx, createKeyRingReq)
+	e, ok = status.FromError(err)
+	if err != nil && !(ok && e.Code() == codes.AlreadyExists) {
+		return fmt.Errorf("err creating key ring: %q", err)
+	}
+
+	keyRingName := fmt.Sprintf("%s/keyRings/%s", projectID, testKeyring)
+	keyReq := &kmspb.CreateCryptoKeyRequest{
+		Parent:      keyRingName,
+		CryptoKeyId: testKey,
+		CryptoKey: &kmspb.CryptoKey{
+			Purpose: kmspb.CryptoKey_ENCRYPT_DECRYPT,
+		},
+	}
+	_, err = c.CreateCryptoKey(ctx, keyReq)
+	e, ok = status.FromError(err)
+	if err != nil && !(ok && e.Code() == codes.AlreadyExists) {
+		return fmt.Errorf("err creating crypto key: %q", err)
+	}
+
+	return nil
+}
diff --git a/internal/sops/gcpkms/keysource_test.go b/internal/sops/gcpkms/keysource_test.go
new file mode 100644
index 0000000..b5ce5eb
--- /dev/null
+++ b/internal/sops/gcpkms/keysource_test.go
@@ -0,0 +1,165 @@
+// Copyright (C) 2022 The Flux authors
+//
+// This Source Code Form is subject to the terms of the Mozilla Public
+// License, v. 2.0. If a copy of the MPL was not distributed with this
+// file, You can obtain one at https://mozilla.org/MPL/2.0/.
+
+package gcpkms
+
+import (
+	"encoding/base64"
+	"fmt"
+	"log"
+	"net"
+	"testing"
+	"time"
+
+	. "github.com/onsi/gomega"
+	kmspb "google.golang.org/genproto/googleapis/cloud/kms/v1"
+	"google.golang.org/grpc"
+)
+
+var (
+	testResourceID = "projects/test-flux/locations/global/keyRings/test-flux/cryptoKeys/sops"
+	decryptedData  = "decrypted data"
+	encryptedData  = "encrypted data"
+)
+
+func TestMasterKey_EncryptedDataKey(t *testing.T) {
+	g := NewWithT(t)
+	key := MasterKey{EncryptedKey: encryptedData}
+	g.Expect(key.EncryptedDataKey()).To(BeEquivalentTo(encryptedData))
+}
+
+func TestMasterKey_SetEncryptedDataKey(t *testing.T) {
+	g := NewWithT(t)
+	enc := "encrypted key"
+	key := &MasterKey{}
+	key.SetEncryptedDataKey([]byte(enc))
+	g.Expect(key.EncryptedDataKey()).To(BeEquivalentTo(enc))
+}
+
+func TestMasterKey_EncryptIfNeeded(t *testing.T) {
+	g := NewWithT(t)
+	key := MasterKey{EncryptedKey: "encrypted key"}
+	g.Expect(key.EncryptedDataKey()).To(BeEquivalentTo(key.EncryptedKey))
+
+	err := key.EncryptIfNeeded([]byte("sops data key"))
+	g.Expect(err).ToNot(HaveOccurred())
+	g.Expect(key.EncryptedDataKey()).To(BeEquivalentTo(key.EncryptedKey))
+}
+
+func TestMasterKey_ToString(t *testing.T) {
+	rsrcId := testResourceID
+	g := NewWithT(t)
+	key := MasterKeyFromResourceID(rsrcId)
+	g.Expect(key.ToString()).To(Equal(rsrcId))
+}
+
+func TestMasterKey_ToMap(t *testing.T) {
+	g := NewWithT(t)
+	key := MasterKey{
+		credentialJSON: []byte("sensitive creds"),
+		CreationDate:   time.Date(2016, time.October, 31, 10, 0, 0, 0, time.UTC),
+		ResourceID:     testResourceID,
+		EncryptedKey:   "this is encrypted",
+	}
+	g.Expect(key.ToMap()).To(Equal(map[string]interface{}{
+		"resource_id": testResourceID,
+		"enc":         "this is encrypted",
+		"created_at":  "2016-10-31T10:00:00Z",
+	}))
+}
+
+func TestMasterKey_createCloudKMSService(t *testing.T) {
+	g := NewWithT(t)
+
+	tests := []struct {
+		key       MasterKey
+		errString string
+	}{
+		{
+			key: MasterKey{
+				ResourceID:     "/projects",
+				credentialJSON: []byte("some secret"),
+			},
+			errString: "no valid resourceId",
+		},
+		{
+			key: MasterKey{
+				ResourceID: testResourceID,
+				credentialJSON: []byte(`{ "client_id": "<client-id>.apps.googleusercontent.com",
+ 		"client_secret": "<secret>",
+		"type": "authorized_user"}`),
+			},
+		},
+	}
+
+	for _, tt := range tests {
+		_, err := tt.key.newKMSClient()
+		if tt.errString != "" {
+			g.Expect(err).To(HaveOccurred())
+			g.Expect(err.Error()).To(ContainSubstring(tt.errString))
+		} else {
+			g.Expect(err).To(BeNil())
+		}
+	}
+}
+
+func TestMasterKey_Decrypt(t *testing.T) {
+	g := NewWithT(t)
+
+	mockKeyManagement.err = nil
+	mockKeyManagement.reqs = nil
+	mockKeyManagement.resps = append(mockKeyManagement.resps[:0], &kmspb.DecryptResponse{
+		Plaintext: []byte(decryptedData),
+	})
+	key := MasterKey{
+		grpcConn:     newGRPCServer("0"),
+		ResourceID:   testResourceID,
+		EncryptedKey: "encryptedKey",
+	}
+	data, err := key.Decrypt()
+	g.Expect(err).ToNot(HaveOccurred())
+	g.Expect(data).To(BeEquivalentTo(decryptedData))
+}
+
+func TestMasterKey_Encrypt(t *testing.T) {
+	g := NewWithT(t)
+
+	mockKeyManagement.err = nil
+	mockKeyManagement.reqs = nil
+	mockKeyManagement.resps = append(mockKeyManagement.resps[:0], &kmspb.EncryptResponse{
+		Ciphertext: []byte(encryptedData),
+	})
+
+	key := MasterKey{
+		grpcConn:   newGRPCServer("0"),
+		ResourceID: testResourceID,
+	}
+	err := key.Encrypt([]byte("encrypt"))
+	g.Expect(err).ToNot(HaveOccurred())
+	g.Expect(key.EncryptedDataKey()).To(BeEquivalentTo(base64.StdEncoding.EncodeToString([]byte(encryptedData))))
+}
+
+var (
+	mockKeyManagement mockKeyManagementServer
+)
+
+func newGRPCServer(port string) *grpc.ClientConn {
+	serv := grpc.NewServer()
+	kmspb.RegisterKeyManagementServiceServer(serv, &mockKeyManagement)
+
+	lis, err := net.Listen("tcp", fmt.Sprintf("localhost:%s", port))
+	if err != nil {
+		log.Fatal(err)
+	}
+	go serv.Serve(lis)
+
+	conn, err := grpc.Dial(lis.Addr().String(), grpc.WithInsecure())
+	if err != nil {
+		log.Fatal(err)
+	}
+
+	return conn
+}
diff --git a/internal/sops/gcpkms/mock_kms_server_test.go b/internal/sops/gcpkms/mock_kms_server_test.go
new file mode 100644
index 0000000..ec85d67
--- /dev/null
+++ b/internal/sops/gcpkms/mock_kms_server_test.go
@@ -0,0 +1,328 @@
+// Copyright 2019 Google LLC
+//
+// 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
+//
+//     https://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.
+
+// Code generated by gapic-generator. DO NOT EDIT.
+
+// Ref: https://github.com/googleapis/google-cloud-go/blob/4fe86a327f97ada275ce1744459129df38f9c95b/kms/apiv1/mock_test.go
+
+package gcpkms
+
+import (
+	"context"
+	"fmt"
+	"io"
+	"strings"
+
+	"github.com/golang/protobuf/proto"
+	"github.com/golang/protobuf/ptypes"
+	kmspb "google.golang.org/genproto/googleapis/cloud/kms/v1"
+
+	status "google.golang.org/genproto/googleapis/rpc/status"
+	"google.golang.org/grpc/metadata"
+)
+
+var _ = io.EOF
+var _ = ptypes.MarshalAny
+var _ status.Status
+
+type mockKeyManagementServer struct {
+	// Embed for forward compatibility.
+	// Tests will keep working if more methods are added
+	// in the future.
+	kmspb.KeyManagementServiceServer
+
+	reqs []proto.Message
+
+	// If set, all calls return this error.
+	err error
+
+	// responses to return if err == nil
+	resps []proto.Message
+}
+
+func (s *mockKeyManagementServer) ListKeyRings(ctx context.Context, req *kmspb.ListKeyRingsRequest) (*kmspb.ListKeyRingsResponse, error) {
+	md, _ := metadata.FromIncomingContext(ctx)
+	if xg := md["x-goog-api-client"]; len(xg) == 0 || !strings.Contains(xg[0], "gl-go/") {
+		return nil, fmt.Errorf("x-goog-api-client = %v, expected gl-go key", xg)
+	}
+	s.reqs = append(s.reqs, req)
+	if s.err != nil {
+		return nil, s.err
+	}
+	return s.resps[0].(*kmspb.ListKeyRingsResponse), nil
+}
+
+func (s *mockKeyManagementServer) ListCryptoKeys(ctx context.Context, req *kmspb.ListCryptoKeysRequest) (*kmspb.ListCryptoKeysResponse, error) {
+	md, _ := metadata.FromIncomingContext(ctx)
+	if xg := md["x-goog-api-client"]; len(xg) == 0 || !strings.Contains(xg[0], "gl-go/") {
+		return nil, fmt.Errorf("x-goog-api-client = %v, expected gl-go key", xg)
+	}
+	s.reqs = append(s.reqs, req)
+	if s.err != nil {
+		return nil, s.err
+	}
+	return s.resps[0].(*kmspb.ListCryptoKeysResponse), nil
+}
+
+func (s *mockKeyManagementServer) ListCryptoKeyVersions(ctx context.Context, req *kmspb.ListCryptoKeyVersionsRequest) (*kmspb.ListCryptoKeyVersionsResponse, error) {
+	md, _ := metadata.FromIncomingContext(ctx)
+	if xg := md["x-goog-api-client"]; len(xg) == 0 || !strings.Contains(xg[0], "gl-go/") {
+		return nil, fmt.Errorf("x-goog-api-client = %v, expected gl-go key", xg)
+	}
+	s.reqs = append(s.reqs, req)
+	if s.err != nil {
+		return nil, s.err
+	}
+	return s.resps[0].(*kmspb.ListCryptoKeyVersionsResponse), nil
+}
+
+func (s *mockKeyManagementServer) ListImportJobs(ctx context.Context, req *kmspb.ListImportJobsRequest) (*kmspb.ListImportJobsResponse, error) {
+	md, _ := metadata.FromIncomingContext(ctx)
+	if xg := md["x-goog-api-client"]; len(xg) == 0 || !strings.Contains(xg[0], "gl-go/") {
+		return nil, fmt.Errorf("x-goog-api-client = %v, expected gl-go key", xg)
+	}
+	s.reqs = append(s.reqs, req)
+	if s.err != nil {
+		return nil, s.err
+	}
+	return s.resps[0].(*kmspb.ListImportJobsResponse), nil
+}
+
+func (s *mockKeyManagementServer) GetKeyRing(ctx context.Context, req *kmspb.GetKeyRingRequest) (*kmspb.KeyRing, error) {
+	md, _ := metadata.FromIncomingContext(ctx)
+	if xg := md["x-goog-api-client"]; len(xg) == 0 || !strings.Contains(xg[0], "gl-go/") {
+		return nil, fmt.Errorf("x-goog-api-client = %v, expected gl-go key", xg)
+	}
+	s.reqs = append(s.reqs, req)
+	if s.err != nil {
+		return nil, s.err
+	}
+	return s.resps[0].(*kmspb.KeyRing), nil
+}
+
+func (s *mockKeyManagementServer) GetCryptoKey(ctx context.Context, req *kmspb.GetCryptoKeyRequest) (*kmspb.CryptoKey, error) {
+	md, _ := metadata.FromIncomingContext(ctx)
+	if xg := md["x-goog-api-client"]; len(xg) == 0 || !strings.Contains(xg[0], "gl-go/") {
+		return nil, fmt.Errorf("x-goog-api-client = %v, expected gl-go key", xg)
+	}
+	s.reqs = append(s.reqs, req)
+	if s.err != nil {
+		return nil, s.err
+	}
+	return s.resps[0].(*kmspb.CryptoKey), nil
+}
+
+func (s *mockKeyManagementServer) GetCryptoKeyVersion(ctx context.Context, req *kmspb.GetCryptoKeyVersionRequest) (*kmspb.CryptoKeyVersion, error) {
+	md, _ := metadata.FromIncomingContext(ctx)
+	if xg := md["x-goog-api-client"]; len(xg) == 0 || !strings.Contains(xg[0], "gl-go/") {
+		return nil, fmt.Errorf("x-goog-api-client = %v, expected gl-go key", xg)
+	}
+	s.reqs = append(s.reqs, req)
+	if s.err != nil {
+		return nil, s.err
+	}
+	return s.resps[0].(*kmspb.CryptoKeyVersion), nil
+}
+
+func (s *mockKeyManagementServer) GetPublicKey(ctx context.Context, req *kmspb.GetPublicKeyRequest) (*kmspb.PublicKey, error) {
+	md, _ := metadata.FromIncomingContext(ctx)
+	if xg := md["x-goog-api-client"]; len(xg) == 0 || !strings.Contains(xg[0], "gl-go/") {
+		return nil, fmt.Errorf("x-goog-api-client = %v, expected gl-go key", xg)
+	}
+	s.reqs = append(s.reqs, req)
+	if s.err != nil {
+		return nil, s.err
+	}
+	return s.resps[0].(*kmspb.PublicKey), nil
+}
+
+func (s *mockKeyManagementServer) GetImportJob(ctx context.Context, req *kmspb.GetImportJobRequest) (*kmspb.ImportJob, error) {
+	md, _ := metadata.FromIncomingContext(ctx)
+	if xg := md["x-goog-api-client"]; len(xg) == 0 || !strings.Contains(xg[0], "gl-go/") {
+		return nil, fmt.Errorf("x-goog-api-client = %v, expected gl-go key", xg)
+	}
+	s.reqs = append(s.reqs, req)
+	if s.err != nil {
+		return nil, s.err
+	}
+	return s.resps[0].(*kmspb.ImportJob), nil
+}
+
+func (s *mockKeyManagementServer) CreateKeyRing(ctx context.Context, req *kmspb.CreateKeyRingRequest) (*kmspb.KeyRing, error) {
+	md, _ := metadata.FromIncomingContext(ctx)
+	if xg := md["x-goog-api-client"]; len(xg) == 0 || !strings.Contains(xg[0], "gl-go/") {
+		return nil, fmt.Errorf("x-goog-api-client = %v, expected gl-go key", xg)
+	}
+	s.reqs = append(s.reqs, req)
+	if s.err != nil {
+		return nil, s.err
+	}
+	return s.resps[0].(*kmspb.KeyRing), nil
+}
+
+func (s *mockKeyManagementServer) CreateCryptoKey(ctx context.Context, req *kmspb.CreateCryptoKeyRequest) (*kmspb.CryptoKey, error) {
+	md, _ := metadata.FromIncomingContext(ctx)
+	if xg := md["x-goog-api-client"]; len(xg) == 0 || !strings.Contains(xg[0], "gl-go/") {
+		return nil, fmt.Errorf("x-goog-api-client = %v, expected gl-go key", xg)
+	}
+	s.reqs = append(s.reqs, req)
+	if s.err != nil {
+		return nil, s.err
+	}
+	return s.resps[0].(*kmspb.CryptoKey), nil
+}
+
+func (s *mockKeyManagementServer) CreateCryptoKeyVersion(ctx context.Context, req *kmspb.CreateCryptoKeyVersionRequest) (*kmspb.CryptoKeyVersion, error) {
+	md, _ := metadata.FromIncomingContext(ctx)
+	if xg := md["x-goog-api-client"]; len(xg) == 0 || !strings.Contains(xg[0], "gl-go/") {
+		return nil, fmt.Errorf("x-goog-api-client = %v, expected gl-go key", xg)
+	}
+	s.reqs = append(s.reqs, req)
+	if s.err != nil {
+		return nil, s.err
+	}
+	return s.resps[0].(*kmspb.CryptoKeyVersion), nil
+}
+
+func (s *mockKeyManagementServer) ImportCryptoKeyVersion(ctx context.Context, req *kmspb.ImportCryptoKeyVersionRequest) (*kmspb.CryptoKeyVersion, error) {
+	md, _ := metadata.FromIncomingContext(ctx)
+	if xg := md["x-goog-api-client"]; len(xg) == 0 || !strings.Contains(xg[0], "gl-go/") {
+		return nil, fmt.Errorf("x-goog-api-client = %v, expected gl-go key", xg)
+	}
+	s.reqs = append(s.reqs, req)
+	if s.err != nil {
+		return nil, s.err
+	}
+	return s.resps[0].(*kmspb.CryptoKeyVersion), nil
+}
+
+func (s *mockKeyManagementServer) CreateImportJob(ctx context.Context, req *kmspb.CreateImportJobRequest) (*kmspb.ImportJob, error) {
+	md, _ := metadata.FromIncomingContext(ctx)
+	if xg := md["x-goog-api-client"]; len(xg) == 0 || !strings.Contains(xg[0], "gl-go/") {
+		return nil, fmt.Errorf("x-goog-api-client = %v, expected gl-go key", xg)
+	}
+	s.reqs = append(s.reqs, req)
+	if s.err != nil {
+		return nil, s.err
+	}
+	return s.resps[0].(*kmspb.ImportJob), nil
+}
+
+func (s *mockKeyManagementServer) UpdateCryptoKey(ctx context.Context, req *kmspb.UpdateCryptoKeyRequest) (*kmspb.CryptoKey, error) {
+	md, _ := metadata.FromIncomingContext(ctx)
+	if xg := md["x-goog-api-client"]; len(xg) == 0 || !strings.Contains(xg[0], "gl-go/") {
+		return nil, fmt.Errorf("x-goog-api-client = %v, expected gl-go key", xg)
+	}
+	s.reqs = append(s.reqs, req)
+	if s.err != nil {
+		return nil, s.err
+	}
+	return s.resps[0].(*kmspb.CryptoKey), nil
+}
+
+func (s *mockKeyManagementServer) UpdateCryptoKeyVersion(ctx context.Context, req *kmspb.UpdateCryptoKeyVersionRequest) (*kmspb.CryptoKeyVersion, error) {
+	md, _ := metadata.FromIncomingContext(ctx)
+	if xg := md["x-goog-api-client"]; len(xg) == 0 || !strings.Contains(xg[0], "gl-go/") {
+		return nil, fmt.Errorf("x-goog-api-client = %v, expected gl-go key", xg)
+	}
+	s.reqs = append(s.reqs, req)
+	if s.err != nil {
+		return nil, s.err
+	}
+	return s.resps[0].(*kmspb.CryptoKeyVersion), nil
+}
+
+func (s *mockKeyManagementServer) Encrypt(ctx context.Context, req *kmspb.EncryptRequest) (*kmspb.EncryptResponse, error) {
+	md, _ := metadata.FromIncomingContext(ctx)
+	if xg := md["x-goog-api-client"]; len(xg) == 0 || !strings.Contains(xg[0], "gl-go/") {
+		return nil, fmt.Errorf("x-goog-api-client = %v, expected gl-go key", xg)
+	}
+	s.reqs = append(s.reqs, req)
+	if s.err != nil {
+		return nil, s.err
+	}
+	return s.resps[0].(*kmspb.EncryptResponse), nil
+}
+
+func (s *mockKeyManagementServer) Decrypt(ctx context.Context, req *kmspb.DecryptRequest) (*kmspb.DecryptResponse, error) {
+	md, _ := metadata.FromIncomingContext(ctx)
+	if xg := md["x-goog-api-client"]; len(xg) == 0 || !strings.Contains(xg[0], "gl-go/") {
+		return nil, fmt.Errorf("x-goog-api-client = %v, expected gl-go key", xg)
+	}
+	s.reqs = append(s.reqs, req)
+	if s.err != nil {
+		return nil, s.err
+	}
+	return s.resps[0].(*kmspb.DecryptResponse), nil
+}
+
+func (s *mockKeyManagementServer) AsymmetricSign(ctx context.Context, req *kmspb.AsymmetricSignRequest) (*kmspb.AsymmetricSignResponse, error) {
+	md, _ := metadata.FromIncomingContext(ctx)
+	if xg := md["x-goog-api-client"]; len(xg) == 0 || !strings.Contains(xg[0], "gl-go/") {
+		return nil, fmt.Errorf("x-goog-api-client = %v, expected gl-go key", xg)
+	}
+	s.reqs = append(s.reqs, req)
+	if s.err != nil {
+		return nil, s.err
+	}
+	return s.resps[0].(*kmspb.AsymmetricSignResponse), nil
+}
+
+func (s *mockKeyManagementServer) AsymmetricDecrypt(ctx context.Context, req *kmspb.AsymmetricDecryptRequest) (*kmspb.AsymmetricDecryptResponse, error) {
+	md, _ := metadata.FromIncomingContext(ctx)
+	if xg := md["x-goog-api-client"]; len(xg) == 0 || !strings.Contains(xg[0], "gl-go/") {
+		return nil, fmt.Errorf("x-goog-api-client = %v, expected gl-go key", xg)
+	}
+	s.reqs = append(s.reqs, req)
+	if s.err != nil {
+		return nil, s.err
+	}
+	return s.resps[0].(*kmspb.AsymmetricDecryptResponse), nil
+}
+
+func (s *mockKeyManagementServer) UpdateCryptoKeyPrimaryVersion(ctx context.Context, req *kmspb.UpdateCryptoKeyPrimaryVersionRequest) (*kmspb.CryptoKey, error) {
+	md, _ := metadata.FromIncomingContext(ctx)
+	if xg := md["x-goog-api-client"]; len(xg) == 0 || !strings.Contains(xg[0], "gl-go/") {
+		return nil, fmt.Errorf("x-goog-api-client = %v, expected gl-go key", xg)
+	}
+	s.reqs = append(s.reqs, req)
+	if s.err != nil {
+		return nil, s.err
+	}
+	return s.resps[0].(*kmspb.CryptoKey), nil
+}
+
+func (s *mockKeyManagementServer) DestroyCryptoKeyVersion(ctx context.Context, req *kmspb.DestroyCryptoKeyVersionRequest) (*kmspb.CryptoKeyVersion, error) {
+	md, _ := metadata.FromIncomingContext(ctx)
+	if xg := md["x-goog-api-client"]; len(xg) == 0 || !strings.Contains(xg[0], "gl-go/") {
+		return nil, fmt.Errorf("x-goog-api-client = %v, expected gl-go key", xg)
+	}
+	s.reqs = append(s.reqs, req)
+	if s.err != nil {
+		return nil, s.err
+	}
+	return s.resps[0].(*kmspb.CryptoKeyVersion), nil
+}
+
+func (s *mockKeyManagementServer) RestoreCryptoKeyVersion(ctx context.Context, req *kmspb.RestoreCryptoKeyVersionRequest) (*kmspb.CryptoKeyVersion, error) {
+	md, _ := metadata.FromIncomingContext(ctx)
+	if xg := md["x-goog-api-client"]; len(xg) == 0 || !strings.Contains(xg[0], "gl-go/") {
+		return nil, fmt.Errorf("x-goog-api-client = %v, expected gl-go key", xg)
+	}
+	s.reqs = append(s.reqs, req)
+	if s.err != nil {
+		return nil, s.err
+	}
+	return s.resps[0].(*kmspb.CryptoKeyVersion), nil
+}
diff --git a/internal/sops/keyservice/server_test.go b/internal/sops/keyservice/server_test.go
index a0fa1a3..fb4da31 100644
--- a/internal/sops/keyservice/server_test.go
+++ b/internal/sops/keyservice/server_test.go
@@ -20,6 +20,7 @@ import (
 	"github.com/fluxcd/kustomize-controller/internal/sops/age"
 	"github.com/fluxcd/kustomize-controller/internal/sops/awskms"
 	"github.com/fluxcd/kustomize-controller/internal/sops/azkv"
+	"github.com/fluxcd/kustomize-controller/internal/sops/gcpkms"
 	"github.com/fluxcd/kustomize-controller/internal/sops/hcvault"
 	"github.com/fluxcd/kustomize-controller/internal/sops/pgp"
 )
@@ -128,7 +129,6 @@ func TestServer_EncryptDecrypt_HCVault_Fallback(t *testing.T) {
 
 	fallback = NewMockKeyServer()
 	s = NewServer(WithDefaultServer{Server: fallback})
-
 	decReq := &keyservice.DecryptRequest{
 		Key:        &key,
 		Ciphertext: []byte("some ciphertext"),
@@ -211,6 +211,30 @@ func TestServer_EncryptDecrypt_azkv_Fallback(t *testing.T) {
 	g.Expect(fallback.encryptReqs).To(HaveLen(0))
 }
 
+func TestServer_EncryptDecrypt_gcpkms(t *testing.T) {
+	g := NewWithT(t)
+
+	creds := `{ "client_id": "<client-id>.apps.googleusercontent.com",
+ 		"client_secret": "<secret>",
+		"type": "authorized_user"}`
+	s := NewServer(WithGCPCredsJSON([]byte(creds)))
+
+	resourceID := "projects/test-flux/locations/global/keyRings/test-flux/cryptoKeys/sops"
+	key := KeyFromMasterKey(gcpkms.MasterKeyFromResourceID(resourceID))
+	_, err := s.Encrypt(context.TODO(), &keyservice.EncryptRequest{
+		Key: &key,
+	})
+	g.Expect(err).To(HaveOccurred())
+	g.Expect(err.Error()).To(ContainSubstring("failed to encrypt sops data key with GCP KMS"))
+
+	_, err = s.Decrypt(context.TODO(), &keyservice.DecryptRequest{
+		Key: &key,
+	})
+	g.Expect(err).To(HaveOccurred())
+	g.Expect(err.Error()).To(ContainSubstring("failed to decrypt sops data key with GCP KMS"))
+
+}
+
 func TestServer_EncryptDecrypt_Nil_KeyType(t *testing.T) {
 	g := NewWithT(t)
 
diff --git a/internal/sops/keyservice/utils_test.go b/internal/sops/keyservice/utils_test.go
index e0d3bad..9bdfb5b 100644
--- a/internal/sops/keyservice/utils_test.go
+++ b/internal/sops/keyservice/utils_test.go
@@ -16,6 +16,7 @@ import (
 	"github.com/fluxcd/kustomize-controller/internal/sops/age"
 	"github.com/fluxcd/kustomize-controller/internal/sops/awskms"
 	"github.com/fluxcd/kustomize-controller/internal/sops/azkv"
+	"github.com/fluxcd/kustomize-controller/internal/sops/gcpkms"
 	"github.com/fluxcd/kustomize-controller/internal/sops/hcvault"
 	"github.com/fluxcd/kustomize-controller/internal/sops/pgp"
 )
@@ -68,6 +69,14 @@ func KeyFromMasterKey(k keys.MasterKey) keyservice.Key {
 				},
 			},
 		}
+	case *gcpkms.MasterKey:
+		return keyservice.Key{
+			KeyType: &keyservice.Key_GcpKmsKey{
+				GcpKmsKey: &keyservice.GcpKmsKey{
+					ResourceId: mk.ResourceID,
+				},
+			},
+		}
 	default:
 		panic(fmt.Sprintf("tried to convert unknown MasterKey type %T to keyservice.Key", mk))
 	}