misc: added import support and a few ui/ux updates

This commit is contained in:
Sheen Capadngan
2025-01-28 16:36:56 +08:00
parent effd88c4bd
commit 0a3e99b334
19 changed files with 179 additions and 107 deletions

View File

@@ -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;
};

View File

@@ -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
};

View File

@@ -0,0 +1,3 @@
export enum GcpSyncScope {
Global = "global"
}

View File

@@ -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<GCPLatestSecretVersionAccess>(
`${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<TSecretMap> => {
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}`,

View File

@@ -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)
});

View File

@@ -1,3 +1,4 @@
export * from "./gcp-sync-constants";
export * from "./gcp-sync-enums";
export * from "./gcp-sync-schemas";
export * from "./gcp-sync-types";

View File

@@ -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)
});
}

View File

@@ -0,0 +1,4 @@
---
title: "Import Secrets"
openapi: "POST /api/v1/secret-syncs/gcp-secret-manager/{syncId}/import-secrets"
---

Binary file not shown.

Before

Width:  |  Height:  |  Size: 610 KiB

After

Width:  |  Height:  |  Size: 632 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 416 KiB

After

Width:  |  Height:  |  Size: 380 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 685 KiB

After

Width:  |  Height:  |  Size: 602 KiB

View File

@@ -40,11 +40,11 @@ Infisical supports [service account impersonation](https://cloud.google.com/iam/
<Step title="Create Service Account">
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)
</Step>

View File

@@ -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",

View File

@@ -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"
]
}

View File

@@ -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 (
<>
<SecretSyncConnectionField
@@ -31,7 +39,22 @@ export const GcpSyncFields = () => {
name="destinationConfig.projectId"
control={control}
render={({ field: { value, onChange }, fieldState: { error } }) => (
<FormControl isError={Boolean(error)} errorText={error?.message} label="Project">
<FormControl
isError={Boolean(error)}
errorText={error?.message}
label="Project"
helperText={
<Tooltip
className="max-w-md"
content="Ensure that you've enabled the Secret Manager API and Cloud Resource Manager API on your GCP project. Additionally, make sure that the service account is assigned the appropriate GCP roles."
>
<div>
<span>Don&#39;t see the project you&#39;re looking for?</span>{" "}
<FontAwesomeIcon icon={faCircleInfo} className="text-mineshaft-400" />
</div>
</Tooltip>
}
>
<FilterableSelect
menuPlacement="top"
isLoading={isPending && Boolean(connectionId)}

View File

@@ -8,7 +8,7 @@ import { SECRET_SYNC_INITIAL_SYNC_BEHAVIOR_MAP, SECRET_SYNC_MAP } from "@app/hel
import { SecretSync } from "@app/hooks/api/secretSyncs";
import { AwsParameterStoreSyncReviewFields } from "./AwsParameterStoreSyncReviewFields";
import { GcpSyncReviewFields } from "./GcpSyncReviewFIelds";
import { GcpSyncReviewFields } from "./GcpSyncReviewFields";
import { GitHubSyncReviewFields } from "./GitHubSyncReviewFields";
export const SecretSyncReviewFields = () => {

View File

@@ -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")
})
});

View File

@@ -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: {

View File

@@ -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("-")}".`}
>
<SecretInput
containerClassName="text-gray-400 group-focus-within:!border-primary-400/50 border border-mineshaft-500 bg-mineshaft-900 px-2.5 py-1.5"