diff --git a/backend/src/services/app-connection/gcp/gcp-connection-fns.ts b/backend/src/services/app-connection/gcp/gcp-connection-fns.ts index dadfc7d43..997021076 100644 --- a/backend/src/services/app-connection/gcp/gcp-connection-fns.ts +++ b/backend/src/services/app-connection/gcp/gcp-connection-fns.ts @@ -25,7 +25,7 @@ export const getGcpAppConnectionListItem = () => { }; }; -export const getAuthToken = async (appConnection: TGcpConnectionConfig) => { +export const getGcpConnectionAuthToken = async (appConnection: TGcpConnectionConfig) => { const appCfg = getConfig(); if (!appCfg.INF_APP_CONNECTION_GCP_SERVICE_ACCOUNT_CREDENTIAL) { throw new InternalServerError({ @@ -78,7 +78,7 @@ export const getAuthToken = async (appConnection: TGcpConnectionConfig) => { }; export const getGcpSecretManagerProjects = async (appConnection: TGcpConnection) => { - const accessToken = await getAuthToken(appConnection); + const accessToken = await getGcpConnectionAuthToken(appConnection); let gcpApps: GCPApp[] = []; @@ -146,19 +146,19 @@ export const getGcpSecretManagerProjects = async (appConnection: TGcpConnection) }; export const validateGcpConnectionCredentials = async (appConnection: TGcpConnectionConfig) => { - // Check if provided service account email prefix matches organization ID. + // Check if provided service account email suffix matches organization ID. // We do this to mitigate confused deputy attacks in multi-tenant instances - const expectedEmailPrefix = appConnection.orgId.split("-").slice(0, 2).join("-"); - if ( - appConnection.credentials.serviceAccountEmail && - !appConnection.credentials.serviceAccountEmail.startsWith(expectedEmailPrefix) - ) { - throw new BadRequestError({ - message: `GCP service account email must have a prefix of "${expectedEmailPrefix}"` - }); + if (appConnection.credentials.serviceAccountEmail) { + const expectedAccountIdSuffix = appConnection.orgId.split("-").slice(0, 2).join("-"); + const serviceAccountId = appConnection.credentials.serviceAccountEmail.split("@")[0]; + if (!serviceAccountId.endsWith(expectedAccountIdSuffix)) { + throw new BadRequestError({ + message: `GCP service account ID (the part of the email before '@') must have a suffix of "${expectedAccountIdSuffix}"` + }); + } } - await getAuthToken(appConnection); + await getGcpConnectionAuthToken(appConnection); return appConnection.credentials; }; diff --git a/backend/src/services/secret-sync/gcp/gcp-sync-constants.ts b/backend/src/services/secret-sync/gcp/gcp-sync-constants.ts index 7b54b1782..39ae0a9a4 100644 --- a/backend/src/services/secret-sync/gcp/gcp-sync-constants.ts +++ b/backend/src/services/secret-sync/gcp/gcp-sync-constants.ts @@ -6,5 +6,5 @@ export const GCP_SYNC_LIST_OPTION: TSecretSyncListItem = { name: "GCP Secret Manager", destination: SecretSync.GCPSecretManager, connection: AppConnection.GCP, - canImportSecrets: false + canImportSecrets: true }; diff --git a/backend/src/services/secret-sync/gcp/gcp-sync-enums.ts b/backend/src/services/secret-sync/gcp/gcp-sync-enums.ts new file mode 100644 index 000000000..348d2bfa5 --- /dev/null +++ b/backend/src/services/secret-sync/gcp/gcp-sync-enums.ts @@ -0,0 +1,3 @@ +export enum GcpSyncScope { + Global = "global" +} diff --git a/backend/src/services/secret-sync/gcp/gcp-sync-fns.ts b/backend/src/services/secret-sync/gcp/gcp-sync-fns.ts index 75dedbad7..79053104d 100644 --- a/backend/src/services/secret-sync/gcp/gcp-sync-fns.ts +++ b/backend/src/services/secret-sync/gcp/gcp-sync-fns.ts @@ -1,9 +1,11 @@ +import { AxiosError } from "axios"; + import { request } from "@app/lib/config/request"; import { logger } from "@app/lib/logger"; -import { getAuthToken } from "@app/services/app-connection/gcp"; +import { getGcpConnectionAuthToken } from "@app/services/app-connection/gcp"; import { IntegrationUrls } from "@app/services/integration-auth/integration-list"; -import { SECRET_SYNC_NAME_MAP } from "../secret-sync-maps"; +import { SecretSyncError } from "../secret-sync-errors"; import { TSecretMap } from "../secret-sync-types"; import { GCPLatestSecretVersionAccess, @@ -13,6 +15,8 @@ import { } from "./gcp-sync-types"; const getGcpSecrets = async (accessToken: string, secretSync: TGcpSyncWithCredentials) => { + const { destinationConfig } = secretSync; + let gcpSecrets: GCPSecret[] = []; const pageSize = 100; @@ -48,21 +52,13 @@ const getGcpSecrets = async (accessToken: string, secretSync: TGcpSyncWithCreden pageToken = secretsRes.nextPageToken; } - return gcpSecrets; -}; + const res: { [key: string]: string } = {}; -export const GcpSyncFns = { - syncSecrets: async (secretSync: TGcpSyncWithCredentials, secretMap: TSecretMap) => { - const { destinationConfig, connection } = secretSync; - const accessToken = await getAuthToken(connection); - - const gcpSecrets = await getGcpSecrets(accessToken, secretSync); - const res: { [key: string]: string } = {}; - - for await (const gcpSecret of gcpSecrets) { - const arr = gcpSecret.name.split("/"); - const key = arr[arr.length - 1]; + for await (const gcpSecret of gcpSecrets) { + const arr = gcpSecret.name.split("/"); + const key = arr[arr.length - 1]; + try { const { data: secretLatest } = await request.get( `${IntegrationUrls.GCP_SECRET_MANAGER_URL}/v1/projects/${destinationConfig.projectId}/secrets/${key}/versions/latest:access`, { @@ -74,100 +70,132 @@ export const GcpSyncFns = { ); res[key] = Buffer.from(secretLatest.payload.data, "base64").toString("utf-8"); + } catch (error) { + // when a secret in GCP has no versions, we treat it as if it's a blank value + if (error instanceof AxiosError && error.response?.status === 404) { + res[key] = ""; + } else { + throw new SecretSyncError({ + error, + secretKey: key + }); + } } + } + + return res; +}; + +export const GcpSyncFns = { + syncSecrets: async (secretSync: TGcpSyncWithCredentials, secretMap: TSecretMap) => { + const { destinationConfig, connection } = secretSync; + const accessToken = await getGcpConnectionAuthToken(connection); + + const gcpSecrets = await getGcpSecrets(accessToken, secretSync); for await (const key of Object.keys(secretMap)) { - if (!(key in res)) { - // case: create secret - await request.post( - `${IntegrationUrls.GCP_SECRET_MANAGER_URL}/v1/projects/${destinationConfig.projectId}/secrets`, - { - replication: { - automatic: {} - } - }, - { - params: { - secretId: key + try { + if (!(key in gcpSecrets)) { + // case: create secret + await request.post( + `${IntegrationUrls.GCP_SECRET_MANAGER_URL}/v1/projects/${destinationConfig.projectId}/secrets`, + { + replication: { + automatic: {} + } }, - headers: { - Authorization: `Bearer ${accessToken}`, - "Accept-Encoding": "application/json" + { + params: { + secretId: key + }, + headers: { + Authorization: `Bearer ${accessToken}`, + "Accept-Encoding": "application/json" + } } - } - ); + ); - if (!secretMap[key].value) { - logger.warn( - `syncSecretsGcpsecretManager: create secret value in gcp where [key=${key}] and [projectId=${destinationConfig.projectId}]` + await request.post( + `${IntegrationUrls.GCP_SECRET_MANAGER_URL}/v1/projects/${destinationConfig.projectId}/secrets/${key}:addVersion`, + { + payload: { + data: Buffer.from(secretMap[key].value).toString("base64") + } + }, + { + headers: { + Authorization: `Bearer ${accessToken}`, + "Accept-Encoding": "application/json" + } + } ); } - - await request.post( - `${IntegrationUrls.GCP_SECRET_MANAGER_URL}/v1/projects/${destinationConfig.projectId}/secrets/${key}:addVersion`, - { - payload: { - data: Buffer.from(secretMap[key].value).toString("base64") - } - }, - { - headers: { - Authorization: `Bearer ${accessToken}`, - "Accept-Encoding": "application/json" - } - } - ); + } catch (error) { + throw new SecretSyncError({ + error, + secretKey: key + }); } } - for await (const key of Object.keys(res)) { - if (!(key in secretMap)) { - // case: delete secret - await request.delete( - `${IntegrationUrls.GCP_SECRET_MANAGER_URL}/v1/projects/${destinationConfig.projectId}/secrets/${key}`, - { - headers: { - Authorization: `Bearer ${accessToken}`, - "Accept-Encoding": "application/json" + for await (const key of Object.keys(gcpSecrets)) { + try { + if (!(key in secretMap)) { + // case: delete secret + await request.delete( + `${IntegrationUrls.GCP_SECRET_MANAGER_URL}/v1/projects/${destinationConfig.projectId}/secrets/${key}`, + { + headers: { + Authorization: `Bearer ${accessToken}`, + "Accept-Encoding": "application/json" + } } + ); + } else if (secretMap[key].value !== gcpSecrets[key]) { + if (!secretMap[key].value) { + logger.warn( + `syncSecretsGcpsecretManager: update secret value in gcp where [key=${key}] and [projectId=${destinationConfig.projectId}]` + ); } - ); - } else if (secretMap[key].value !== res[key]) { - if (!secretMap[key].value) { - logger.warn( - `syncSecretsGcpsecretManager: update secret value in gcp where [key=${key}] and [projectId=${destinationConfig.projectId}]` + + await request.post( + `${IntegrationUrls.GCP_SECRET_MANAGER_URL}/v1/projects/${destinationConfig.projectId}/secrets/${key}:addVersion`, + { + payload: { + data: Buffer.from(secretMap[key].value).toString("base64") + } + }, + { + headers: { + Authorization: `Bearer ${accessToken}`, + "Accept-Encoding": "application/json" + } + } ); } - - await request.post( - `${IntegrationUrls.GCP_SECRET_MANAGER_URL}/v1/projects/${destinationConfig.projectId}/secrets/${key}:addVersion`, - { - payload: { - data: Buffer.from(secretMap[key].value).toString("base64") - } - }, - { - headers: { - Authorization: `Bearer ${accessToken}`, - "Accept-Encoding": "application/json" - } - } - ); + } catch (error) { + throw new SecretSyncError({ + error, + secretKey: key + }); } } }, + getSecrets: async (secretSync: TGcpSyncWithCredentials): Promise => { - throw new Error(`${SECRET_SYNC_NAME_MAP[secretSync.destination]} does not support importing secrets.`); + const { connection } = secretSync; + const accessToken = await getGcpConnectionAuthToken(connection); + + const gcpSecrets = await getGcpSecrets(accessToken, secretSync); + return Object.fromEntries(Object.entries(gcpSecrets).map(([key, value]) => [key, { value: value ?? "" }])); }, removeSecrets: async (secretSync: TGcpSyncWithCredentials, secretMap: TSecretMap) => { const { destinationConfig, connection } = secretSync; - const accessToken = await getAuthToken(connection); + const accessToken = await getGcpConnectionAuthToken(connection); const gcpSecrets = await getGcpSecrets(accessToken, secretSync); - for await (const entry of gcpSecrets) { - const arr = entry.name.split("/"); - const key = arr[arr.length - 1]; + for await (const [key] of Object.entries(gcpSecrets)) { if (key in secretMap) { await request.delete( `${IntegrationUrls.GCP_SECRET_MANAGER_URL}/v1/projects/${destinationConfig.projectId}/secrets/${key}`, diff --git a/backend/src/services/secret-sync/gcp/gcp-sync-schemas.ts b/backend/src/services/secret-sync/gcp/gcp-sync-schemas.ts index 9cf7369ee..b0516d166 100644 --- a/backend/src/services/secret-sync/gcp/gcp-sync-schemas.ts +++ b/backend/src/services/secret-sync/gcp/gcp-sync-schemas.ts @@ -9,10 +9,12 @@ import { import { TSyncOptionsConfig } from "@app/services/secret-sync/secret-sync-types"; import { SecretSync } from "../secret-sync-enums"; +import { GcpSyncScope } from "./gcp-sync-enums"; -const GcpSyncOptionsConfig: TSyncOptionsConfig = { canImportSecrets: false }; +const GcpSyncOptionsConfig: TSyncOptionsConfig = { canImportSecrets: true }; const GcpSyncDestinationConfigSchema = z.object({ + scope: z.literal(GcpSyncScope.Global), projectId: z.string().min(1, "Project ID is required") }); @@ -39,5 +41,5 @@ export const GcpSyncListItemSchema = z.object({ name: z.literal("GCP Secret Manager"), connection: z.literal(AppConnection.GCP), destination: z.literal(SecretSync.GCPSecretManager), - canImportSecrets: z.literal(false) + canImportSecrets: z.literal(true) }); diff --git a/backend/src/services/secret-sync/gcp/index.ts b/backend/src/services/secret-sync/gcp/index.ts index 296c6867c..c92ecc890 100644 --- a/backend/src/services/secret-sync/gcp/index.ts +++ b/backend/src/services/secret-sync/gcp/index.ts @@ -1,3 +1,4 @@ export * from "./gcp-sync-constants"; +export * from "./gcp-sync-enums"; export * from "./gcp-sync-schemas"; export * from "./gcp-sync-types"; diff --git a/backend/src/services/secret-sync/secret-sync-fns.ts b/backend/src/services/secret-sync/secret-sync-fns.ts index 0f55c8aa9..c02b8599d 100644 --- a/backend/src/services/secret-sync/secret-sync-fns.ts +++ b/backend/src/services/secret-sync/secret-sync-fns.ts @@ -126,7 +126,7 @@ export const parseSyncErrorMessage = (err: unknown): string => { if (err instanceof SecretSyncError) { return JSON.stringify({ secretKey: err.secretKey, - error: err.message ?? parseSyncErrorMessage(err.error) + error: err.message || parseSyncErrorMessage(err.error) }); } diff --git a/docs/api-reference/endpoints/secret-syncs/gcp-secret-manager/import-secrets.mdx b/docs/api-reference/endpoints/secret-syncs/gcp-secret-manager/import-secrets.mdx new file mode 100644 index 000000000..a975d83bf --- /dev/null +++ b/docs/api-reference/endpoints/secret-syncs/gcp-secret-manager/import-secrets.mdx @@ -0,0 +1,4 @@ +--- +title: "Import Secrets" +openapi: "POST /api/v1/secret-syncs/gcp-secret-manager/{syncId}/import-secrets" +--- diff --git a/docs/images/app-connections/gcp/create-gcp-impersonation-method.png b/docs/images/app-connections/gcp/create-gcp-impersonation-method.png index 8d06e0d8b..141e24430 100644 Binary files a/docs/images/app-connections/gcp/create-gcp-impersonation-method.png and b/docs/images/app-connections/gcp/create-gcp-impersonation-method.png differ diff --git a/docs/images/app-connections/gcp/create-service-account.png b/docs/images/app-connections/gcp/create-service-account.png index cc26e04a2..c0d86b681 100644 Binary files a/docs/images/app-connections/gcp/create-service-account.png and b/docs/images/app-connections/gcp/create-service-account.png differ diff --git a/docs/images/secret-syncs/gcp-secret-manager/gcp-secret-manager-options.png b/docs/images/secret-syncs/gcp-secret-manager/gcp-secret-manager-options.png index addb343a7..d3eddfab9 100644 Binary files a/docs/images/secret-syncs/gcp-secret-manager/gcp-secret-manager-options.png and b/docs/images/secret-syncs/gcp-secret-manager/gcp-secret-manager-options.png differ diff --git a/docs/integrations/app-connections/gcp.mdx b/docs/integrations/app-connections/gcp.mdx index 1e05165ab..3a23535ad 100644 --- a/docs/integrations/app-connections/gcp.mdx +++ b/docs/integrations/app-connections/gcp.mdx @@ -40,11 +40,11 @@ Infisical supports [service account impersonation](https://cloud.google.com/iam/ Create a new service account with an ID that follows this requirement: - Your service account ID must start with the first two sections of your Infisical organization ID. + Your service account ID must end with the first two sections of your Infisical organization ID. Example: - Infisical organization ID: `df92581a-0fe9-42b5-b526-0a1e88ec8085` - - Required service account ID prefix: `df92581a-0fe9` + - Required service account ID suffix: `df92581a-0fe9` ![Create Service Account](/images/app-connections/gcp/create-service-account.png) diff --git a/docs/integrations/secret-syncs/gcp-secret-manager.mdx b/docs/integrations/secret-syncs/gcp-secret-manager.mdx index 613de0a99..ca20d8f79 100644 --- a/docs/integrations/secret-syncs/gcp-secret-manager.mdx +++ b/docs/integrations/secret-syncs/gcp-secret-manager.mdx @@ -7,7 +7,7 @@ description: "Learn how to configure a GCP Secret Manager Sync for Infisical." - Set up and add secrets to [Infisical Cloud](https://app.infisical.com) - Create a [GCP Connection](/integrations/app-connections/gcp) with the required **Secret Sync** permissions - - Enable Cloud Manager Resource API and Secret Manager API on your GCP project + - Enable **Cloud Resource Manager API** and **Secret Manager API** on your GCP project ![Secret Syncs Tab](/images/secret-syncs/gcp-secret-manager/enable-resource-manager-api.png) ![Secret Syncs Tab](/images/secret-syncs/gcp-secret-manager/enable-secret-manager-api.png) @@ -40,6 +40,8 @@ description: "Learn how to configure a GCP Secret Manager Sync for Infisical." - **Initial Sync Behavior**: Determines how Infisical should resolve the initial sync. - **Overwrite Destination Secrets**: Removes any secrets at the destination endpoint not present in Infisical. + - **Import Secrets (Prioritize Infisical)**: Imports secrets from the destination endpoint prior to syncing, prioritizing values present in Infisical if secrets conflict. + - **Import Secrets (Prioritize GCP Secret Manager)**: Imports secrets from the destination endpoint prior to syncing, prioritizing values present in GCP secret manager if secrets conflict. - **Auto-Sync Enabled**: If enabled, secrets will automatically be synced from the source location when changes occur. Disable to enforce manual syncing only. 6. Configure the **Details** of your GCP Secret Manager Sync, then click **Next**. @@ -67,6 +69,7 @@ description: "Learn how to configure a GCP Secret Manager Sync for Infisical." --header 'Content-Type: application/json' \ --data '{ "destinationConfig": { + "scope": "global", "projectId": "infisical-test-playground" }, "name": "my-gcp-sync", diff --git a/docs/mint.json b/docs/mint.json index 6093a5697..b4ad1f3d1 100644 --- a/docs/mint.json +++ b/docs/mint.json @@ -880,6 +880,7 @@ "api-reference/endpoints/secret-syncs/gcp-secret-manager/update", "api-reference/endpoints/secret-syncs/gcp-secret-manager/delete", "api-reference/endpoints/secret-syncs/gcp-secret-manager/sync-secrets", + "api-reference/endpoints/secret-syncs/gcp-secret-manager/import-secrets", "api-reference/endpoints/secret-syncs/gcp-secret-manager/remove-secrets" ] } diff --git a/frontend/src/components/secret-syncs/forms/SecretSyncDestinationFields/GcpSyncFields.tsx b/frontend/src/components/secret-syncs/forms/SecretSyncDestinationFields/GcpSyncFields.tsx index e27bb980c..2af03b203 100644 --- a/frontend/src/components/secret-syncs/forms/SecretSyncDestinationFields/GcpSyncFields.tsx +++ b/frontend/src/components/secret-syncs/forms/SecretSyncDestinationFields/GcpSyncFields.tsx @@ -1,11 +1,15 @@ +import { useEffect } from "react"; import { Controller, useFormContext, useWatch } from "react-hook-form"; import { SingleValue } from "react-select"; +import { faCircleInfo } from "@fortawesome/free-solid-svg-icons"; +import { FontAwesomeIcon } from "@fortawesome/react-fontawesome"; import { SecretSyncConnectionField } from "@app/components/secret-syncs/forms/SecretSyncConnectionField"; -import { FilterableSelect, FormControl } from "@app/components/v2"; +import { FilterableSelect, FormControl, Tooltip } from "@app/components/v2"; import { useGcpConnectionListProjects } from "@app/hooks/api/appConnections/gcp/queries"; import { TGitHubConnectionEnvironment } from "@app/hooks/api/appConnections/github"; import { SecretSync } from "@app/hooks/api/secretSyncs"; +import { GcpSyncScope } from "@app/hooks/api/secretSyncs/types/gcp-sync"; import { TSecretSyncForm } from "../schemas"; @@ -20,6 +24,10 @@ export const GcpSyncFields = () => { enabled: Boolean(connectionId) }); + useEffect(() => { + setValue("destinationConfig.scope", GcpSyncScope.Global); + }, []); + return ( <> { name="destinationConfig.projectId" control={control} render={({ field: { value, onChange }, fieldState: { error } }) => ( - + +
+ Don't see the project you're looking for?{" "} + +
+ + } + > { diff --git a/frontend/src/components/secret-syncs/forms/schemas/gcp-sync-destination-schema.ts b/frontend/src/components/secret-syncs/forms/schemas/gcp-sync-destination-schema.ts index b9efbf2e2..6ffa3ca86 100644 --- a/frontend/src/components/secret-syncs/forms/schemas/gcp-sync-destination-schema.ts +++ b/frontend/src/components/secret-syncs/forms/schemas/gcp-sync-destination-schema.ts @@ -1,10 +1,12 @@ import { z } from "zod"; import { SecretSync } from "@app/hooks/api/secretSyncs"; +import { GcpSyncScope } from "@app/hooks/api/secretSyncs/types/gcp-sync"; export const GcpSyncDestinationSchema = z.object({ destination: z.literal(SecretSync.GCPSecretManager), destinationConfig: z.object({ + scope: z.literal(GcpSyncScope.Global), projectId: z.string().min(1, "Project ID required") }) }); diff --git a/frontend/src/hooks/api/secretSyncs/types/gcp-sync.ts b/frontend/src/hooks/api/secretSyncs/types/gcp-sync.ts index 4857e23e0..bda7da6be 100644 --- a/frontend/src/hooks/api/secretSyncs/types/gcp-sync.ts +++ b/frontend/src/hooks/api/secretSyncs/types/gcp-sync.ts @@ -2,9 +2,14 @@ import { AppConnection } from "@app/hooks/api/appConnections/enums"; import { SecretSync } from "@app/hooks/api/secretSyncs"; import { TRootSecretSync } from "@app/hooks/api/secretSyncs/types/root-sync"; +export enum GcpSyncScope { + Global = "global" +} + export type TGcpSync = TRootSecretSync & { destination: SecretSync.GCPSecretManager; destinationConfig: { + scope: GcpSyncScope.Global; projectId: string; }; connection: { diff --git a/frontend/src/pages/organization/SettingsPage/components/AppConnectionsTab/components/AppConnectionForm/GcpConnectionForm.tsx b/frontend/src/pages/organization/SettingsPage/components/AppConnectionsTab/components/AppConnectionForm/GcpConnectionForm.tsx index bdcb96242..4c61c1df4 100644 --- a/frontend/src/pages/organization/SettingsPage/components/AppConnectionsTab/components/AppConnectionForm/GcpConnectionForm.tsx +++ b/frontend/src/pages/organization/SettingsPage/components/AppConnectionsTab/components/AppConnectionForm/GcpConnectionForm.tsx @@ -103,7 +103,7 @@ export const GcpConnectionForm = ({ appConnection, onSubmit }: Props) => { isError={Boolean(error?.message)} label="Service Account Email" className="group" - helperText={`Service account email must be prefixed with "${currentOrg.id.split("-").slice(0, 2).join("-")}".`} + helperText={`Service account ID (the part of the email before '@') must be suffixed with "${currentOrg.id.split("-").slice(0, 2).join("-")}".`} >