diff --git a/backend/package-lock.json b/backend/package-lock.json index 2113d21a6..277543d39 100644 --- a/backend/package-lock.json +++ b/backend/package-lock.json @@ -28,6 +28,7 @@ "@fastify/session": "^10.7.0", "@fastify/swagger": "^8.14.0", "@fastify/swagger-ui": "^2.1.0", + "@google-cloud/kms": "^4.5.0", "@node-saml/passport-saml": "^4.0.4", "@octokit/auth-app": "^7.1.1", "@octokit/plugin-retry": "^5.0.5", @@ -5598,6 +5599,18 @@ "yaml": "^2.2.2" } }, + "node_modules/@google-cloud/kms": { + "version": "4.5.0", + "resolved": "https://registry.npmjs.org/@google-cloud/kms/-/kms-4.5.0.tgz", + "integrity": "sha512-i2vC0DI7bdfEhQszqASTw0KVvbB7HsO2CwTBod423NawAu7FWi+gVVa7NLfXVNGJaZZayFfci2Hu+om/HmyEjQ==", + "license": "Apache-2.0", + "dependencies": { + "google-gax": "^4.0.3" + }, + "engines": { + "node": ">=14.0.0" + } + }, "node_modules/@google-cloud/paginator": { "version": "5.0.2", "resolved": "https://registry.npmjs.org/@google-cloud/paginator/-/paginator-5.0.2.tgz", @@ -15086,6 +15099,44 @@ "safe-buffer": "^5.0.1" } }, + "node_modules/google-gax": { + "version": "4.4.1", + "resolved": "https://registry.npmjs.org/google-gax/-/google-gax-4.4.1.tgz", + "integrity": "sha512-Phyp9fMfA00J3sZbJxbbB4jC55b7DBjE3F6poyL3wKMEBVKA79q6BGuHcTiM28yOzVql0NDbRL8MLLh8Iwk9Dg==", + "license": "Apache-2.0", + "dependencies": { + "@grpc/grpc-js": "^1.10.9", + "@grpc/proto-loader": "^0.7.13", + "@types/long": "^4.0.0", + "abort-controller": "^3.0.0", + "duplexify": "^4.0.0", + "google-auth-library": "^9.3.0", + "node-fetch": "^2.7.0", + "object-hash": "^3.0.0", + "proto3-json-serializer": "^2.0.2", + "protobufjs": "^7.3.2", + "retry-request": "^7.0.0", + "uuid": "^9.0.1" + }, + "engines": { + "node": ">=14" + } + }, + "node_modules/google-gax/node_modules/@types/long": { + "version": "4.0.2", + "resolved": "https://registry.npmjs.org/@types/long/-/long-4.0.2.tgz", + "integrity": "sha512-MqTGEo5bj5t157U6fA/BiDynNkn0YknVdh48CMPkTSpFTVmvao5UQmm7uEF6xBEo7qIMAlY/JSleYaE6VOdpaA==", + "license": "MIT" + }, + "node_modules/google-gax/node_modules/object-hash": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/object-hash/-/object-hash-3.0.0.tgz", + "integrity": "sha512-RSn9F68PjH9HqtltsSnqYC1XXoWe9Bju5+213R98cNGttag9q9yAOTzdbsqvIa7aNm5WffBZFpWYr2aWrklWAw==", + "license": "MIT", + "engines": { + "node": ">= 6" + } + }, "node_modules/googleapis": { "version": "137.1.0", "resolved": "https://registry.npmjs.org/googleapis/-/googleapis-137.1.0.tgz", @@ -19223,6 +19274,18 @@ "node": ">=6" } }, + "node_modules/proto3-json-serializer": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/proto3-json-serializer/-/proto3-json-serializer-2.0.2.tgz", + "integrity": "sha512-SAzp/O4Yh02jGdRc+uIrGoe87dkN/XtwxfZ4ZyafJHymd79ozp5VG5nyZ7ygqPM5+cpLDjjGnYFUkngonyDPOQ==", + "license": "Apache-2.0", + "dependencies": { + "protobufjs": "^7.2.5" + }, + "engines": { + "node": ">=14.0.0" + } + }, "node_modules/protobufjs": { "version": "7.4.0", "resolved": "https://registry.npmjs.org/protobufjs/-/protobufjs-7.4.0.tgz", diff --git a/backend/package.json b/backend/package.json index 1aabdde2e..6f932c4c8 100644 --- a/backend/package.json +++ b/backend/package.json @@ -136,6 +136,7 @@ "@fastify/session": "^10.7.0", "@fastify/swagger": "^8.14.0", "@fastify/swagger-ui": "^2.1.0", + "@google-cloud/kms": "^4.5.0", "@node-saml/passport-saml": "^4.0.4", "@octokit/auth-app": "^7.1.1", "@octokit/plugin-retry": "^5.0.5", diff --git a/backend/src/ee/routes/v1/external-kms-router.ts b/backend/src/ee/routes/v1/external-kms-router.ts index 4e43d6ed9..a48e28e3d 100644 --- a/backend/src/ee/routes/v1/external-kms-router.ts +++ b/backend/src/ee/routes/v1/external-kms-router.ts @@ -4,9 +4,15 @@ import { ExternalKmsSchema, KmsKeysSchema } from "@app/db/schemas"; import { EventType } from "@app/ee/services/audit-log/audit-log-types"; import { ExternalKmsAwsSchema, + ExternalKmsGcpCredentialSchema, + ExternalKmsGcpSchema, ExternalKmsInputSchema, - ExternalKmsInputUpdateSchema + ExternalKmsInputUpdateSchema, + KmsGcpKeyFetchAuthType, + KmsProviders, + TExternalKmsGcpCredentialSchema } from "@app/ee/services/external-kms/providers/model"; +import { NotFoundError } from "@app/lib/errors"; import { readLimit, writeLimit } from "@app/server/config/rateLimiter"; import { verifyAuth } from "@app/server/plugins/auth/verify-auth"; import { AuthMode } from "@app/services/auth/auth-type"; @@ -44,7 +50,8 @@ const sanitizedExternalSchemaForGetById = KmsKeysSchema.extend({ statusDetails: true, provider: true }).extend({ - providerInput: ExternalKmsAwsSchema + // for GCP, we don't return the credential object as it is sensitive data that should not be exposed + providerInput: z.union([ExternalKmsAwsSchema, ExternalKmsGcpSchema.pick({ gcpRegion: true, keyName: true })]) }) }); @@ -286,4 +293,67 @@ export const registerExternalKmsRouter = async (server: FastifyZodProvider) => { return { externalKms }; } }); + + server.route({ + method: "POST", + url: "/gcp/keys", + config: { + rateLimit: writeLimit + }, + schema: { + body: z.discriminatedUnion("authMethod", [ + z.object({ + authMethod: z.literal(KmsGcpKeyFetchAuthType.Credential), + region: z.string().trim().min(1), + credential: ExternalKmsGcpCredentialSchema + }), + z.object({ + authMethod: z.literal(KmsGcpKeyFetchAuthType.Kms), + region: z.string().trim().min(1), + kmsId: z.string().trim().min(1) + }) + ]), + response: { + 200: z.object({ + keys: z.string().array() + }) + } + }, + onRequest: verifyAuth([AuthMode.JWT, AuthMode.IDENTITY_ACCESS_TOKEN]), + handler: async (req) => { + const { region, authMethod } = req.body; + let credentialJson: TExternalKmsGcpCredentialSchema | undefined; + + if (authMethod === KmsGcpKeyFetchAuthType.Credential) { + credentialJson = req.body.credential; + } else if (authMethod === KmsGcpKeyFetchAuthType.Kms) { + const externalKms = await server.services.externalKms.findById({ + actor: req.permission.type, + actorId: req.permission.id, + actorAuthMethod: req.permission.authMethod, + actorOrgId: req.permission.orgId, + id: req.body.kmsId + }); + + if (!externalKms || externalKms.external.provider !== KmsProviders.Gcp) { + throw new NotFoundError({ message: "KMS not found or not of type GCP" }); + } + + credentialJson = externalKms.external.providerInput.credential as TExternalKmsGcpCredentialSchema; + } + + if (!credentialJson) { + throw new NotFoundError({ + message: "Something went wrong while fetching the GCP credential, please check inputs and try again" + }); + } + + const results = await server.services.externalKms.fetchGcpKeys({ + credential: credentialJson, + gcpRegion: region + }); + + return results; + } + }); }; diff --git a/backend/src/ee/services/external-kms/external-kms-service.ts b/backend/src/ee/services/external-kms/external-kms-service.ts index c3b774afd..8dd80ce2c 100644 --- a/backend/src/ee/services/external-kms/external-kms-service.ts +++ b/backend/src/ee/services/external-kms/external-kms-service.ts @@ -20,7 +20,8 @@ import { TUpdateExternalKmsDTO } from "./external-kms-types"; import { AwsKmsProviderFactory } from "./providers/aws-kms"; -import { ExternalKmsAwsSchema, KmsProviders } from "./providers/model"; +import { GcpKmsProviderFactory } from "./providers/gcp-kms"; +import { ExternalKmsAwsSchema, ExternalKmsGcpSchema, KmsProviders, TExternalKmsGcpSchema } from "./providers/model"; type TExternalKmsServiceFactoryDep = { externalKmsDAL: TExternalKmsDALFactory; @@ -78,6 +79,13 @@ export const externalKmsServiceFactory = ({ await externalKms.validateConnection(); } break; + case KmsProviders.Gcp: + { + const externalKms = await GcpKmsProviderFactory({ inputs: provider.inputs }); + await externalKms.validateConnection(); + sanitizedProviderInput = JSON.stringify(provider.inputs); + } + break; default: throw new BadRequestError({ message: "external kms provided is invalid" }); } @@ -88,7 +96,7 @@ export const externalKmsServiceFactory = ({ }); const { cipherTextBlob: encryptedProviderInputs } = orgDataKeyEncryptor({ - plainText: Buffer.from(sanitizedProviderInput, "utf8") + plainText: Buffer.from(sanitizedProviderInput) }); const externalKms = await externalKmsDAL.transaction(async (tx) => { @@ -162,7 +170,7 @@ export const externalKmsServiceFactory = ({ case KmsProviders.Aws: { const decryptedProviderInput = await ExternalKmsAwsSchema.parseAsync( - JSON.parse(decryptedProviderInputBlob.toString("utf8")) + JSON.parse(decryptedProviderInputBlob.toString()) ); const updatedProviderInput = { ...decryptedProviderInput, ...provider.inputs }; const externalKms = await AwsKmsProviderFactory({ inputs: updatedProviderInput }); @@ -170,6 +178,17 @@ export const externalKmsServiceFactory = ({ sanitizedProviderInput = JSON.stringify(updatedProviderInput); } break; + case KmsProviders.Gcp: + { + const decryptedProviderInput = await ExternalKmsGcpSchema.parseAsync( + JSON.parse(decryptedProviderInputBlob.toString()) + ); + const updatedProviderInput = { ...decryptedProviderInput, ...provider.inputs }; + const externalKms = await GcpKmsProviderFactory({ inputs: updatedProviderInput }); + await externalKms.validateConnection(); + sanitizedProviderInput = JSON.stringify(updatedProviderInput); + } + break; default: throw new BadRequestError({ message: "external kms provided is invalid" }); } @@ -178,7 +197,7 @@ export const externalKmsServiceFactory = ({ let encryptedProviderInputs: Buffer | undefined; if (sanitizedProviderInput) { const { cipherTextBlob } = orgDataKeyEncryptor({ - plainText: Buffer.from(sanitizedProviderInput, "utf8") + plainText: Buffer.from(sanitizedProviderInput) }); encryptedProviderInputs = cipherTextBlob; } @@ -271,10 +290,17 @@ export const externalKmsServiceFactory = ({ switch (externalKmsDoc.provider) { case KmsProviders.Aws: { const decryptedProviderInput = await ExternalKmsAwsSchema.parseAsync( - JSON.parse(decryptedProviderInputBlob.toString("utf8")) + JSON.parse(decryptedProviderInputBlob.toString()) ); return { ...kmsDoc, external: { ...externalKmsDoc, providerInput: decryptedProviderInput } }; } + case KmsProviders.Gcp: { + const decryptedProviderInput = await ExternalKmsGcpSchema.parseAsync( + JSON.parse(decryptedProviderInputBlob.toString()) + ); + + return { ...kmsDoc, external: { ...externalKmsDoc, providerInput: decryptedProviderInput } }; + } default: throw new BadRequestError({ message: "external kms provided is invalid" }); } @@ -312,21 +338,34 @@ export const externalKmsServiceFactory = ({ switch (externalKmsDoc.provider) { case KmsProviders.Aws: { const decryptedProviderInput = await ExternalKmsAwsSchema.parseAsync( - JSON.parse(decryptedProviderInputBlob.toString("utf8")) + JSON.parse(decryptedProviderInputBlob.toString()) ); return { ...kmsDoc, external: { ...externalKmsDoc, providerInput: decryptedProviderInput } }; } + case KmsProviders.Gcp: { + const decryptedProviderInput = await ExternalKmsGcpSchema.parseAsync( + JSON.parse(decryptedProviderInputBlob.toString()) + ); + + return { ...kmsDoc, external: { ...externalKmsDoc, providerInput: decryptedProviderInput } }; + } default: throw new BadRequestError({ message: "external kms provided is invalid" }); } }; + const fetchGcpKeys = async ({ credential, gcpRegion }: Pick) => { + const externalKms = await GcpKmsProviderFactory({ inputs: { credential, gcpRegion, keyName: "" } }); + return externalKms.getKeysList(); + }; + return { create, updateById, deleteById, list, findById, - findByName + findByName, + fetchGcpKeys }; }; diff --git a/backend/src/ee/services/external-kms/providers/gcp-kms.ts b/backend/src/ee/services/external-kms/providers/gcp-kms.ts new file mode 100644 index 000000000..b3b61694b --- /dev/null +++ b/backend/src/ee/services/external-kms/providers/gcp-kms.ts @@ -0,0 +1,113 @@ +import { KeyManagementServiceClient } from "@google-cloud/kms"; + +import { BadRequestError } from "@app/lib/errors"; +import { logger } from "@app/lib/logger"; + +import { ExternalKmsGcpSchema, TExternalKmsGcpClientSchema, TExternalKmsProviderFns } from "./model"; + +const getGcpKmsClient = async ({ credential, gcpRegion }: TExternalKmsGcpClientSchema) => { + const gcpKmsClient = new KeyManagementServiceClient({ + credentials: credential + }); + const projectId = credential.project_id; + const locationName = gcpKmsClient.locationPath(projectId, gcpRegion); + + return { + gcpKmsClient, + locationName + }; +}; + +type GcpKmsProviderArgs = { + inputs: unknown; +}; +type TGcpKmsProviderFactoryReturn = TExternalKmsProviderFns & { + getKeysList: () => Promise<{ keys: string[] }>; +}; + +export const GcpKmsProviderFactory = async ({ inputs }: GcpKmsProviderArgs): Promise => { + const { credential, gcpRegion, keyName } = await ExternalKmsGcpSchema.parseAsync(inputs); + const { gcpKmsClient, locationName } = await getGcpKmsClient({ + credential, + gcpRegion + }); + + const validateConnection = async () => { + try { + await gcpKmsClient.listKeyRings({ + parent: locationName + }); + return true; + } catch (error) { + throw new BadRequestError({ + message: "Cannot connect to GCP KMS" + }); + } + }; + + // Used when adding the KMS to fetch the list of keys in specified region + const getKeysList = async () => { + try { + const [keyRings] = await gcpKmsClient.listKeyRings({ + parent: locationName + }); + + const validKeyRings = keyRings + .filter( + (keyRing): keyRing is { name: string } => + keyRing !== null && typeof keyRing === "object" && "name" in keyRing && typeof keyRing.name === "string" + ) + .map((keyRing) => keyRing.name); + const keyList: string[] = []; + const keyListPromises = validKeyRings.map((keyRingName) => + gcpKmsClient + .listCryptoKeys({ + parent: keyRingName + }) + .then(([cryptoKeys]) => + cryptoKeys + .filter( + (key): key is { name: string } => + key !== null && typeof key === "object" && "name" in key && typeof key.name === "string" + ) + .map((key) => key.name) + ) + ); + + const cryptoKeyLists = await Promise.all(keyListPromises); + keyList.push(...cryptoKeyLists.flat()); + return { keys: keyList }; + } catch (error) { + logger.error(error, "Could not validate GCP KMS connection and credentials"); + throw new BadRequestError({ + message: "Could not validate GCP KMS connection and credentials", + error + }); + } + }; + + const encrypt = async (data: Buffer) => { + const encryptedText = await gcpKmsClient.encrypt({ + name: keyName, + plaintext: data + }); + if (!encryptedText[0].ciphertext) throw new Error("encryption failed"); + return { encryptedBlob: Buffer.from(encryptedText[0].ciphertext) }; + }; + + const decrypt = async (encryptedBlob: Buffer) => { + const decryptedText = await gcpKmsClient.decrypt({ + name: keyName, + ciphertext: encryptedBlob + }); + if (!decryptedText[0].plaintext) throw new Error("decryption failed"); + return { data: Buffer.from(decryptedText[0].plaintext) }; + }; + + return { + validateConnection, + getKeysList, + encrypt, + decrypt + }; +}; diff --git a/backend/src/ee/services/external-kms/providers/model.ts b/backend/src/ee/services/external-kms/providers/model.ts index 5a87e0c98..436b39423 100644 --- a/backend/src/ee/services/external-kms/providers/model.ts +++ b/backend/src/ee/services/external-kms/providers/model.ts @@ -1,13 +1,23 @@ import { z } from "zod"; export enum KmsProviders { - Aws = "aws" + Aws = "aws", + Gcp = "gcp" } export enum KmsAwsCredentialType { AssumeRole = "assume-role", AccessKey = "access-key" } +// Google uses snake_case for their enum values and we need to match that +export enum KmsGcpCredentialType { + ServiceAccount = "service_account" +} + +export enum KmsGcpKeyFetchAuthType { + Credential = "credential", + Kms = "kmsId" +} export const ExternalKmsAwsSchema = z.object({ credential: z @@ -42,14 +52,44 @@ export const ExternalKmsAwsSchema = z.object({ }); export type TExternalKmsAwsSchema = z.infer; +export const ExternalKmsGcpCredentialSchema = z.object({ + type: z.literal(KmsGcpCredentialType.ServiceAccount), + project_id: z.string().min(1), + private_key_id: z.string().min(1), + private_key: z.string().min(1), + client_email: z.string().min(1), + client_id: z.string().min(1), + auth_uri: z.string().min(1), + token_uri: z.string().min(1), + auth_provider_x509_cert_url: z.string().min(1), + client_x509_cert_url: z.string().min(1), + universe_domain: z.string().min(1) +}); + +export type TExternalKmsGcpCredentialSchema = z.infer; + +export const ExternalKmsGcpSchema = z.object({ + credential: ExternalKmsGcpCredentialSchema.describe("GCP Service Account JSON credential to connect"), + gcpRegion: z.string().trim().describe("GCP region where the KMS key is located"), + keyName: z.string().trim().describe("GCP key name") +}); +export type TExternalKmsGcpSchema = z.infer; + +const ExternalKmsGcpClientSchema = ExternalKmsGcpSchema.pick({ gcpRegion: true }).extend({ + credential: ExternalKmsGcpCredentialSchema +}); +export type TExternalKmsGcpClientSchema = z.infer; + // The root schema of the JSON export const ExternalKmsInputSchema = z.discriminatedUnion("type", [ - z.object({ type: z.literal(KmsProviders.Aws), inputs: ExternalKmsAwsSchema }) + z.object({ type: z.literal(KmsProviders.Aws), inputs: ExternalKmsAwsSchema }), + z.object({ type: z.literal(KmsProviders.Gcp), inputs: ExternalKmsGcpSchema }) ]); export type TExternalKmsInputSchema = z.infer; export const ExternalKmsInputUpdateSchema = z.discriminatedUnion("type", [ - z.object({ type: z.literal(KmsProviders.Aws), inputs: ExternalKmsAwsSchema.partial() }) + z.object({ type: z.literal(KmsProviders.Aws), inputs: ExternalKmsAwsSchema.partial() }), + z.object({ type: z.literal(KmsProviders.Gcp), inputs: ExternalKmsGcpSchema.partial() }) ]); export type TExternalKmsInputUpdateSchema = z.infer; diff --git a/backend/src/services/kms/kms-service.ts b/backend/src/services/kms/kms-service.ts index 007d33e61..c41783860 100644 --- a/backend/src/services/kms/kms-service.ts +++ b/backend/src/services/kms/kms-service.ts @@ -4,8 +4,10 @@ import { z } from "zod"; import { KmsKeysSchema, TKmsRootConfig } from "@app/db/schemas"; import { AwsKmsProviderFactory } from "@app/ee/services/external-kms/providers/aws-kms"; +import { GcpKmsProviderFactory } from "@app/ee/services/external-kms/providers/gcp-kms"; import { ExternalKmsAwsSchema, + ExternalKmsGcpSchema, KmsProviders, TExternalKmsProviderFns } from "@app/ee/services/external-kms/providers/model"; @@ -291,6 +293,16 @@ export const kmsServiceFactory = ({ }); break; } + case KmsProviders.Gcp: { + const decryptedProviderInput = await ExternalKmsGcpSchema.parseAsync( + JSON.parse(decryptedProviderInputBlob.toString("utf8")) + ); + + externalKms = await GcpKmsProviderFactory({ + inputs: decryptedProviderInput + }); + break; + } default: throw new Error("Invalid KMS provider."); } @@ -353,6 +365,16 @@ export const kmsServiceFactory = ({ }); break; } + case KmsProviders.Gcp: { + const decryptedProviderInput = await ExternalKmsGcpSchema.parseAsync( + JSON.parse(decryptedProviderInputBlob.toString("utf8")) + ); + + externalKms = await GcpKmsProviderFactory({ + inputs: decryptedProviderInput + }); + break; + } default: throw new Error("Invalid KMS provider."); } diff --git a/docs/documentation/platform/kms-configuration/aws-kms.mdx b/docs/documentation/platform/kms-configuration/aws-kms.mdx index f9fa54b4d..598a3a32b 100644 --- a/docs/documentation/platform/kms-configuration/aws-kms.mdx +++ b/docs/documentation/platform/kms-configuration/aws-kms.mdx @@ -74,22 +74,22 @@ Next, you will need to follow the steps listed below to add AWS KMS for your org - ![Open encryption org settings](../../../images/platform/kms/aws/encryption-org-settings.png) + ![Open encryption org settings](../../../images/platform/kms/encryption-org-settings.png) - ![Add encryption org settings](../../../images/platform/kms/aws/encryption-org-settings-add.png) + ![Add encryption org settings](../../../images/platform/kms/encryption-org-settings-add.png) Click the 'Add' button to begin adding a new external KMS. - ![Select Encryption Provider](../../../images/platform/kms/aws/encryption-modal-provider-select.png) + ![Select Encryption Provider](../../../images/platform/kms/encryption-modal-provider-select.png) Choose 'AWS KMS' from the list of encryption providers. - Selecting AWS as the provider will require you input the following fields. + Selecting AWS as the provider will require you input the following fields. - - Name for referencing the AWS KMS key within the organization. - + + Name for referencing the AWS KMS key within the organization. + Short description of the AWS KMS key. diff --git a/docs/documentation/platform/kms-configuration/gcp-kms.mdx b/docs/documentation/platform/kms-configuration/gcp-kms.mdx new file mode 100644 index 000000000..5682814fe --- /dev/null +++ b/docs/documentation/platform/kms-configuration/gcp-kms.mdx @@ -0,0 +1,132 @@ +--- +title: "GCP Key Management Service" +description: "Learn how to manage encryption using GCP KMS" +--- + +To enhance the security of your Infisical projects, you can now encrypt your secrets using an external Key Management Service (KMS). +When external KMS is configured for your project, all encryption and decryption operations will be handled by the chosen KMS. +This guide will walk you through the steps needed to configure external KMS support with Google Cloud KMS. + +## Prerequisites + +Before you begin, you'll first need to set up a GCP Service Account, add a KMS key and set the required permissions. + + + + 1. Navigate to the [Create Service Account](https://console.cloud.google.com/iam-admin/serviceaccounts/create) page in your GCP Console. + ![GCP Service Account Creation](/images/platform/kms/gcp/service-account-form.png) + + 2. Give the service account a suitable **name** and **description**. Then click **Create and Continue**. + 3. Under **Grant this service account access to project**, click **Select a role** and select the + **Cloud KMS Viewer** and **Cloud KMS CryptoKey Encrypter/Decrypter*** roles, then click **Continue**. + ![GCP Service Account Permissions](/images/platform/kms/gcp/service-account-permissions.png) + 3. You can skip the **Grant users access to this service account** options. + 4. Click Done. + 5. You should see the service account in the list of service accounts. Click it to view the service account details. + 6. Select the **Keys** tab, click **Add Key**, select **Create new key**, select **JSON** as the key type, then click **Create**. + 7. You will be prompted to download a JSON file that we will need later on. + + Remember to keep the JSON file in a secure location. It will be used to authenticate your GCP service account. + + Once you have successfully set up GCP KMS with Infisical, you should permanently delete the JSON file. + + + + + 1. Navigate to the [KMS](https://console.cloud.google.com/security/kms) page in your GCP Console. + + If you have not used GCP KMS before, you will be redirected to the **Cloud Key Management Service (KMS) API** page. + + Click **Enable** to enable the KMS API, then continue the steps below. + + It may take a few minutes for the API to be enabled and KMS section of the Cloud Console to become viewable. + + + 2. In the KMS section, click **Create Key Ring**. + ![GCP Create Key Ring](/images/platform/kms/gcp/keyring-create.png) + + 3. Give the key ring a **Name** and select a **Region**, then click **Create**. + + We don't currently support multi-region key rings. + + + 4. On the "Create Key" page, give the key a **Name** and set the **Protection Level** based on your requirements (or use default *Software*), then click **Continue**. + + 5. Under **Key Material**, select **Generated Key**, then click **Continue**. + + 6. Under **Purpose**, select **Symmetric encrypt/decrypt**, then click **Continue**. + + 7. For **Key Rotation Period**, select **Never (manual rotation)**, then click **Continue** followed by **Create**. + + 8. You should see the key in the list of keys. We're now ready to set it up in Infisical. + + + + +## Setup GCP KMS in the Organization Settings + +Next, you will need to follow the steps listed below to add GCP KMS for your organization. + + + + ![Open encryption org settings](../../../images/platform/kms/encryption-org-settings.png) + + + ![Add encryption org settings](../../../images/platform/kms/encryption-org-settings-add.png) + Click the 'Add' button to begin adding a new external KMS. + + + ![Select Encryption Provider](../../../images/platform/kms/encryption-modal-provider-select.png) + Choose 'GCP KMS' from the list of encryption providers. + + + + ![GCP Create KMS Modal](/images/platform/kms/gcp/gcp-add-modal-filled.png) + Selecting GCP as the provider will require you input the following fields. + + + Name for referencing the GCP KMS key within the organization. + + + + Short description of the GCP KMS key. + + + + The GCP region where the GCP KMS key ring is located. + + + + Upload the JSON file you downloaded earlier when creating the GCP service account. + + + + This field will be populated with the list of GCP KMS keys in the selected region. Select the key you created earlier. + + + + + Save your configuration to apply the settings. + + + +You now have a GCP KMS Key configured at the organization level. You can assign these GCP KMS keys to existing Infisical projects by visiting the 'Project Settings' page. + +## Assign GCP KMS Key to an Existing Project + +To assign the GCP KMS key you added to your organization, follow the steps below. + + + + ![Open encryption project + settings](../../../images/platform/kms/gcp/project-settings.png) + + + ![Select encryption project + settings](../../../images/platform/kms/gcp/select-gcp-kms-in-project.png) + Choose the GCP KMS key you configured earlier. + + + Once you have selected the KMS of choice, click save. + + diff --git a/docs/documentation/platform/kms-configuration/overview.mdx b/docs/documentation/platform/kms-configuration/overview.mdx index 327481bc4..159d71dd3 100644 --- a/docs/documentation/platform/kms-configuration/overview.mdx +++ b/docs/documentation/platform/kms-configuration/overview.mdx @@ -25,4 +25,4 @@ For existing projects, you can configure the KMS from the Project Settings page. ## External KMS -Infisical supports the use of external KMS solutions to enhance security and compliance. You can configure your project to use services like [AWS Key Management Service](./aws-kms) for managing encryption. \ No newline at end of file +Infisical supports the use of external KMS solutions to enhance security and compliance. You can configure your project to use services like [AWS Key Management Service](./aws-kms) or [GCP Key Management Service](./gcp-kms) for managing encryption. diff --git a/docs/images/platform/kms/aws/encryption-modal-provider-select.png b/docs/images/platform/kms/aws/encryption-modal-provider-select.png deleted file mode 100644 index 704043a74..000000000 Binary files a/docs/images/platform/kms/aws/encryption-modal-provider-select.png and /dev/null differ diff --git a/docs/images/platform/kms/encryption-modal-provider-select.png b/docs/images/platform/kms/encryption-modal-provider-select.png new file mode 100644 index 000000000..5bc696021 Binary files /dev/null and b/docs/images/platform/kms/encryption-modal-provider-select.png differ diff --git a/docs/images/platform/kms/aws/encryption-org-settings-add.png b/docs/images/platform/kms/encryption-org-settings-add.png similarity index 100% rename from docs/images/platform/kms/aws/encryption-org-settings-add.png rename to docs/images/platform/kms/encryption-org-settings-add.png diff --git a/docs/images/platform/kms/aws/encryption-org-settings.png b/docs/images/platform/kms/encryption-org-settings.png similarity index 100% rename from docs/images/platform/kms/aws/encryption-org-settings.png rename to docs/images/platform/kms/encryption-org-settings.png diff --git a/docs/images/platform/kms/gcp/gcp-add-modal-filled.png b/docs/images/platform/kms/gcp/gcp-add-modal-filled.png new file mode 100644 index 000000000..c6d4b0725 Binary files /dev/null and b/docs/images/platform/kms/gcp/gcp-add-modal-filled.png differ diff --git a/docs/images/platform/kms/gcp/keyring-create.png b/docs/images/platform/kms/gcp/keyring-create.png new file mode 100644 index 000000000..c03097cc1 Binary files /dev/null and b/docs/images/platform/kms/gcp/keyring-create.png differ diff --git a/docs/images/platform/kms/gcp/project-settings.png b/docs/images/platform/kms/gcp/project-settings.png new file mode 100644 index 000000000..915115204 Binary files /dev/null and b/docs/images/platform/kms/gcp/project-settings.png differ diff --git a/docs/images/platform/kms/gcp/select-gcp-kms-in-project.png b/docs/images/platform/kms/gcp/select-gcp-kms-in-project.png new file mode 100644 index 000000000..18f24d304 Binary files /dev/null and b/docs/images/platform/kms/gcp/select-gcp-kms-in-project.png differ diff --git a/docs/images/platform/kms/gcp/service-account-form.png b/docs/images/platform/kms/gcp/service-account-form.png new file mode 100644 index 000000000..eea0dc324 Binary files /dev/null and b/docs/images/platform/kms/gcp/service-account-form.png differ diff --git a/docs/images/platform/kms/gcp/service-account-permissions.png b/docs/images/platform/kms/gcp/service-account-permissions.png new file mode 100644 index 000000000..d528199dc Binary files /dev/null and b/docs/images/platform/kms/gcp/service-account-permissions.png differ diff --git a/docs/mint.json b/docs/mint.json index 7df2e1062..9775046b3 100644 --- a/docs/mint.json +++ b/docs/mint.json @@ -32,7 +32,10 @@ "thumbsRating": true }, "api": { - "baseUrl": ["https://app.infisical.com", "http://localhost:8080"] + "baseUrl": [ + "https://app.infisical.com", + "http://localhost:8080" + ] }, "topbarLinks": [ { @@ -73,7 +76,9 @@ "documentation/getting-started/introduction", { "group": "Quickstart", - "pages": ["documentation/guides/local-development"] + "pages": [ + "documentation/guides/local-development" + ] }, { "group": "Guides", @@ -127,7 +132,8 @@ "pages": [ "documentation/platform/kms-configuration/overview", "documentation/platform/kms-configuration/aws-kms", - "documentation/platform/kms-configuration/aws-hsm" + "documentation/platform/kms-configuration/aws-hsm", + "documentation/platform/kms-configuration/gcp-kms" ] }, { @@ -461,11 +467,15 @@ }, { "group": "Build Tool Integrations", - "pages": ["integrations/build-tools/gradle"] + "pages": [ + "integrations/build-tools/gradle" + ] }, { "group": "", - "pages": ["sdks/overview"] + "pages": [ + "sdks/overview" + ] }, { "group": "SDK's", @@ -485,7 +495,9 @@ "api-reference/overview/authentication", { "group": "Examples", - "pages": ["api-reference/overview/examples/integration"] + "pages": [ + "api-reference/overview/examples/integration" + ] } ] }, @@ -760,11 +772,15 @@ }, { "group": "Service Tokens", - "pages": ["api-reference/endpoints/service-tokens/get"] + "pages": [ + "api-reference/endpoints/service-tokens/get" + ] }, { "group": "Audit Logs", - "pages": ["api-reference/endpoints/audit-logs/export-audit-log"] + "pages": [ + "api-reference/endpoints/audit-logs/export-audit-log" + ] } ] }, @@ -863,7 +879,9 @@ }, { "group": "", - "pages": ["changelog/overview"] + "pages": [ + "changelog/overview" + ] }, { "group": "Contributing", @@ -887,7 +905,9 @@ }, { "group": "Contributing to SDK", - "pages": ["contributing/sdk/developing"] + "pages": [ + "contributing/sdk/developing" + ] } ] } @@ -911,13 +931,22 @@ { "title": "PRODUCT", "links": [ - { "label": "Secret Management", "url": "https://infisical.com/" }, - { "label": "Secret Scanning", "url": "https://infisical.com/radar" }, + { + "label": "Secret Management", + "url": "https://infisical.com/" + }, + { + "label": "Secret Scanning", + "url": "https://infisical.com/radar" + }, { "label": "Share Secrets", "url": "https://app.infisical.com/share-secret" }, - { "label": "Pricing", "url": "https://infisical.com/pricing" }, + { + "label": "Pricing", + "url": "https://infisical.com/pricing" + }, { "label": "Security", "url": "https://infisical.com/docs/internals/security" @@ -1061,4 +1090,4 @@ } ] } -} +} \ No newline at end of file diff --git a/frontend/src/components/v2/Select/components/index.tsx b/frontend/src/components/v2/Select/components/index.tsx index 75bdfcfb2..8d0e57d87 100644 --- a/frontend/src/components/v2/Select/components/index.tsx +++ b/frontend/src/components/v2/Select/components/index.tsx @@ -36,7 +36,7 @@ export const MultiValueRemove = (props: MultiValueRemoveProps) => { export const Option = ({ isSelected, children, ...props }: OptionProps) => { return ( -
+

{children}

{isSelected && ( diff --git a/frontend/src/hooks/api/kms/index.tsx b/frontend/src/hooks/api/kms/index.tsx index 84b238a3b..906368bd3 100644 --- a/frontend/src/hooks/api/kms/index.tsx +++ b/frontend/src/hooks/api/kms/index.tsx @@ -1,5 +1,6 @@ export { useAddExternalKms, + useExternalKmsFetchGcpKeys, useLoadProjectKmsBackup, useRemoveExternalKms, useUpdateExternalKms, diff --git a/frontend/src/hooks/api/kms/mutations.tsx b/frontend/src/hooks/api/kms/mutations.tsx index f0c623ceb..b87c495c9 100644 --- a/frontend/src/hooks/api/kms/mutations.tsx +++ b/frontend/src/hooks/api/kms/mutations.tsx @@ -3,7 +3,13 @@ import { useMutation, useQueryClient } from "@tanstack/react-query"; import { apiRequest } from "@app/config/request"; import { kmsKeys } from "./queries"; -import { AddExternalKmsType, KmsType } from "./types"; +import { + AddExternalKmsType, + ExternalKmsGcpSchemaType, + KmsGcpKeyFetchAuthType, + KmsType, + UpdateExternalKmsType +} from "./types"; export const useAddExternalKms = (orgId: string) => { const queryClient = useQueryClient(); @@ -33,7 +39,7 @@ export const useUpdateExternalKms = (orgId: string) => { provider }: { kmsId: string; - } & AddExternalKmsType) => { + } & UpdateExternalKmsType) => { const { data } = await apiRequest.patch(`/api/v1/external-kms/${kmsId}`, { name, description, @@ -96,3 +102,44 @@ export const useLoadProjectKmsBackup = (projectId: string) => { } }); }; + +export const useExternalKmsFetchGcpKeys = (orgId: string) => { + const queryClient = useQueryClient(); + return useMutation({ + mutationFn: async ({ + gcpRegion, + ...rest + }: Pick & + ( + | (Pick & { + [KmsGcpKeyFetchAuthType.Kms]?: never; + }) + | { + [KmsGcpKeyFetchAuthType.Kms]: string; + [KmsGcpKeyFetchAuthType.Credential]?: never; + } + )): Promise<{ keys: string[] }> => { + const { + [KmsGcpKeyFetchAuthType.Credential]: credential, + [KmsGcpKeyFetchAuthType.Kms]: kmsId + } = rest; + + if ((credential && kmsId) || (!credential && !kmsId)) { + throw new Error( + `Either '${KmsGcpKeyFetchAuthType.Credential}' or '${KmsGcpKeyFetchAuthType.Kms}' must be provided, but not both.` + ); + } + + const { data } = await apiRequest.post("/api/v1/external-kms/gcp/keys", { + authMethod: credential ? KmsGcpKeyFetchAuthType.Credential : KmsGcpKeyFetchAuthType.Kms, + region: gcpRegion, + ...rest + }); + + return data; + }, + onSuccess: () => { + queryClient.invalidateQueries(kmsKeys.getExternalKmsList(orgId)); + } + }); +}; diff --git a/frontend/src/hooks/api/kms/types.ts b/frontend/src/hooks/api/kms/types.ts index 513b59e2a..3e4b69880 100644 --- a/frontend/src/hooks/api/kms/types.ts +++ b/frontend/src/hooks/api/kms/types.ts @@ -35,7 +35,8 @@ export enum KmsType { } export enum ExternalKmsProvider { - AWS = "aws" + Aws = "aws", + Gcp = "gcp" } export const INTERNAL_KMS_KEY_ID = "internal"; @@ -44,6 +45,10 @@ export enum KmsAwsCredentialType { AssumeRole = "assume-role", AccessKey = "access-key" } +// Google uses snake_case for their enum values and we need to match that +export enum KmsGcpCredentialType { + ServiceAccount = "service_account" +} export const ExternalKmsAwsSchema = z.object({ credential: z @@ -83,8 +88,34 @@ export const ExternalKmsAwsSchema = z.object({ ) }); +export const ExternalKmsGcpCredentialSchema = z.object({ + type: z.literal(KmsGcpCredentialType.ServiceAccount), + project_id: z.string().min(1), + private_key_id: z.string().min(1), + private_key: z.string().min(1), + client_email: z.string().min(1), + client_id: z.string().min(1), + auth_uri: z.string().min(1), + token_uri: z.string().min(1), + auth_provider_x509_cert_url: z.string().min(1), + client_x509_cert_url: z.string().min(1), + universe_domain: z.string().min(1) +}); + +export type ExternalKmsGcpCredentialSchemaType = z.infer; + +export const ExternalKmsGcpSchema = z.object({ + credential: ExternalKmsGcpCredentialSchema.describe( + "GCP Service Account JSON credential to connect" + ), + gcpRegion: z.string().min(1).trim().describe("GCP region where the KMS key is located"), + keyName: z.string().min(1).trim().describe("GCP key name") +}); +export type ExternalKmsGcpSchemaType = z.infer; + export const ExternalKmsInputSchema = z.discriminatedUnion("type", [ - z.object({ type: z.literal(ExternalKmsProvider.AWS), inputs: ExternalKmsAwsSchema }) + z.object({ type: z.literal(ExternalKmsProvider.Aws), inputs: ExternalKmsAwsSchema }), + z.object({ type: z.literal(ExternalKmsProvider.Gcp), inputs: ExternalKmsGcpSchema }) ]); export const AddExternalKmsSchema = z.object({ @@ -100,3 +131,71 @@ export const AddExternalKmsSchema = z.object({ }); export type AddExternalKmsType = z.infer; + +// we need separate schema for update because the credential field is not required on GCP +export const ExternalKmsUpdateInputSchema = z.discriminatedUnion("type", [ + z.object({ type: z.literal(ExternalKmsProvider.Aws), inputs: ExternalKmsAwsSchema }), + z.object({ + type: z.literal(ExternalKmsProvider.Gcp), + inputs: ExternalKmsGcpSchema.pick({ gcpRegion: true, keyName: true }) + }) +]); + +export const UpdateExternalKmsSchema = z.object({ + name: z + .string() + .trim() + .min(1) + .refine((v) => slugify(v) === v, { + message: "Alias must be a valid slug" + }), + description: z.string().trim().optional(), + provider: ExternalKmsUpdateInputSchema +}); + +export type UpdateExternalKmsType = z.infer; + +const GCP_CREDENTIAL_MAX_FILE_SIZE = 8 * 1024; // 8KB +const GCP_CREDENTIAL_ACCEPTED_FILE_TYPES = ["application/json"]; + +const AddExternalKmsGcpFormSchemaStandardInputs = z.object({ + keyObject: z + .object({ label: z.string().trim(), value: z.string().trim() }) + .describe("GCP key name"), + gcpRegion: z.object({ label: z.string().trim(), value: z.string().trim() }).describe("GCP Region") +}); + +export const AddExternalKmsGcpFormSchema = z.discriminatedUnion("formType", [ + z + .object({ + formType: z.literal("newGcpKms"), + // `FileList` is a browser-only (window-specific) type, so we need to handle it differently on the server to avoid SSR errors + credentialFile: + typeof window === "undefined" + ? z.any() + : z + .instanceof(FileList) + .refine((files) => files?.length === 1, "Image is required.") + .refine( + (files) => files?.[0]?.size <= GCP_CREDENTIAL_MAX_FILE_SIZE, + "Max file size is 8KB." + ) + .refine( + (files) => GCP_CREDENTIAL_ACCEPTED_FILE_TYPES.includes(files?.[0]?.type), + "Only .json files are accepted." + ) + }) + .merge(AddExternalKmsGcpFormSchemaStandardInputs) + .merge(AddExternalKmsSchema.pick({ name: true, description: true })), + z + .object({ formType: z.literal("updateGcpKms") }) + .merge(AddExternalKmsGcpFormSchemaStandardInputs) + .merge(AddExternalKmsSchema.pick({ name: true, description: true })) +]); + +export type AddExternalKmsGcpFormSchemaType = z.infer; + +export enum KmsGcpKeyFetchAuthType { + Credential = "credential", + Kms = "kmsId" +} diff --git a/frontend/src/views/Settings/OrgSettingsPage/components/OrgEncryptionTab/AddExternalKmsForm.tsx b/frontend/src/views/Settings/OrgSettingsPage/components/OrgEncryptionTab/AddExternalKmsForm.tsx index 70debab36..131a23442 100644 --- a/frontend/src/views/Settings/OrgSettingsPage/components/OrgEncryptionTab/AddExternalKmsForm.tsx +++ b/frontend/src/views/Settings/OrgSettingsPage/components/OrgEncryptionTab/AddExternalKmsForm.tsx @@ -1,5 +1,5 @@ import { useState } from "react"; -import { faAws } from "@fortawesome/free-brands-svg-icons"; +import { faAws, faGoogle } from "@fortawesome/free-brands-svg-icons"; import { FontAwesomeIcon } from "@fortawesome/react-fontawesome"; import { AnimatePresence, motion } from "framer-motion"; @@ -7,6 +7,7 @@ import { Modal, ModalContent } from "@app/components/v2"; import { ExternalKmsProvider } from "@app/hooks/api/kms/types"; import { AwsKmsForm } from "./AwsKmsForm"; +import { GcpKmsForm } from "./GcpKmsForm"; type Props = { isOpen?: boolean; @@ -21,8 +22,13 @@ enum WizardSteps { const EXTERNAL_KMS_LIST = [ { icon: faAws, - provider: ExternalKmsProvider.AWS, + provider: ExternalKmsProvider.Aws, title: "AWS KMS" + }, + { + icon: faGoogle, + provider: ExternalKmsProvider.Gcp, + title: "GCP KMS" } ]; @@ -42,6 +48,7 @@ export const AddExternalKmsForm = ({ isOpen, onToggle }: Props) => { title="Add a Key Management System" subTitle="Configure an external key management system (KMS)" className="my-4" + bodyClassName="overflow-visible" > {wizardStep === WizardSteps.SelectProvider && ( @@ -79,7 +86,7 @@ export const AddExternalKmsForm = ({ isOpen, onToggle }: Props) => { )} {wizardStep === WizardSteps.ProviderInputs && - selectedProvider === ExternalKmsProvider.AWS && ( + selectedProvider === ExternalKmsProvider.Aws && ( { onToggle(false)} onCompleted={() => onToggle(false)} /> )} + {wizardStep === WizardSteps.ProviderInputs && + selectedProvider === ExternalKmsProvider.Gcp && ( + + onToggle(false)} onCompleted={() => onToggle(false)} /> + + )} diff --git a/frontend/src/views/Settings/OrgSettingsPage/components/OrgEncryptionTab/AwsKmsForm.tsx b/frontend/src/views/Settings/OrgSettingsPage/components/OrgEncryptionTab/AwsKmsForm.tsx index e5edfc696..b8ba6d984 100644 --- a/frontend/src/views/Settings/OrgSettingsPage/components/OrgEncryptionTab/AwsKmsForm.tsx +++ b/frontend/src/views/Settings/OrgSettingsPage/components/OrgEncryptionTab/AwsKmsForm.tsx @@ -64,7 +64,7 @@ export const AwsKmsForm = ({ onCompleted, onCancel, kms }: Props) => { name: kms?.name, description: kms?.description ?? "", provider: { - type: ExternalKmsProvider.AWS, + type: ExternalKmsProvider.Aws, inputs: { credential: { type: kms?.external?.providerInput?.credential?.type, @@ -88,7 +88,7 @@ export const AwsKmsForm = ({ onCompleted, onCancel, kms }: Props) => { const selectedAwsAuthType = watch("provider.inputs.credential.type"); - const handleAddAwsKms = async (data: AddExternalKmsType) => { + const handleAwsKmsFormSubmit = async (data: AddExternalKmsType) => { const { name, description, provider } = data; try { if (kms) { @@ -123,7 +123,7 @@ export const AwsKmsForm = ({ onCompleted, onCancel, kms }: Props) => { }; return ( -
+ void; + onCancel: () => void; + kms?: Kms; +}; + +const GCP_REGIONS = [ + { label: "Johannesburg", value: "africa-south1" }, + { label: "Taiwan", value: "asia-east1" }, + { label: "Hong Kong", value: "asia-east2" }, + { label: "Tokyo", value: "asia-northeast1" }, + { label: "Osaka", value: "asia-northeast2" }, + { label: "Seoul", value: "asia-northeast3" }, + { label: "Mumbai", value: "asia-south1" }, + { label: "Delhi", value: "asia-south2" }, + { label: "Singapore", value: "asia-southeast1" }, + { label: "Jakarta", value: "asia-southeast2" }, + { label: "Sydney", value: "australia-southeast1" }, + { label: "Melbourne", value: "australia-southeast2" }, + { label: "Warsaw", value: "europe-central2" }, + { label: "Finland", value: "europe-north1" }, + { label: "Belgium", value: "europe-west1" }, + { label: "London", value: "europe-west2" }, + { label: "Frankfurt", value: "europe-west3" }, + { label: "Netherlands", value: "europe-west4" }, + { label: "Zurich", value: "europe-west6" }, + { label: "Milan", value: "europe-west8" }, + { label: "Paris", value: "europe-west9" }, + { label: "Berlin", value: "europe-west10" }, + { label: "Turin", value: "europe-west12" }, + { label: "Madrid", value: "europe-southwest1" }, + { label: "Doha", value: "me-central1" }, + { label: "Dammam", value: "me-central2" }, + { label: "Tel Aviv", value: "me-west1" }, + { label: "Montréal", value: "northamerica-northeast1" }, + { label: "Toronto", value: "northamerica-northeast2" }, + { label: "São Paulo", value: "southamerica-east1" }, + { label: "Santiago", value: "southamerica-west1" }, + { label: "Iowa", value: "us-central1" }, + { label: "South Carolina", value: "us-east1" }, + { label: "North Virginia", value: "us-east4" }, + { label: "Columbus", value: "us-east5" }, + { label: "Dallas", value: "us-south1" }, + { label: "Oregon", value: "us-west1" }, + { label: "Los Angeles", value: "us-west2" }, + { label: "Salt Lake City", value: "us-west3" }, + { label: "Las Vegas", value: "us-west4" } +]; + +const formatOptionLabel = ({ value, label }: { value: string; label: string }) => ( +
+ {label} + {value} +
+); + +export const GcpKmsForm = ({ onCompleted, onCancel, kms }: Props) => { + const [isCredentialValid, setIsCredentialValid] = useState(false); + const [keys, setKeys] = useState<{ value: string; label: string }[]>([]); + + const { + control, + handleSubmit, + setError, + clearErrors, + getValues, + resetField, + setValue, + formState: { isSubmitting } + } = useForm({ + resolver: zodResolver(AddExternalKmsGcpFormSchema), + defaultValues: { + formType: kms ? "updateGcpKms" : "newGcpKms", + name: kms?.name ?? "", + description: kms?.description ?? "", + gcpRegion: kms + ? { + label: + GCP_REGIONS.find((r) => r.value === kms.external.providerInput.gcpRegion)?.label ?? + "", + value: kms.external.providerInput.gcpRegion + } + : undefined, + keyObject: undefined + } + }); + + const { currentOrg } = useOrganization(); + const { mutateAsync: addGcpExternalKms } = useAddExternalKms(currentOrg?.id!); + const { mutateAsync: updateGcpExternalKms } = useUpdateExternalKms(currentOrg?.id!); + const { mutateAsync: fetchGcpKeys, isLoading: isFetchGcpKeysLoading } = + useExternalKmsFetchGcpKeys(currentOrg?.id!); + + // transforms the credential file into a JSON object + async function getCredentialFileJson(): Promise { + const files = getValues("credentialFile"); + if (!files || !files.length) { + return null; + } + const file = files[0]; + if (file.type !== "application/json") { + setError("credentialFile", { + message: "Only .json files are accepted." + }); + return null; + } + const jsonContents = await file.text(); + const parsedJson = ExternalKmsGcpCredentialSchema.safeParse(JSON.parse(jsonContents)); + if (!parsedJson.success) { + setError("credentialFile", { + message: "Invalid Service Account credential JSON." + }); + return null; + } + clearErrors("credentialFile"); + return parsedJson.data; + } + + // handles the form submission + const handleGcpKmsFormSubmit = async (data: AddExternalKmsGcpFormSchemaType) => { + const { name, description, gcpRegion: gcpRegionObject, keyObject } = data; + const gcpRegion = gcpRegionObject.value; + if (!keys.find((k) => k.value === keyObject?.value)) { + setError("keyObject", { + message: "Please select a valid key." + }); + resetField("keyObject"); + return; + } + + try { + if (kms) { + await updateGcpExternalKms({ + kmsId: kms.id, + name, + description, + provider: { + type: ExternalKmsProvider.Gcp, + inputs: { + gcpRegion, + keyName: keyObject?.value + } + } + }); + + createNotification({ + text: "Successfully updated GCP External KMS", + type: "success" + }); + } else { + const credentialJson = await getCredentialFileJson(); + if (!credentialJson) { + return; + } + await addGcpExternalKms({ + name, + description, + provider: { + type: ExternalKmsProvider.Gcp, + inputs: { + gcpRegion, + keyName: keyObject?.value, + credential: credentialJson + } + } + }); + + createNotification({ + text: "Successfully added GCP External KMS", + type: "success" + }); + } + + onCompleted(); + } catch (err) { + console.error(err); + } + }; + + const fetchGCPKeys = async () => { + // @ts-expect-error - issue with the way react-select renders the placeholder. We need to set the value to null explicitly otherwise it will not re-render + setValue("keyObject", null); + setKeys([]); + + const credentialJson = kms ? undefined : await getCredentialFileJson(); + if (!kms && !credentialJson) { + return; + } + const gcpRegion = getValues("gcpRegion").value; + if (!gcpRegion.length) { + setError("gcpRegion", { + message: "Please select a GCP region to fetch GCP Keys." + }); + return; + } + const res = await fetchGcpKeys({ + gcpRegion, + ...(kms ? { kmsId: kms.id } : { credential: credentialJson! }) + }); + setIsCredentialValid(!!res.keys); + const returnedKeys = res.keys.map((key) => { + const parts = key.split("/"); + const keyLabel = `${parts[5]}/${parts[7]}`; + return { + value: key, + label: keyLabel + }; + }); + + setKeys(returnedKeys); + if (kms) { + const existingKey = returnedKeys.find((k) => k.value === kms.external.providerInput.keyName); + if (existingKey) { + setValue("keyObject", existingKey); + } + } + }; + + const getPlaceholderText = () => { + if (isFetchGcpKeysLoading) { + return "Loading keys in this region..."; + } + if (!isCredentialValid) { + return "Upload a valid credential file"; + } + if (keys.length) { + return "Select a key"; + } + + return "No valid keys found in this region"; + }; + + useEffect(() => { + if (kms && !isCredentialValid) { + fetchGCPKeys(); + } + }, [kms]); + + return ( + + ( + + + + )} + /> + ( + + + + )} + /> + ( + + { + resetField("keyObject"); + field.onChange(e); + fetchGCPKeys(); + }} + formatOptionLabel={formatOptionLabel} + /> + + )} + /> + {!kms && ( + ( + + { + onChange(e.target.files); + fetchGCPKeys(); + }} + /> + + )} + /> + )} + ( + + + + )} + /> + {kms && ( + + To change your GCP credentials, create a new external KMS and assign it to project you + want to use it with. + + )} +
+ + +
+ + ); +}; diff --git a/frontend/src/views/Settings/OrgSettingsPage/components/OrgEncryptionTab/OrgEncryptionTab.tsx b/frontend/src/views/Settings/OrgSettingsPage/components/OrgEncryptionTab/OrgEncryptionTab.tsx index d1de3a2e5..1bdf2757d 100644 --- a/frontend/src/views/Settings/OrgSettingsPage/components/OrgEncryptionTab/OrgEncryptionTab.tsx +++ b/frontend/src/views/Settings/OrgSettingsPage/components/OrgEncryptionTab/OrgEncryptionTab.tsx @@ -1,4 +1,4 @@ -import { faAws } from "@fortawesome/free-brands-svg-icons"; +import { faAws, faGoogle } from "@fortawesome/free-brands-svg-icons"; import { faEllipsis, faLock, faPlus } from "@fortawesome/free-solid-svg-icons"; import { FontAwesomeIcon } from "@fortawesome/react-fontawesome"; import { twMerge } from "tailwind-merge"; @@ -117,9 +117,12 @@ export const OrgEncryptionTab = withPermission( externalKmsList?.map((kms) => ( - {kms.externalKms.provider === ExternalKmsProvider.AWS && ( + {kms.externalKms.provider === ExternalKmsProvider.Aws && ( )} + {kms.externalKms.provider === ExternalKmsProvider.Gcp && ( + + )}
{kms.externalKms.provider.toUpperCase()}
{kms.name} diff --git a/frontend/src/views/Settings/OrgSettingsPage/components/OrgEncryptionTab/UpdateExternalKmsForm.tsx b/frontend/src/views/Settings/OrgSettingsPage/components/OrgEncryptionTab/UpdateExternalKmsForm.tsx index 80e171167..bc2e85558 100644 --- a/frontend/src/views/Settings/OrgSettingsPage/components/OrgEncryptionTab/UpdateExternalKmsForm.tsx +++ b/frontend/src/views/Settings/OrgSettingsPage/components/OrgEncryptionTab/UpdateExternalKmsForm.tsx @@ -3,6 +3,7 @@ import { useGetExternalKmsById } from "@app/hooks/api"; import { ExternalKmsProvider } from "@app/hooks/api/kms/types"; import { AwsKmsForm } from "./AwsKmsForm"; +import { GcpKmsForm } from "./GcpKmsForm"; type Props = { isOpen: boolean; @@ -14,15 +15,22 @@ export const UpdateExternalKmsForm = ({ isOpen, kmsId, onOpenChange }: Props) => const { data: externalKms, isLoading } = useGetExternalKmsById(kmsId); return ( - + {isLoading && } - {externalKms?.external?.provider === ExternalKmsProvider.AWS && ( + {externalKms?.external?.provider === ExternalKmsProvider.Aws && ( onOpenChange(false)} onCompleted={() => onOpenChange(false)} /> )} + {externalKms?.external?.provider === ExternalKmsProvider.Gcp && ( + onOpenChange(false)} + onCompleted={() => onOpenChange(false)} + /> + )} );