diff --git a/backend/src/lib/api-docs/constants.ts b/backend/src/lib/api-docs/constants.ts index 01f0e5142..6ae5a9d33 100644 --- a/backend/src/lib/api-docs/constants.ts +++ b/backend/src/lib/api-docs/constants.ts @@ -662,6 +662,7 @@ export const INTEGRATION = { secretPrefix: "The prefix for the saved secret. Used by GCP.", secretSuffix: "The suffix for the saved secret. Used by GCP.", initialSyncBehavoir: "Type of syncing behavoir with the integration.", + mappingBehavior: "The mapping behavior of the integration.", shouldAutoRedeploy: "Used by Render to trigger auto deploy.", secretGCPLabel: "The label for GCP secrets.", secretAWSTag: "The tags for AWS secrets.", diff --git a/backend/src/server/routes/v1/integration-router.ts b/backend/src/server/routes/v1/integration-router.ts index 1fd92df3a..f23abc45b 100644 --- a/backend/src/server/routes/v1/integration-router.ts +++ b/backend/src/server/routes/v1/integration-router.ts @@ -8,6 +8,7 @@ import { writeLimit } from "@app/server/config/rateLimiter"; import { getTelemetryDistinctId } from "@app/server/lib/telemetry"; import { verifyAuth } from "@app/server/plugins/auth/verify-auth"; import { AuthMode } from "@app/services/auth/auth-type"; +import { IntegrationMappingBehavior } from "@app/services/integration-auth/integration-list"; import { PostHogEventTypes, TIntegrationCreatedEvent } from "@app/services/telemetry/telemetry-types"; export const registerIntegrationRouter = async (server: FastifyZodProvider) => { @@ -49,6 +50,10 @@ export const registerIntegrationRouter = async (server: FastifyZodProvider) => { secretPrefix: z.string().optional().describe(INTEGRATION.CREATE.metadata.secretPrefix), secretSuffix: z.string().optional().describe(INTEGRATION.CREATE.metadata.secretSuffix), initialSyncBehavior: z.string().optional().describe(INTEGRATION.CREATE.metadata.initialSyncBehavoir), + mappingBehavior: z + .nativeEnum(IntegrationMappingBehavior) + .optional() + .describe(INTEGRATION.CREATE.metadata.mappingBehavior), shouldAutoRedeploy: z.boolean().optional().describe(INTEGRATION.CREATE.metadata.shouldAutoRedeploy), secretGCPLabel: z .object({ @@ -160,6 +165,7 @@ export const registerIntegrationRouter = async (server: FastifyZodProvider) => { secretPrefix: z.string().optional().describe(INTEGRATION.CREATE.metadata.secretPrefix), secretSuffix: z.string().optional().describe(INTEGRATION.CREATE.metadata.secretSuffix), initialSyncBehavior: z.string().optional().describe(INTEGRATION.CREATE.metadata.initialSyncBehavoir), + mappingBehavior: z.string().optional().describe(INTEGRATION.CREATE.metadata.mappingBehavior), shouldAutoRedeploy: z.boolean().optional().describe(INTEGRATION.CREATE.metadata.shouldAutoRedeploy), secretGCPLabel: z .object({ diff --git a/backend/src/services/integration-auth/integration-list.ts b/backend/src/services/integration-auth/integration-list.ts index e49cd3862..2aaf5d5f4 100644 --- a/backend/src/services/integration-auth/integration-list.ts +++ b/backend/src/services/integration-auth/integration-list.ts @@ -43,6 +43,11 @@ export enum IntegrationInitialSyncBehavior { PREFER_SOURCE = "prefer-source" } +export enum IntegrationMappingBehavior { + ONE_TO_ONE = "one-to-one", + MANY_TO_ONE = "many-to-one" +} + export enum IntegrationUrls { // integration oauth endpoints GCP_TOKEN_URL = "https://oauth2.googleapis.com/token", diff --git a/backend/src/services/integration-auth/integration-sync-secret.ts b/backend/src/services/integration-auth/integration-sync-secret.ts index 1581eaef7..5c5500a69 100644 --- a/backend/src/services/integration-auth/integration-sync-secret.ts +++ b/backend/src/services/integration-auth/integration-sync-secret.ts @@ -30,7 +30,12 @@ import { BadRequestError } from "@app/lib/errors"; import { TCreateManySecretsRawFn, TUpdateManySecretsRawFn } from "@app/services/secret/secret-types"; import { TIntegrationDALFactory } from "../integration/integration-dal"; -import { IntegrationInitialSyncBehavior, Integrations, IntegrationUrls } from "./integration-list"; +import { + IntegrationInitialSyncBehavior, + IntegrationMappingBehavior, + Integrations, + IntegrationUrls +} from "./integration-list"; const getSecretKeyValuePair = (secrets: Record) => Object.keys(secrets).reduce>((prev, key) => { @@ -570,134 +575,145 @@ const syncSecretsAWSSecretManager = async ({ accessId: string | null; accessToken: string; }) => { - let secretsManager; - const secKeyVal = getSecretKeyValuePair(secrets); const metadata = z.record(z.any()).parse(integration.metadata || {}); - try { - if (!accessId) return; - secretsManager = new SecretsManagerClient({ - region: integration.region as string, - credentials: { - accessKeyId: accessId, - secretAccessKey: accessToken + if (!accessId) return; + + const secretsManager = new SecretsManagerClient({ + region: integration.region as string, + credentials: { + accessKeyId: accessId, + secretAccessKey: accessToken + } + }); + + const processAwsSecret = async (secretId: string, keyValuePairs: Record) => { + try { + const awsSecretManagerSecret = await secretsManager.send( + new GetSecretValueCommand({ + SecretId: secretId + }) + ); + + let awsSecretManagerSecretObj: { [key: string]: AWS.SecretsManager } = {}; + + if (awsSecretManagerSecret?.SecretString) { + awsSecretManagerSecretObj = JSON.parse(awsSecretManagerSecret.SecretString); } - }); - const awsSecretManagerSecret = await secretsManager.send( - new GetSecretValueCommand({ - SecretId: integration.app as string - }) - ); + if (!isEqual(awsSecretManagerSecretObj, keyValuePairs)) { + await secretsManager.send( + new UpdateSecretCommand({ + SecretId: secretId, + SecretString: JSON.stringify(keyValuePairs) + }) + ); + } - let awsSecretManagerSecretObj: { [key: string]: AWS.SecretsManager } = {}; + const secretAWSTag = metadata.secretAWSTag as { key: string; value: string }[] | undefined; - if (awsSecretManagerSecret?.SecretString) { - awsSecretManagerSecretObj = JSON.parse(awsSecretManagerSecret.SecretString); - } + if (secretAWSTag && secretAWSTag.length) { + const describedSecret = await secretsManager.send( + // requires secretsmanager:DescribeSecret policy + new DescribeSecretCommand({ + SecretId: secretId + }) + ); - if (!isEqual(awsSecretManagerSecretObj, secKeyVal)) { - await secretsManager.send( - new UpdateSecretCommand({ - SecretId: integration.app as string, - SecretString: JSON.stringify(secKeyVal) - }) - ); - } + if (!describedSecret.Tags) return; - const secretAWSTag = metadata.secretAWSTag as { key: string; value: string }[] | undefined; + const integrationTagObj = secretAWSTag.reduce( + (acc, item) => { + acc[item.key] = item.value; + return acc; + }, + {} as Record + ); - if (secretAWSTag && secretAWSTag.length) { - const describedSecret = await secretsManager.send( - // requires secretsmanager:DescribeSecret policy - new DescribeSecretCommand({ - SecretId: integration.app as string - }) - ); + const awsTagObj = (describedSecret.Tags || []).reduce( + (acc, item) => { + if (item.Key && item.Value) { + acc[item.Key] = item.Value; + } + return acc; + }, + {} as Record + ); - if (!describedSecret.Tags) return; + const tagsToUpdate: { Key: string; Value: string }[] = []; + const tagsToDelete: { Key: string; Value: string }[] = []; - const integrationTagObj = secretAWSTag.reduce( - (acc, item) => { - acc[item.key] = item.value; - return acc; - }, - {} as Record - ); - - const awsTagObj = (describedSecret.Tags || []).reduce( - (acc, item) => { - if (item.Key && item.Value) { - acc[item.Key] = item.Value; + describedSecret.Tags?.forEach((tag) => { + if (tag.Key && tag.Value) { + if (!(tag.Key in integrationTagObj)) { + // delete tag from AWS secret manager + tagsToDelete.push({ + Key: tag.Key, + Value: tag.Value + }); + } else if (tag.Value !== integrationTagObj[tag.Key]) { + // update tag in AWS secret manager + tagsToUpdate.push({ + Key: tag.Key, + Value: integrationTagObj[tag.Key] + }); + } } - return acc; - }, - {} as Record - ); + }); - const tagsToUpdate: { Key: string; Value: string }[] = []; - const tagsToDelete: { Key: string; Value: string }[] = []; - - describedSecret.Tags?.forEach((tag) => { - if (tag.Key && tag.Value) { - if (!(tag.Key in integrationTagObj)) { - // delete tag from AWS secret manager - tagsToDelete.push({ - Key: tag.Key, - Value: tag.Value - }); - } else if (tag.Value !== integrationTagObj[tag.Key]) { - // update tag in AWS secret manager + secretAWSTag?.forEach((tag) => { + if (!(tag.key in awsTagObj)) { + // create tag in AWS secret manager tagsToUpdate.push({ - Key: tag.Key, - Value: integrationTagObj[tag.Key] + Key: tag.key, + Value: tag.value }); } - } - }); + }); - secretAWSTag?.forEach((tag) => { - if (!(tag.key in awsTagObj)) { - // create tag in AWS secret manager - tagsToUpdate.push({ - Key: tag.key, - Value: tag.value - }); + if (tagsToUpdate.length) { + await secretsManager.send( + new TagResourceCommand({ + SecretId: secretId, + Tags: tagsToUpdate + }) + ); } - }); - if (tagsToUpdate.length) { - await secretsManager.send( - new TagResourceCommand({ - SecretId: integration.app as string, - Tags: tagsToUpdate - }) - ); + if (tagsToDelete.length) { + await secretsManager.send( + new UntagResourceCommand({ + SecretId: secretId, + TagKeys: tagsToDelete.map((tag) => tag.Key) + }) + ); + } } - - if (tagsToDelete.length) { + } catch (err) { + // case when AWS manager can't find the specified secret + if (err instanceof ResourceNotFoundException && secretsManager) { await secretsManager.send( - new UntagResourceCommand({ - SecretId: integration.app as string, - TagKeys: tagsToDelete.map((tag) => tag.Key) + new CreateSecretCommand({ + Name: secretId, + SecretString: JSON.stringify(keyValuePairs), + ...(metadata.kmsKeyId && { KmsKeyId: metadata.kmsKeyId }), + Tags: metadata.secretAWSTag + ? metadata.secretAWSTag.map((tag: { key: string; value: string }) => ({ Key: tag.key, Value: tag.value })) + : [] }) ); } } - } catch (err) { - // case when AWS manager can't find the specified secret - if (err instanceof ResourceNotFoundException && secretsManager) { - await secretsManager.send( - new CreateSecretCommand({ - Name: integration.app as string, - SecretString: JSON.stringify(secKeyVal), - ...(metadata.kmsKeyId && { KmsKeyId: metadata.kmsKeyId }), - Tags: metadata.secretAWSTag - ? metadata.secretAWSTag.map((tag: { key: string; value: string }) => ({ Key: tag.key, Value: tag.value })) - : [] - }) - ); + }; + + if (metadata.mappingBehavior === IntegrationMappingBehavior.ONE_TO_ONE) { + for await (const [key, value] of Object.entries(secrets)) { + await processAwsSecret(key, { + [key]: value.value + }); } + } else { + await processAwsSecret(integration.app as string, getSecretKeyValuePair(secrets)); } }; diff --git a/docs/images/integrations/aws/integrations-aws-secret-manager-create.png b/docs/images/integrations/aws/integrations-aws-secret-manager-create.png index 21f2213ef..e43cfbf9e 100644 Binary files a/docs/images/integrations/aws/integrations-aws-secret-manager-create.png and b/docs/images/integrations/aws/integrations-aws-secret-manager-create.png differ diff --git a/docs/integrations/cloud/aws-secret-manager.mdx b/docs/integrations/cloud/aws-secret-manager.mdx index db95c8308..9b3a8a2f8 100644 --- a/docs/integrations/cloud/aws-secret-manager.mdx +++ b/docs/integrations/cloud/aws-secret-manager.mdx @@ -72,6 +72,9 @@ Prerequisites: The region that you want to integrate with in AWS Secrets Manager. + + How you want the integration to map the secrets. The selected value could be either one to one or one to many. + The secret name/path in AWS into which you want to sync the secrets from Infisical. diff --git a/frontend/src/hooks/api/integrations/queries.tsx b/frontend/src/hooks/api/integrations/queries.tsx index 9a1ee6fbf..7325dc4a3 100644 --- a/frontend/src/hooks/api/integrations/queries.tsx +++ b/frontend/src/hooks/api/integrations/queries.tsx @@ -64,6 +64,7 @@ export const useCreateIntegration = () => { secretSuffix?: string; initialSyncBehavior?: string; shouldAutoRedeploy?: boolean; + mappingBehavior?: string; secretAWSTag?: { key: string; value: string; diff --git a/frontend/src/hooks/api/integrations/types.ts b/frontend/src/hooks/api/integrations/types.ts index 345e41b1a..21e6bff26 100644 --- a/frontend/src/hooks/api/integrations/types.ts +++ b/frontend/src/hooks/api/integrations/types.ts @@ -36,6 +36,7 @@ export type TIntegration = { metadata?: { secretSuffix?: string; syncBehavior?: IntegrationSyncBehavior; + mappingBehavior?: IntegrationMappingBehavior; scope: string; org: string; project: string; @@ -48,3 +49,8 @@ export enum IntegrationSyncBehavior { PREFER_TARGET = "prefer-target", PREFER_SOURCE = "prefer-source" } + +export enum IntegrationMappingBehavior { + ONE_TO_ONE = "one-to-one", + MANY_TO_ONE = "many-to-one" +} diff --git a/frontend/src/pages/integrations/aws-secret-manager/create.tsx b/frontend/src/pages/integrations/aws-secret-manager/create.tsx index 07daff39c..2c04702e1 100644 --- a/frontend/src/pages/integrations/aws-secret-manager/create.tsx +++ b/frontend/src/pages/integrations/aws-secret-manager/create.tsx @@ -15,6 +15,7 @@ import queryString from "query-string"; import { useCreateIntegration } from "@app/hooks/api"; import { useGetIntegrationAuthAwsKmsKeys } from "@app/hooks/api/integrationAuth/queries"; +import { IntegrationMappingBehavior } from "@app/hooks/api/integrations/types"; import { Button, @@ -70,6 +71,17 @@ const awsRegions = [ { name: "AWS GovCloud (US-West)", slug: "us-gov-west-1" } ]; +const mappingBehaviors = [ + { + label: "Many to One (All Infisical secrets will be mapped to a single AWS secret)", + value: IntegrationMappingBehavior.MANY_TO_ONE + }, + { + label: "One to One - (Each Infisical secret will be mapped to its own AWS secret)", + value: IntegrationMappingBehavior.ONE_TO_ONE + } +]; + export default function AWSSecretManagerCreateIntegrationPage() { const router = useRouter(); const { mutateAsync } = useCreateIntegration(); @@ -84,6 +96,9 @@ export default function AWSSecretManagerCreateIntegrationPage() { const [selectedSourceEnvironment, setSelectedSourceEnvironment] = useState(""); const [secretPath, setSecretPath] = useState("/"); const [selectedAWSRegion, setSelectedAWSRegion] = useState(""); + const [selectedMappingBehavior, setSelectedMappingBehavior] = useState( + IntegrationMappingBehavior.MANY_TO_ONE + ); const [targetSecretName, setTargetSecretName] = useState(""); const [targetSecretNameErrorText, setTargetSecretNameErrorText] = useState(""); const [tagKey, setTagKey] = useState(""); @@ -116,7 +131,14 @@ export default function AWSSecretManagerCreateIntegrationPage() { const handleButtonClick = async () => { try { - if (targetSecretName.trim() === "") { + if (!selectedMappingBehavior) { + return; + } + + if ( + selectedMappingBehavior === IntegrationMappingBehavior.MANY_TO_ONE && + targetSecretName.trim() === "" + ) { setTargetSecretName("Secret name cannot be blank"); return; } @@ -143,7 +165,8 @@ export default function AWSSecretManagerCreateIntegrationPage() { ] } : {}), - ...(kmsKeyId && { kmsKeyId }) + ...(kmsKeyId && { kmsKeyId }), + mappingBehavior: selectedMappingBehavior } }); @@ -248,19 +271,40 @@ export default function AWSSecretManagerCreateIntegrationPage() { ))} - - setTargetSecretName(e.target.value)} - /> + + + {selectedMappingBehavior === IntegrationMappingBehavior.MANY_TO_ONE && ( + + setTargetSecretName(e.target.value)} + /> + + )} diff --git a/frontend/src/views/IntegrationsPage/components/IntegrationsSection/IntegrationsSection.tsx b/frontend/src/views/IntegrationsPage/components/IntegrationsSection/IntegrationsSection.tsx index a6e3a45e7..267ff8580 100644 --- a/frontend/src/views/IntegrationsPage/components/IntegrationsSection/IntegrationsSection.tsx +++ b/frontend/src/views/IntegrationsPage/components/IntegrationsSection/IntegrationsSection.tsx @@ -21,6 +21,7 @@ import { import { ProjectPermissionActions, ProjectPermissionSub } from "@app/context"; import { usePopUp } from "@app/hooks"; import { useSyncIntegration } from "@app/hooks/api/integrations/queries"; +import { IntegrationMappingBehavior } from "@app/hooks/api/integrations/types"; import { TIntegration } from "@app/hooks/api/types"; type Props = { @@ -131,30 +132,35 @@ export const IntegrationsSection = ({ )} -
- -
- {(integration.integration === "hashicorp-vault" && - `${integration.app} - path: ${integration.path}`) || - (integration.scope === "github-org" && `${integration.owner}`) || - (integration.integration === "aws-parameter-store" && - `${integration.path}`) || - (integration.scope?.startsWith("github-") && - `${integration.owner}/${integration.app}`) || - integration.app} + {!( + integration.integration === "aws-secret-manager" && + integration.metadata?.mappingBehavior === IntegrationMappingBehavior.ONE_TO_ONE + ) && ( +
+ +
+ {(integration.integration === "hashicorp-vault" && + `${integration.app} - path: ${integration.path}`) || + (integration.scope === "github-org" && `${integration.owner}`) || + (integration.integration === "aws-parameter-store" && + `${integration.path}`) || + (integration.scope?.startsWith("github-") && + `${integration.owner}/${integration.app}`) || + integration.app} +
-
+ )} {(integration.integration === "vercel" || integration.integration === "netlify" || integration.integration === "railway" ||