diff --git a/backend/src/lib/api-docs/constants.ts b/backend/src/lib/api-docs/constants.ts index 7e85c2031..fb0bf84e3 100644 --- a/backend/src/lib/api-docs/constants.ts +++ b/backend/src/lib/api-docs/constants.ts @@ -1722,6 +1722,18 @@ export const SecretSyncs = { initialSyncBehavior: `Specify how Infisical should resolve the initial sync to the ${destinationName} destination.` }; }, + ADDITIONAL_SYNC_OPTIONS: { + AWS_PARAMETER_STORE: { + keyId: "The AWS KMS key ID or alias to use when encrypting parameters synced by Infisical.", + tags: "Optional resource tags to add to parameters synced by Infisical.", + syncSecretMetadataAsTags: `Whether Infisical secret metadata should be added as resource tags to parameters synced by Infisical.` + }, + AWS_SECRETS_MANAGER: { + keyId: "The AWS KMS key ID or alias to use when encrypting parameters synced by Infisical.", + tags: "Optional tags to add to secrets synced by Infisical.", + syncSecretMetadataAsTags: `Whether Infisical secret metadata should be added as tags to secrets synced by Infisical.` + } + }, DESTINATION_CONFIG: { AWS_PARAMETER_STORE: { region: "The AWS region to sync secrets to.", diff --git a/backend/src/server/routes/v1/app-connection-routers/aws-connection-router.ts b/backend/src/server/routes/v1/app-connection-routers/aws-connection-router.ts index 87ed022a2..674e6e417 100644 --- a/backend/src/server/routes/v1/app-connection-routers/aws-connection-router.ts +++ b/backend/src/server/routes/v1/app-connection-routers/aws-connection-router.ts @@ -1,13 +1,19 @@ -import { AppConnection } from "@app/services/app-connection/app-connection-enums"; +import { z } from "zod"; + +import { readLimit } from "@app/server/config/rateLimiter"; +import { verifyAuth } from "@app/server/plugins/auth/verify-auth"; +import { AppConnection, AWSRegion } from "@app/services/app-connection/app-connection-enums"; import { CreateAwsConnectionSchema, SanitizedAwsConnectionSchema, UpdateAwsConnectionSchema } from "@app/services/app-connection/aws"; +import { AuthMode } from "@app/services/auth/auth-type"; +import { SecretSync } from "@app/services/secret-sync/secret-sync-enums"; import { registerAppConnectionEndpoints } from "./app-connection-endpoints"; -export const registerAwsConnectionRouter = async (server: FastifyZodProvider) => +export const registerAwsConnectionRouter = async (server: FastifyZodProvider) => { registerAppConnectionEndpoints({ app: AppConnection.AWS, server, @@ -15,3 +21,42 @@ export const registerAwsConnectionRouter = async (server: FastifyZodProvider) => createSchema: CreateAwsConnectionSchema, updateSchema: UpdateAwsConnectionSchema }); + + // The below endpoints are not exposed and for Infisical App use + + server.route({ + method: "GET", + url: `/:connectionId/kms-keys`, + config: { + rateLimit: readLimit + }, + schema: { + params: z.object({ + connectionId: z.string().uuid() + }), + querystring: z.object({ + region: z.nativeEnum(AWSRegion), + destination: z.enum([SecretSync.AWSParameterStore, SecretSync.AWSSecretsManager]) + }), + response: { + 200: z.object({ + kmsKeys: z.object({ alias: z.string(), id: z.string() }).array() + }) + } + }, + onRequest: verifyAuth([AuthMode.JWT]), + handler: async (req) => { + const { connectionId } = req.params; + + const kmsKeys = await server.services.appConnection.aws.listKmsKeys( + { + connectionId, + ...req.query + }, + req.permission + ); + + return { kmsKeys }; + } + }); +}; diff --git a/backend/src/services/app-connection/app-connection-service.ts b/backend/src/services/app-connection/app-connection-service.ts index 7ae8b6377..8c4dd6a7c 100644 --- a/backend/src/services/app-connection/app-connection-service.ts +++ b/backend/src/services/app-connection/app-connection-service.ts @@ -22,18 +22,19 @@ import { TUpdateAppConnectionDTO, TValidateAppConnectionCredentials } from "@app/services/app-connection/app-connection-types"; -import { ValidateAwsConnectionCredentialsSchema } from "@app/services/app-connection/aws"; -import { ValidateDatabricksConnectionCredentialsSchema } from "@app/services/app-connection/databricks"; -import { databricksConnectionService } from "@app/services/app-connection/databricks/databricks-connection-service"; -import { ValidateGitHubConnectionCredentialsSchema } from "@app/services/app-connection/github"; -import { githubConnectionService } from "@app/services/app-connection/github/github-connection-service"; import { TKmsServiceFactory } from "@app/services/kms/kms-service"; import { TAppConnectionDALFactory } from "./app-connection-dal"; +import { ValidateAwsConnectionCredentialsSchema } from "./aws"; +import { awsConnectionService } from "./aws/aws-connection-service"; import { ValidateAzureAppConfigurationConnectionCredentialsSchema } from "./azure-app-configuration"; import { ValidateAzureKeyVaultConnectionCredentialsSchema } from "./azure-key-vault"; +import { ValidateDatabricksConnectionCredentialsSchema } from "./databricks"; +import { databricksConnectionService } from "./databricks/databricks-connection-service"; import { ValidateGcpConnectionCredentialsSchema } from "./gcp"; import { gcpConnectionService } from "./gcp/gcp-connection-service"; +import { ValidateGitHubConnectionCredentialsSchema } from "./github"; +import { githubConnectionService } from "./github/github-connection-service"; export type TAppConnectionServiceFactoryDep = { appConnectionDAL: TAppConnectionDALFactory; @@ -369,6 +370,7 @@ export const appConnectionServiceFactory = ({ listAvailableAppConnectionsForUser, github: githubConnectionService(connectAppConnectionById), gcp: gcpConnectionService(connectAppConnectionById), - databricks: databricksConnectionService(connectAppConnectionById, appConnectionDAL, kmsService) + databricks: databricksConnectionService(connectAppConnectionById, appConnectionDAL, kmsService), + aws: awsConnectionService(connectAppConnectionById) }; }; diff --git a/backend/src/services/app-connection/app-connection-types.ts b/backend/src/services/app-connection/app-connection-types.ts index f0640eea4..b9dceda2e 100644 --- a/backend/src/services/app-connection/app-connection-types.ts +++ b/backend/src/services/app-connection/app-connection-types.ts @@ -1,3 +1,4 @@ +import { AWSRegion } from "@app/services/app-connection/app-connection-enums"; import { TAwsConnection, TAwsConnectionConfig, @@ -16,6 +17,7 @@ import { TGitHubConnectionInput, TValidateGitHubConnectionCredentials } from "@app/services/app-connection/github"; +import { SecretSync } from "@app/services/secret-sync/secret-sync-enums"; import { TAzureAppConfigurationConnection, @@ -73,3 +75,9 @@ export type TValidateAppConnectionCredentials = | TValidateAzureKeyVaultConnectionCredentials | TValidateAzureAppConfigurationConnectionCredentials | TValidateDatabricksConnectionCredentials; + +export type TListAwsConnectionKmsKeys = { + connectionId: string; + region: AWSRegion; + destination: SecretSync.AWSParameterStore | SecretSync.AWSSecretsManager; +}; diff --git a/backend/src/services/app-connection/aws/aws-connection-service.ts b/backend/src/services/app-connection/aws/aws-connection-service.ts new file mode 100644 index 000000000..689608b81 --- /dev/null +++ b/backend/src/services/app-connection/aws/aws-connection-service.ts @@ -0,0 +1,88 @@ +import AWS from "aws-sdk"; + +import { OrgServiceActor } from "@app/lib/types"; +import { AppConnection } from "@app/services/app-connection/app-connection-enums"; +import { TListAwsConnectionKmsKeys } from "@app/services/app-connection/app-connection-types"; +import { getAwsConnectionConfig } from "@app/services/app-connection/aws/aws-connection-fns"; +import { TAwsConnection } from "@app/services/app-connection/aws/aws-connection-types"; +import { SecretSync } from "@app/services/secret-sync/secret-sync-enums"; + +type TGetAppConnectionFunc = ( + app: AppConnection, + connectionId: string, + actor: OrgServiceActor +) => Promise; + +const listAwsKmsKeys = async ( + appConnection: TAwsConnection, + { region, destination }: Pick +) => { + const { credentials } = await getAwsConnectionConfig(appConnection, region); + + const awsKms = new AWS.KMS({ + credentials, + region + }); + + const aliasEntries: AWS.KMS.AliasList = []; + let aliasMarker: string | undefined; + do { + // eslint-disable-next-line no-await-in-loop + const response = await awsKms.listAliases({ Limit: 100, Marker: aliasMarker }).promise(); + aliasEntries.push(...(response.Aliases || [])); + aliasMarker = response.NextMarker; + } while (aliasMarker); + + const keyMetadataRecord: Record = {}; + for await (const aliasEntry of aliasEntries) { + if (aliasEntry.TargetKeyId) { + const keyDescription = await awsKms.describeKey({ KeyId: aliasEntry.TargetKeyId }).promise(); + + keyMetadataRecord[aliasEntry.TargetKeyId] = keyDescription.KeyMetadata; + } + } + + const validAliasEntries = aliasEntries.filter((aliasEntry) => { + if (!aliasEntry.TargetKeyId) return false; + + if (destination === SecretSync.AWSParameterStore && aliasEntry.AliasName === "alias/aws/ssm") return true; + + if (destination === SecretSync.AWSSecretsManager && aliasEntry.AliasName === "alias/aws/secretsmanager") + return true; + + if (aliasEntry.AliasName?.includes("alias/aws/")) return false; + + const keyMetadata = keyMetadataRecord[aliasEntry.TargetKeyId]; + + if (!keyMetadata || keyMetadata.KeyUsage !== "ENCRYPT_DECRYPT" || keyMetadata.KeySpec !== "SYMMETRIC_DEFAULT") + return false; + + return true; + }); + + const kmsKeys = validAliasEntries.map((aliasEntry) => { + return { + id: aliasEntry.TargetKeyId!, + alias: aliasEntry.AliasName! + }; + }); + + return kmsKeys; +}; + +export const awsConnectionService = (getAppConnection: TGetAppConnectionFunc) => { + const listKmsKeys = async ( + { connectionId, region, destination }: TListAwsConnectionKmsKeys, + actor: OrgServiceActor + ) => { + const appConnection = await getAppConnection(AppConnection.AWS, connectionId, actor); + + const kmsKeys = await listAwsKmsKeys(appConnection, { region, destination }); + + return kmsKeys; + }; + + return { + listKmsKeys + }; +}; diff --git a/backend/src/services/secret-sync/aws-parameter-store/aws-parameter-store-sync-fns.ts b/backend/src/services/secret-sync/aws-parameter-store/aws-parameter-store-sync-fns.ts index 6d7739afa..9ca035774 100644 --- a/backend/src/services/secret-sync/aws-parameter-store/aws-parameter-store-sync-fns.ts +++ b/backend/src/services/secret-sync/aws-parameter-store/aws-parameter-store-sync-fns.ts @@ -7,6 +7,8 @@ import { TSecretMap } from "@app/services/secret-sync/secret-sync-types"; import { TAwsParameterStoreSyncWithCredentials } from "./aws-parameter-store-sync-types"; type TAWSParameterStoreRecord = Record; +type TAWSParameterStoreMetadataRecord = Record; +type TAWSParameterStoreTagsRecord = Record>; const MAX_RETRIES = 5; const BATCH_SIZE = 10; @@ -80,6 +82,129 @@ const getParametersByPath = async (ssm: AWS.SSM, path: string): Promise => { + const awsParameterStoreMetadataRecord: TAWSParameterStoreMetadataRecord = {}; + let hasNext = true; + let nextToken: string | undefined; + let attempt = 0; + + while (hasNext) { + try { + // eslint-disable-next-line no-await-in-loop + const parameters = await ssm + .describeParameters({ + MaxResults: 10, + NextToken: nextToken, + ParameterFilters: [ + { + Key: "Path", + Option: "OneLevel", + Values: [path] + } + ] + }) + .promise(); + + attempt = 0; + + if (parameters.Parameters) { + parameters.Parameters.forEach((parameter) => { + if (parameter.Name) { + // no leading slash if path is '/' + const secKey = path.length > 1 ? parameter.Name.substring(path.length) : parameter.Name; + awsParameterStoreMetadataRecord[secKey] = parameter; + } + }); + } + + hasNext = Boolean(parameters.NextToken); + nextToken = parameters.NextToken; + } catch (e) { + if ((e as AWSError).code === "ThrottlingException" && attempt < MAX_RETRIES) { + attempt += 1; + // eslint-disable-next-line no-await-in-loop + await sleep(); + // eslint-disable-next-line no-continue + continue; + } + + throw e; + } + } + + return awsParameterStoreMetadataRecord; +}; + +const getParameterStoreTagsRecord = async ( + ssm: AWS.SSM, + awsParameterStoreSecretsRecord: TAWSParameterStoreRecord, + needsTagsPermissions: boolean +): Promise<{ shouldManageTags: boolean; awsParameterStoreTagsRecord: TAWSParameterStoreTagsRecord }> => { + const awsParameterStoreTagsRecord: TAWSParameterStoreTagsRecord = {}; + + for await (const entry of Object.entries(awsParameterStoreSecretsRecord)) { + const [key, parameter] = entry; + + if (!parameter.Name) { + // eslint-disable-next-line no-continue + continue; + } + + try { + const tags = await ssm + .listTagsForResource({ + ResourceType: "Parameter", + ResourceId: parameter.Name + }) + .promise(); + + awsParameterStoreTagsRecord[key] = Object.fromEntries(tags.TagList?.map((tag) => [tag.Key, tag.Value]) ?? []); + } catch (e) { + // users aren't required to provide tag permissions to use sync so we handle gracefully if unauthorized + // and they aren't trying to configure tags + if ((e as AWSError).code === "AccessDeniedException") { + if (!needsTagsPermissions) { + return { shouldManageTags: false, awsParameterStoreTagsRecord: {} }; + } + + throw new SecretSyncError({ + message: + "IAM role has inadequate permissions to manage resource tags. Ensure the following polices are present: ssm:ListTagsForResource, ssm:AddTagsToResource, and ssm:RemoveTagsFromResource", + shouldRetry: false + }); + } + + throw e; + } + } + + return { shouldManageTags: true, awsParameterStoreTagsRecord }; +}; + +const processParameterTags = ({ + syncTagsRecord, + awsTagsRecord +}: { + syncTagsRecord: Record; + awsTagsRecord: Record; +}) => { + const tagsToAdd: AWS.SSM.TagList = []; + const tagKeysToRemove: string[] = []; + + for (const syncEntry of Object.entries(syncTagsRecord)) { + const [syncKey, syncValue] = syncEntry; + + if (!(syncKey in awsTagsRecord) || syncValue !== awsTagsRecord[syncKey]) + tagsToAdd.push({ Key: syncKey, Value: syncValue }); + } + + for (const awsKey of Object.keys(awsTagsRecord)) { + if (!(awsKey in syncTagsRecord)) tagKeysToRemove.push(awsKey); + } + + return { tagsToAdd, tagKeysToRemove }; +}; + const putParameter = async ( ssm: AWS.SSM, params: AWS.SSM.PutParameterRequest, @@ -98,6 +223,42 @@ const putParameter = async ( } }; +const addTagsToParameter = async ( + ssm: AWS.SSM, + params: Omit, + attempt = 0 +): Promise => { + try { + return await ssm.addTagsToResource({ ...params, ResourceType: "Parameter" }).promise(); + } catch (error) { + if ((error as AWSError).code === "ThrottlingException" && attempt < MAX_RETRIES) { + await sleep(); + + // retry + return addTagsToParameter(ssm, params, attempt + 1); + } + throw error; + } +}; + +const removeTagsFromParameter = async ( + ssm: AWS.SSM, + params: Omit, + attempt = 0 +): Promise => { + try { + return await ssm.removeTagsFromResource({ ...params, ResourceType: "Parameter" }).promise(); + } catch (error) { + if ((error as AWSError).code === "ThrottlingException" && attempt < MAX_RETRIES) { + await sleep(); + + // retry + return removeTagsFromParameter(ssm, params, attempt + 1); + } + throw error; + } +}; + const deleteParametersBatch = async ( ssm: AWS.SSM, parameters: AWS.SSM.Parameter[], @@ -132,35 +293,92 @@ const deleteParametersBatch = async ( export const AwsParameterStoreSyncFns = { syncSecrets: async (secretSync: TAwsParameterStoreSyncWithCredentials, secretMap: TSecretMap) => { - const { destinationConfig } = secretSync; + const { destinationConfig, syncOptions } = secretSync; const ssm = await getSSM(secretSync); - // TODO(scott): KMS Key ID, Tags - const awsParameterStoreSecretsRecord = await getParametersByPath(ssm, destinationConfig.path); - for await (const entry of Object.entries(secretMap)) { - const [key, { value }] = entry; + const awsParameterStoreMetadataRecord = await getParameterMetadataByPath(ssm, destinationConfig.path); - // skip empty values (not allowed by AWS) or secrets that haven't changed - if (!value || (key in awsParameterStoreSecretsRecord && awsParameterStoreSecretsRecord[key].Value === value)) { + const { shouldManageTags, awsParameterStoreTagsRecord } = await getParameterStoreTagsRecord( + ssm, + awsParameterStoreSecretsRecord, + Boolean(syncOptions.tags?.length || syncOptions.syncSecretMetadataAsTags) + ); + const syncTagsRecord = Object.fromEntries(syncOptions.tags?.map((tag) => [tag.key, tag.value]) ?? []); + + for await (const entry of Object.entries(secretMap)) { + const [key, { value, secretMetadata }] = entry; + + // skip empty values (not allowed by AWS) + if (!value) { // eslint-disable-next-line no-continue continue; } - try { - await putParameter(ssm, { - Name: `${destinationConfig.path}${key}`, - Type: "SecureString", - Value: value, - Overwrite: true - }); - } catch (error) { - throw new SecretSyncError({ - error, - secretKey: key + const keyId = syncOptions.keyId ?? "alias/aws/ssm"; + + // create parameter or update if changed + if ( + !(key in awsParameterStoreSecretsRecord) || + value !== awsParameterStoreSecretsRecord[key].Value || + keyId !== awsParameterStoreMetadataRecord[key]?.KeyId + ) { + try { + await putParameter(ssm, { + Name: `${destinationConfig.path}${key}`, + Type: "SecureString", + Value: value, + Overwrite: true, + KeyId: keyId + }); + } catch (error) { + throw new SecretSyncError({ + error, + secretKey: key + }); + } + } + + if (shouldManageTags) { + const { tagsToAdd, tagKeysToRemove } = processParameterTags({ + syncTagsRecord: { + // configured sync tags take preference over secret metadata + ...(syncOptions.syncSecretMetadataAsTags && + Object.fromEntries(secretMetadata?.map((tag) => [tag.key, tag.value]) ?? [])), + ...syncTagsRecord + }, + awsTagsRecord: awsParameterStoreTagsRecord[key] ?? {} }); + + if (tagsToAdd.length) { + try { + await addTagsToParameter(ssm, { + ResourceId: `${destinationConfig.path}${key}`, + Tags: tagsToAdd + }); + } catch (error) { + throw new SecretSyncError({ + error, + secretKey: key + }); + } + } + + if (tagKeysToRemove.length) { + try { + await removeTagsFromParameter(ssm, { + ResourceId: `${destinationConfig.path}${key}`, + TagKeys: tagKeysToRemove + }); + } catch (error) { + throw new SecretSyncError({ + error, + secretKey: key + }); + } + } } } diff --git a/backend/src/services/secret-sync/aws-parameter-store/aws-parameter-store-sync-schemas.ts b/backend/src/services/secret-sync/aws-parameter-store/aws-parameter-store-sync-schemas.ts index 8b0765388..44296c306 100644 --- a/backend/src/services/secret-sync/aws-parameter-store/aws-parameter-store-sync-schemas.ts +++ b/backend/src/services/secret-sync/aws-parameter-store/aws-parameter-store-sync-schemas.ts @@ -8,6 +8,7 @@ import { GenericCreateSecretSyncFieldsSchema, GenericUpdateSecretSyncFieldsSchema } from "@app/services/secret-sync/secret-sync-schemas"; +import { TSyncOptionsConfig } from "@app/services/secret-sync/secret-sync-types"; const AwsParameterStoreSyncDestinationConfigSchema = z.object({ region: z.nativeEnum(AWSRegion).describe(SecretSyncs.DESTINATION_CONFIG.AWS_PARAMETER_STORE.region), @@ -20,19 +21,68 @@ const AwsParameterStoreSyncDestinationConfigSchema = z.object({ .describe(SecretSyncs.DESTINATION_CONFIG.AWS_PARAMETER_STORE.path) }); -export const AwsParameterStoreSyncSchema = BaseSecretSyncSchema(SecretSync.AWSParameterStore).extend({ +const AwsParameterStoreSyncOptionsSchema = z.object({ + keyId: z + .string() + .regex(/^([a-zA-Z0-9:/_-]+)$/, "Invalid KMS Key ID") + .min(1, "Invalid KMS Key ID") + .max(256, "Invalid KMS Key ID") + .optional() + .describe(SecretSyncs.ADDITIONAL_SYNC_OPTIONS.AWS_PARAMETER_STORE.keyId), + tags: z + .object({ + key: z + .string() + .regex( + /^([\p{L}\p{Z}\p{N}_.:/=+\-@]*)$/u, + "Invalid resource tag key: keys can only contain Unicode letters, digits, white space and any of the following: _.:/=+@-" + ) + .min(1, "Resource tag key required") + .max(128, "Resource tag key cannot exceed 128 characters"), + value: z + .string() + .regex( + /^([\p{L}\p{Z}\p{N}_.:/=+\-@]*)$/u, + "Invalid resource tag value: tag values can only contain Unicode letters, digits, white space and any of the following: _.:/=+@-" + ) + .max(256, "Resource tag value cannot exceed 256 characters") + }) + .array() + .max(50) + .refine((items) => new Set(items.map((item) => item.key)).size === items.length, { + message: "Resource tag keys must be unique" + }) + .optional() + .describe(SecretSyncs.ADDITIONAL_SYNC_OPTIONS.AWS_PARAMETER_STORE.tags), + syncSecretMetadataAsTags: z + .boolean() + .optional() + .describe(SecretSyncs.ADDITIONAL_SYNC_OPTIONS.AWS_PARAMETER_STORE.syncSecretMetadataAsTags) +}); + +const AwsParameterStoreSyncOptionsConfig: TSyncOptionsConfig = { canImportSecrets: true }; + +export const AwsParameterStoreSyncSchema = BaseSecretSyncSchema( + SecretSync.AWSParameterStore, + AwsParameterStoreSyncOptionsConfig, + AwsParameterStoreSyncOptionsSchema +).extend({ destination: z.literal(SecretSync.AWSParameterStore), destinationConfig: AwsParameterStoreSyncDestinationConfigSchema }); export const CreateAwsParameterStoreSyncSchema = GenericCreateSecretSyncFieldsSchema( - SecretSync.AWSParameterStore + SecretSync.AWSParameterStore, + AwsParameterStoreSyncOptionsConfig, + AwsParameterStoreSyncOptionsSchema ).extend({ destinationConfig: AwsParameterStoreSyncDestinationConfigSchema }); export const UpdateAwsParameterStoreSyncSchema = GenericUpdateSecretSyncFieldsSchema( - SecretSync.AWSParameterStore + SecretSync.AWSParameterStore, + AwsParameterStoreSyncOptionsConfig, + AwsParameterStoreSyncOptionsSchema ).extend({ destinationConfig: AwsParameterStoreSyncDestinationConfigSchema.optional() }); diff --git a/backend/src/services/secret-sync/aws-secrets-manager/aws-secrets-manager-sync-fns.ts b/backend/src/services/secret-sync/aws-secrets-manager/aws-secrets-manager-sync-fns.ts index f81db073f..d4f272475 100644 --- a/backend/src/services/secret-sync/aws-secrets-manager/aws-secrets-manager-sync-fns.ts +++ b/backend/src/services/secret-sync/aws-secrets-manager/aws-secrets-manager-sync-fns.ts @@ -1,16 +1,28 @@ +import { UntagResourceCommandOutput } from "@aws-sdk/client-kms"; import { BatchGetSecretValueCommand, CreateSecretCommand, CreateSecretCommandInput, DeleteSecretCommand, DeleteSecretResponse, + DescribeSecretCommand, + DescribeSecretCommandInput, ListSecretsCommand, SecretsManagerClient, + TagResourceCommand, + TagResourceCommandOutput, + UntagResourceCommand, UpdateSecretCommand, UpdateSecretCommandInput } from "@aws-sdk/client-secrets-manager"; import { AWSError } from "aws-sdk"; -import { CreateSecretResponse, SecretListEntry, SecretValueEntry } from "aws-sdk/clients/secretsmanager"; +import { + CreateSecretResponse, + DescribeSecretResponse, + SecretListEntry, + SecretValueEntry, + Tag +} from "aws-sdk/clients/secretsmanager"; import { getAwsConnectionConfig } from "@app/services/app-connection/aws/aws-connection-fns"; import { AwsSecretsManagerSyncMappingBehavior } from "@app/services/secret-sync/aws-secrets-manager/aws-secrets-manager-sync-enums"; @@ -21,6 +33,7 @@ import { TAwsSecretsManagerSyncWithCredentials } from "./aws-secrets-manager-syn type TAwsSecretsRecord = Record; type TAwsSecretValuesRecord = Record; +type TAwsSecretDescriptionsRecord = Record; const MAX_RETRIES = 5; const BATCH_SIZE = 20; @@ -135,6 +148,46 @@ const getSecretValuesRecord = async ( return awsSecretValuesRecord; }; +const describeSecret = async ( + client: SecretsManagerClient, + input: DescribeSecretCommandInput, + attempt = 0 +): Promise => { + try { + return await client.send(new DescribeSecretCommand(input)); + } catch (error) { + if ((error as AWSError).code === "ThrottlingException" && attempt < MAX_RETRIES) { + await sleep(); + + // retry + return describeSecret(client, input, attempt + 1); + } + throw error; + } +}; + +const getSecretDescriptionsRecord = async ( + client: SecretsManagerClient, + awsSecretsRecord: TAwsSecretsRecord +): Promise => { + const awsSecretDescriptionsRecord: TAwsSecretValuesRecord = {}; + + for await (const secretKey of Object.keys(awsSecretsRecord)) { + try { + awsSecretDescriptionsRecord[secretKey] = await describeSecret(client, { + SecretId: secretKey + }); + } catch (error) { + throw new SecretSyncError({ + secretKey, + error + }); + } + } + + return awsSecretDescriptionsRecord; +}; + const createSecret = async ( client: SecretsManagerClient, input: CreateSecretCommandInput, @@ -189,9 +242,71 @@ const deleteSecret = async ( } }; +const addTags = async ( + client: SecretsManagerClient, + secretKey: string, + tags: Tag[], + attempt = 0 +): Promise => { + try { + return await client.send(new TagResourceCommand({ SecretId: secretKey, Tags: tags })); + } catch (error) { + if ((error as AWSError).code === "ThrottlingException" && attempt < MAX_RETRIES) { + await sleep(); + + // retry + return addTags(client, secretKey, tags, attempt + 1); + } + throw error; + } +}; + +const removeTags = async ( + client: SecretsManagerClient, + secretKey: string, + tagKeys: string[], + attempt = 0 +): Promise => { + try { + return await client.send(new UntagResourceCommand({ SecretId: secretKey, TagKeys: tagKeys })); + } catch (error) { + if ((error as AWSError).code === "ThrottlingException" && attempt < MAX_RETRIES) { + await sleep(); + + // retry + return removeTags(client, secretKey, tagKeys, attempt + 1); + } + throw error; + } +}; + +const processTags = ({ + syncTagsRecord, + awsTagsRecord +}: { + syncTagsRecord: Record; + awsTagsRecord: Record; +}) => { + const tagsToAdd: Tag[] = []; + const tagKeysToRemove: string[] = []; + + for (const syncEntry of Object.entries(syncTagsRecord)) { + const [syncKey, syncValue] = syncEntry; + + if (!(syncKey in awsTagsRecord) || syncValue !== awsTagsRecord[syncKey]) + tagsToAdd.push({ Key: syncKey, Value: syncValue }); + } + + for (const awsKey of Object.keys(awsTagsRecord)) { + if (!(awsKey in syncTagsRecord)) tagKeysToRemove.push(awsKey); + } + + return { tagsToAdd, tagKeysToRemove }; +}; + export const AwsSecretsManagerSyncFns = { syncSecrets: async (secretSync: TAwsSecretsManagerSyncWithCredentials, secretMap: TSecretMap) => { - const { destinationConfig } = secretSync; + const { destinationConfig, syncOptions } = secretSync; const client = await getSecretsManagerClient(secretSync); @@ -199,9 +314,15 @@ export const AwsSecretsManagerSyncFns = { const awsValuesRecord = await getSecretValuesRecord(client, awsSecretsRecord); + const awsDescriptionsRecord = await getSecretDescriptionsRecord(client, awsSecretsRecord); + + const syncTagsRecord = Object.fromEntries(syncOptions.tags?.map((tag) => [tag.key, tag.value]) ?? []); + + const keyId = syncOptions.keyId ?? "alias/aws/secretsmanager"; + if (destinationConfig.mappingBehavior === AwsSecretsManagerSyncMappingBehavior.OneToOne) { for await (const entry of Object.entries(secretMap)) { - const [key, { value }] = entry; + const [key, { value, secretMetadata }] = entry; // skip secrets that don't have a value set if (!value) { @@ -211,15 +332,26 @@ export const AwsSecretsManagerSyncFns = { if (awsSecretsRecord[key]) { // skip secrets that haven't changed - if (awsValuesRecord[key]?.SecretString === value) { - // eslint-disable-next-line no-continue - continue; + if (awsValuesRecord[key]?.SecretString !== value || keyId !== awsDescriptionsRecord[key]?.KmsKeyId) { + try { + await updateSecret(client, { + SecretId: key, + SecretString: value, + KmsKeyId: keyId + }); + } catch (error) { + throw new SecretSyncError({ + error, + secretKey: key + }); + } } - + } else { try { - await updateSecret(client, { - SecretId: key, - SecretString: value + await createSecret(client, { + Name: key, + SecretString: value, + KmsKeyId: keyId }); } catch (error) { throw new SecretSyncError({ @@ -227,12 +359,34 @@ export const AwsSecretsManagerSyncFns = { secretKey: key }); } - } else { + } + + const { tagsToAdd, tagKeysToRemove } = processTags({ + syncTagsRecord: { + // configured sync tags take preference over secret metadata + ...(syncOptions.syncSecretMetadataAsTags && + Object.fromEntries(secretMetadata?.map((tag) => [tag.key, tag.value]) ?? [])), + ...syncTagsRecord + }, + awsTagsRecord: Object.fromEntries( + awsDescriptionsRecord[key]?.Tags?.map((tag) => [tag.Key!, tag.Value!]) ?? [] + ) + }); + + if (tagsToAdd.length) { try { - await createSecret(client, { - Name: key, - SecretString: value + await addTags(client, key, tagsToAdd); + } catch (error) { + throw new SecretSyncError({ + error, + secretKey: key }); + } + } + + if (tagKeysToRemove.length) { + try { + await removeTags(client, key, tagKeysToRemove); } catch (error) { throw new SecretSyncError({ error, @@ -261,17 +415,48 @@ export const AwsSecretsManagerSyncFns = { Object.fromEntries(Object.entries(secretMap).map(([key, secretData]) => [key, secretData.value])) ); - if (awsValuesRecord[destinationConfig.secretName]) { + if (awsSecretsRecord[destinationConfig.secretName]) { await updateSecret(client, { SecretId: destinationConfig.secretName, - SecretString: secretValue + SecretString: secretValue, + KmsKeyId: keyId }); } else { await createSecret(client, { Name: destinationConfig.secretName, - SecretString: secretValue + SecretString: secretValue, + KmsKeyId: keyId }); } + + const { tagsToAdd, tagKeysToRemove } = processTags({ + syncTagsRecord, + awsTagsRecord: Object.fromEntries( + awsDescriptionsRecord[destinationConfig.secretName]?.Tags?.map((tag) => [tag.Key!, tag.Value!]) ?? [] + ) + }); + + if (tagsToAdd.length) { + try { + await addTags(client, destinationConfig.secretName, tagsToAdd); + } catch (error) { + throw new SecretSyncError({ + error, + secretKey: destinationConfig.secretName + }); + } + } + + if (tagKeysToRemove.length) { + try { + await removeTags(client, destinationConfig.secretName, tagKeysToRemove); + } catch (error) { + throw new SecretSyncError({ + error, + secretKey: destinationConfig.secretName + }); + } + } } }, getSecrets: async (secretSync: TAwsSecretsManagerSyncWithCredentials): Promise => { diff --git a/backend/src/services/secret-sync/aws-secrets-manager/aws-secrets-manager-sync-schemas.ts b/backend/src/services/secret-sync/aws-secrets-manager/aws-secrets-manager-sync-schemas.ts index 6de014502..5e8ce2bad 100644 --- a/backend/src/services/secret-sync/aws-secrets-manager/aws-secrets-manager-sync-schemas.ts +++ b/backend/src/services/secret-sync/aws-secrets-manager/aws-secrets-manager-sync-schemas.ts @@ -9,6 +9,7 @@ import { GenericCreateSecretSyncFieldsSchema, GenericUpdateSecretSyncFieldsSchema } from "@app/services/secret-sync/secret-sync-schemas"; +import { TSyncOptionsConfig } from "@app/services/secret-sync/secret-sync-types"; const AwsSecretsManagerSyncDestinationConfigSchema = z .discriminatedUnion("mappingBehavior", [ @@ -38,22 +39,95 @@ const AwsSecretsManagerSyncDestinationConfigSchema = z }) ); -export const AwsSecretsManagerSyncSchema = BaseSecretSyncSchema(SecretSync.AWSSecretsManager).extend({ +const AwsSecretsManagerSyncOptionsSchema = z.object({ + keyId: z + .string() + .regex(/^([a-zA-Z0-9:/_-]+)$/, "Invalid KMS Key ID") + .min(1, "Invalid KMS Key ID") + .max(256, "Invalid KMS Key ID") + .optional() + .describe(SecretSyncs.ADDITIONAL_SYNC_OPTIONS.AWS_SECRETS_MANAGER.keyId), + tags: z + .object({ + key: z + .string() + .regex( + /^([\p{L}\p{Z}\p{N}_.:/=+\-@]*)$/u, + "Invalid tag key: keys can only contain Unicode letters, digits, white space and any of the following: _.:/=+@-" + ) + .min(1, "Tag key required") + .max(128, "Tag key cannot exceed 128 characters"), + value: z + .string() + .regex( + /^([\p{L}\p{Z}\p{N}_.:/=+\-@]*)$/u, + "Invalid tag value: tag values can only contain Unicode letters, digits, white space and any of the following: _.:/=+@-" + ) + .max(256, "Tag value cannot exceed 256 characters") + }) + .array() + .max(50) + .refine((items) => new Set(items.map((item) => item.key)).size === items.length, { + message: "Tag keys must be unique" + }) + .optional() + .describe(SecretSyncs.ADDITIONAL_SYNC_OPTIONS.AWS_SECRETS_MANAGER.tags), + syncSecretMetadataAsTags: z + .boolean() + .optional() + .describe(SecretSyncs.ADDITIONAL_SYNC_OPTIONS.AWS_SECRETS_MANAGER.syncSecretMetadataAsTags) +}); + +const AwsSecretsManagerSyncOptionsConfig: TSyncOptionsConfig = { canImportSecrets: true }; + +export const AwsSecretsManagerSyncSchema = BaseSecretSyncSchema( + SecretSync.AWSSecretsManager, + AwsSecretsManagerSyncOptionsConfig, + AwsSecretsManagerSyncOptionsSchema +).extend({ destination: z.literal(SecretSync.AWSSecretsManager), destinationConfig: AwsSecretsManagerSyncDestinationConfigSchema }); export const CreateAwsSecretsManagerSyncSchema = GenericCreateSecretSyncFieldsSchema( - SecretSync.AWSSecretsManager -).extend({ - destinationConfig: AwsSecretsManagerSyncDestinationConfigSchema -}); + SecretSync.AWSSecretsManager, + AwsSecretsManagerSyncOptionsConfig, + AwsSecretsManagerSyncOptionsSchema +) + .extend({ + destinationConfig: AwsSecretsManagerSyncDestinationConfigSchema + }) + .superRefine((sync, ctx) => { + if ( + sync.destinationConfig.mappingBehavior === AwsSecretsManagerSyncMappingBehavior.ManyToOne && + sync.syncOptions.syncSecretMetadataAsTags + ) { + ctx.addIssue({ + code: z.ZodIssueCode.custom, + message: 'Syncing secret metadata is not supported with "Many-to-One" mapping behavior.' + }); + } + }); export const UpdateAwsSecretsManagerSyncSchema = GenericUpdateSecretSyncFieldsSchema( - SecretSync.AWSSecretsManager -).extend({ - destinationConfig: AwsSecretsManagerSyncDestinationConfigSchema.optional() -}); + SecretSync.AWSSecretsManager, + AwsSecretsManagerSyncOptionsConfig, + AwsSecretsManagerSyncOptionsSchema +) + .extend({ + destinationConfig: AwsSecretsManagerSyncDestinationConfigSchema.optional() + }) + .superRefine((sync, ctx) => { + if ( + sync.destinationConfig?.mappingBehavior === AwsSecretsManagerSyncMappingBehavior.ManyToOne && + sync.syncOptions.syncSecretMetadataAsTags + ) { + ctx.addIssue({ + code: z.ZodIssueCode.custom, + message: 'Syncing secret metadata is not supported with "Many-to-One" mapping behavior.' + }); + } + }); export const AwsSecretsManagerSyncListItemSchema = z.object({ name: z.literal("AWS Secrets Manager"), diff --git a/backend/src/services/secret-sync/secret-sync-queue.ts b/backend/src/services/secret-sync/secret-sync-queue.ts index 808930b15..cdc9540da 100644 --- a/backend/src/services/secret-sync/secret-sync-queue.ts +++ b/backend/src/services/secret-sync/secret-sync-queue.ts @@ -233,6 +233,7 @@ export const secretSyncQueueFactory = ({ } secretMap[secretKey].skipMultilineEncoding = Boolean(secret.skipMultilineEncoding); + secretMap[secretKey].secretMetadata = secret.secretMetadata; }) ); @@ -258,7 +259,8 @@ export const secretSyncQueueFactory = ({ secretMap[importedSecret.key] = { skipMultilineEncoding: importedSecret.skipMultilineEncoding, comment: importedSecret.secretComment, - value: importedSecret.secretValue || "" + value: importedSecret.secretValue || "", + secretMetadata: importedSecret.secretMetadata }; } } diff --git a/backend/src/services/secret-sync/secret-sync-schemas.ts b/backend/src/services/secret-sync/secret-sync-schemas.ts index 89bdc4375..8ea3a4f0b 100644 --- a/backend/src/services/secret-sync/secret-sync-schemas.ts +++ b/backend/src/services/secret-sync/secret-sync-schemas.ts @@ -1,4 +1,4 @@ -import { z } from "zod"; +import { AnyZodObject, z } from "zod"; import { SecretSyncsSchema } from "@app/db/schemas/secret-syncs"; import { SecretSyncs } from "@app/lib/api-docs"; @@ -8,34 +8,45 @@ import { SecretSync, SecretSyncInitialSyncBehavior } from "@app/services/secret- import { SECRET_SYNC_CONNECTION_MAP } from "@app/services/secret-sync/secret-sync-maps"; import { TSyncOptionsConfig } from "@app/services/secret-sync/secret-sync-types"; -const SyncOptionsSchema = (secretSync: SecretSync, options: TSyncOptionsConfig = { canImportSecrets: true }) => - z.object({ - initialSyncBehavior: (options.canImportSecrets +const BaseSyncOptionsSchema = ({ + destination, + syncOptionsConfig: { canImportSecrets }, + merge, + isUpdateSchema +}: { + destination: SecretSync; + syncOptionsConfig: TSyncOptionsConfig; + merge?: T; + isUpdateSchema?: boolean; +}) => { + const baseSchema = z.object({ + initialSyncBehavior: (canImportSecrets ? z.nativeEnum(SecretSyncInitialSyncBehavior) : z.literal(SecretSyncInitialSyncBehavior.OverwriteDestination) - ).describe(SecretSyncs.SYNC_OPTIONS(secretSync).initialSyncBehavior) - // prependPrefix: z - // .string() - // .trim() - // .transform((str) => str.toUpperCase()) - // .optional() - // .describe(SecretSyncs.SYNC_OPTIONS(secretSync).PREPEND_PREFIX), - // appendSuffix: z - // .string() - // .trim() - // .transform((str) => str.toUpperCase()) - // .optional() - // .describe(SecretSyncs.SYNC_OPTIONS(secretSync).APPEND_SUFFIX) + ).describe(SecretSyncs.SYNC_OPTIONS(destination).initialSyncBehavior) }); -export const BaseSecretSyncSchema = (destination: SecretSync, syncOptionsConfig?: TSyncOptionsConfig) => + const schema = merge ? baseSchema.merge(merge) : baseSchema; + + return ( + isUpdateSchema + ? schema.describe(SecretSyncs.UPDATE(destination).syncOptions).optional() + : schema.describe(SecretSyncs.CREATE(destination).syncOptions) + ) as T extends AnyZodObject ? z.ZodObject> : typeof schema; +}; + +export const BaseSecretSyncSchema = ( + destination: SecretSync, + syncOptionsConfig: TSyncOptionsConfig, + merge?: T +) => SecretSyncsSchema.omit({ destination: true, destinationConfig: true, syncOptions: true }).extend({ // destination needs to be on the extended object for type differentiation - syncOptions: SyncOptionsSchema(destination, syncOptionsConfig), + syncOptions: BaseSyncOptionsSchema({ destination, syncOptionsConfig, merge }), // join properties projectId: z.string(), connection: z.object({ @@ -47,7 +58,11 @@ export const BaseSecretSyncSchema = (destination: SecretSync, syncOptionsConfig? folder: z.object({ id: z.string(), path: z.string() }).nullable() }); -export const GenericCreateSecretSyncFieldsSchema = (destination: SecretSync, syncOptionsConfig?: TSyncOptionsConfig) => +export const GenericCreateSecretSyncFieldsSchema = ( + destination: SecretSync, + syncOptionsConfig: TSyncOptionsConfig, + merge?: T +) => z.object({ name: slugSchema({ field: "name" }).describe(SecretSyncs.CREATE(destination).name), projectId: z.string().trim().min(1, "Project ID required").describe(SecretSyncs.CREATE(destination).projectId), @@ -66,10 +81,14 @@ export const GenericCreateSecretSyncFieldsSchema = (destination: SecretSync, syn .transform(removeTrailingSlash) .describe(SecretSyncs.CREATE(destination).secretPath), isAutoSyncEnabled: z.boolean().default(true).describe(SecretSyncs.CREATE(destination).isAutoSyncEnabled), - syncOptions: SyncOptionsSchema(destination, syncOptionsConfig).describe(SecretSyncs.CREATE(destination).syncOptions) + syncOptions: BaseSyncOptionsSchema({ destination, syncOptionsConfig, merge }) }); -export const GenericUpdateSecretSyncFieldsSchema = (destination: SecretSync, syncOptionsConfig?: TSyncOptionsConfig) => +export const GenericUpdateSecretSyncFieldsSchema = ( + destination: SecretSync, + syncOptionsConfig: TSyncOptionsConfig, + merge?: T +) => z.object({ name: slugSchema({ field: "name" }).describe(SecretSyncs.UPDATE(destination).name).optional(), connectionId: z.string().uuid().describe(SecretSyncs.UPDATE(destination).connectionId).optional(), @@ -90,7 +109,5 @@ export const GenericUpdateSecretSyncFieldsSchema = (destination: SecretSync, syn .optional() .describe(SecretSyncs.UPDATE(destination).secretPath), isAutoSyncEnabled: z.boolean().optional().describe(SecretSyncs.UPDATE(destination).isAutoSyncEnabled), - syncOptions: SyncOptionsSchema(destination, syncOptionsConfig) - .optional() - .describe(SecretSyncs.UPDATE(destination).syncOptions) + syncOptions: BaseSyncOptionsSchema({ destination, syncOptionsConfig, merge, isUpdateSchema: true }) }); diff --git a/backend/src/services/secret-sync/secret-sync-types.ts b/backend/src/services/secret-sync/secret-sync-types.ts index 2c6d3a830..60742e797 100644 --- a/backend/src/services/secret-sync/secret-sync-types.ts +++ b/backend/src/services/secret-sync/secret-sync-types.ts @@ -2,6 +2,7 @@ import { Job } from "bullmq"; import { TCreateAuditLogDTO } from "@app/ee/services/audit-log/audit-log-types"; import { QueueJobs } from "@app/queue"; +import { ResourceMetadataDTO } from "@app/services/resource-metadata/resource-metadata-schema"; import { TAwsSecretsManagerSync, TAwsSecretsManagerSyncInput, @@ -197,5 +198,10 @@ export type TSendSecretSyncFailedNotificationsJobDTO = Job< export type TSecretMap = Record< string, - { value: string; comment?: string; skipMultilineEncoding?: boolean | null | undefined } + { + value: string; + comment?: string; + skipMultilineEncoding?: boolean | null | undefined; + secretMetadata?: ResourceMetadataDTO; + } >; diff --git a/docs/images/app-connections/aws/kms-key-user.png b/docs/images/app-connections/aws/kms-key-user.png new file mode 100644 index 000000000..c94edea67 Binary files /dev/null and b/docs/images/app-connections/aws/kms-key-user.png differ diff --git a/docs/images/app-connections/aws/parameter-store-permissions.png b/docs/images/app-connections/aws/parameter-store-permissions.png index 1fb2b8118..0c5191e37 100644 Binary files a/docs/images/app-connections/aws/parameter-store-permissions.png and b/docs/images/app-connections/aws/parameter-store-permissions.png differ diff --git a/docs/images/app-connections/aws/secrets-manager-permissions.png b/docs/images/app-connections/aws/secrets-manager-permissions.png index 57d2eb2e2..6c60d9b83 100644 Binary files a/docs/images/app-connections/aws/secrets-manager-permissions.png and b/docs/images/app-connections/aws/secrets-manager-permissions.png differ diff --git a/docs/images/secret-syncs/aws-parameter-store/aws-parameter-store-options.png b/docs/images/secret-syncs/aws-parameter-store/aws-parameter-store-options.png index 11923e5a2..6a4a68f2c 100644 Binary files a/docs/images/secret-syncs/aws-parameter-store/aws-parameter-store-options.png and b/docs/images/secret-syncs/aws-parameter-store/aws-parameter-store-options.png differ diff --git a/docs/images/secret-syncs/aws-secrets-manager/aws-secrets-manager-options.png b/docs/images/secret-syncs/aws-secrets-manager/aws-secrets-manager-options.png index abd2c0ad1..89ec35e4d 100644 Binary files a/docs/images/secret-syncs/aws-secrets-manager/aws-secrets-manager-options.png and b/docs/images/secret-syncs/aws-secrets-manager/aws-secrets-manager-options.png differ diff --git a/docs/integrations/app-connections/aws.mdx b/docs/integrations/app-connections/aws.mdx index 27b2ff623..cb8a5bce0 100644 --- a/docs/integrations/app-connections/aws.mdx +++ b/docs/integrations/app-connections/aws.mdx @@ -82,22 +82,26 @@ Infisical supports two methods for connecting to AWS. "Sid": "AllowSecretsManagerAccess", "Effect": "Allow", "Action": [ - "secretsmanager:GetSecretValue", - "secretsmanager:CreateSecret", - "secretsmanager:UpdateSecret", - "secretsmanager:DescribeSecret", - "secretsmanager:TagResource", - "secretsmanager:UntagResource", - "kms:ListKeys", - "kms:ListAliases", - "kms:Encrypt", - "kms:Decrypt" + "secretsmanager:ListSecrets", + "secretsmanager:GetSecretValue", + "secretsmanager:BatchGetSecretValue", + "secretsmanager:CreateSecret", + "secretsmanager:UpdateSecret", + "secretsmanager:DeleteSecret", + "secretsmanager:DescribeSecret", + "secretsmanager:TagResource", + "secretsmanager:UntagResource", + "kms:ListAliases", // if you need to specify the KMS key + "kms:Encrypt", // if you need to specify the KMS key + "kms:Decrypt", // if you need to specify the KMS key + "kms:DescribeKey" // if you need to specify the KMS key ], "Resource": "*" } ] } ``` + If using a custom KMS key, be sure to add the IAM user or role as a key user. ![KMS Key IAM Role User](/images/app-connections/aws/kms-key-user.png) Use the following custom policy to grant the minimum permissions required by Infisical to sync secrets to AWS Parameter Store: @@ -112,23 +116,25 @@ Infisical supports two methods for connecting to AWS. "Sid": "AllowSSMAccess", "Effect": "Allow", "Action": [ - "ssm:PutParameter", - "ssm:DeleteParameter", - "ssm:GetParameters", - "ssm:GetParametersByPath", - "ssm:DescribeParameters", - "ssm:DeleteParameters", - "ssm:AddTagsToResource", // if you need to add tags to secrets - "kms:ListKeys", // if you need to specify the KMS key - "kms:ListAliases", // if you need to specify the KMS key - "kms:Encrypt", // if you need to specify the KMS key - "kms:Decrypt" // if you need to specify the KMS key + "ssm:PutParameter", + "ssm:GetParameters", + "ssm:GetParametersByPath", + "ssm:DescribeParameters", + "ssm:DeleteParameters", + "ssm:ListTagsForResource", // if you need to add tags to secrets + "ssm:AddTagsToResource", // if you need to add tags to secrets + "ssm:RemoveTagsFromResource", // if you need to add tags to secrets + "kms:ListAliases", // if you need to specify the KMS key + "kms:Encrypt", // if you need to specify the KMS key + "kms:Decrypt", // if you need to specify the KMS key + "kms:DescribeKey" // if you need to specify the KMS key ], "Resource": "*" } ] } ``` + If using a custom KMS key, be sure to add the IAM user or role as a key user. ![KMS Key IAM Role User](/images/app-connections/aws/kms-key-user.png) @@ -223,22 +229,26 @@ Infisical supports two methods for connecting to AWS. "Sid": "AllowSecretsManagerAccess", "Effect": "Allow", "Action": [ - "secretsmanager:GetSecretValue", - "secretsmanager:CreateSecret", - "secretsmanager:UpdateSecret", - "secretsmanager:DescribeSecret", - "secretsmanager:TagResource", - "secretsmanager:UntagResource", - "kms:ListKeys", - "kms:ListAliases", - "kms:Encrypt", - "kms:Decrypt" + "secretsmanager:ListSecrets", + "secretsmanager:GetSecretValue", + "secretsmanager:BatchGetSecretValue", + "secretsmanager:CreateSecret", + "secretsmanager:UpdateSecret", + "secretsmanager:DeleteSecret", + "secretsmanager:DescribeSecret", + "secretsmanager:TagResource", + "secretsmanager:UntagResource", + "kms:ListAliases", // if you need to specify the KMS key + "kms:Encrypt", // if you need to specify the KMS key + "kms:Decrypt", // if you need to specify the KMS key + "kms:DescribeKey" // if you need to specify the KMS key ], "Resource": "*" } ] } ``` + If using a custom KMS key, be sure to add the IAM user or role as a key user. ![KMS Key IAM Role User](/images/app-connections/aws/kms-key-user.png) Use the following custom policy to grant the minimum permissions required by Infisical to sync secrets to AWS Parameter Store: @@ -253,23 +263,25 @@ Infisical supports two methods for connecting to AWS. "Sid": "AllowSSMAccess", "Effect": "Allow", "Action": [ - "ssm:PutParameter", - "ssm:DeleteParameter", - "ssm:GetParameters", - "ssm:GetParametersByPath", - "ssm:DescribeParameters", - "ssm:DeleteParameters", - "ssm:AddTagsToResource", // if you need to add tags to secrets - "kms:ListKeys", // if you need to specify the KMS key - "kms:ListAliases", // if you need to specify the KMS key - "kms:Encrypt", // if you need to specify the KMS key - "kms:Decrypt" // if you need to specify the KMS key + "ssm:PutParameter", + "ssm:GetParameters", + "ssm:GetParametersByPath", + "ssm:DescribeParameters", + "ssm:DeleteParameters", + "ssm:ListTagsForResource", // if you need to add tags to secrets + "ssm:AddTagsToResource", // if you need to add tags to secrets + "ssm:RemoveTagsFromResource", // if you need to add tags to secrets + "kms:ListAliases", // if you need to specify the KMS key + "kms:Encrypt", // if you need to specify the KMS key + "kms:Decrypt", // if you need to specify the KMS key + "kms:DescribeKey" // if you need to specify the KMS key ], "Resource": "*" } ] } ``` + If using a custom KMS key, be sure to add the IAM user or role as a key user. ![KMS Key IAM Role User](/images/app-connections/aws/kms-key-user.png) diff --git a/docs/integrations/secret-syncs/aws-parameter-store.mdx b/docs/integrations/secret-syncs/aws-parameter-store.mdx index 4b41094b4..165998841 100644 --- a/docs/integrations/secret-syncs/aws-parameter-store.mdx +++ b/docs/integrations/secret-syncs/aws-parameter-store.mdx @@ -40,6 +40,10 @@ description: "Learn how to configure an AWS Parameter Store Sync for Infisical." - **Overwrite Destination Secrets**: Removes any secrets at the destination endpoint not present in Infisical. - **Import Secrets (Prioritize Infisical)**: Imports secrets from the destination endpoint before syncing, prioritizing values from Infisical over Parameter Store when keys conflict. - **Import Secrets (Prioritize AWS Parameter Store)**: Imports secrets from the destination endpoint before syncing, prioritizing values from Parameter Store over Infisical when keys conflict. + - **KMS Key**: The AWS KMS key ID or alias to encrypt parameters with. + - **Tags**: Optional resource tags to add to parameters synced by Infisical. + - **Sync Secret Metadata as Resource Tags**: If enabled, metadata attached to secrets will be added as resource tags to parameters synced by Infisical. + Manually configured tags from the **Tags** field will take precedence over secret metadata when tag keys 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 Parameter Store Sync, then click **Next**. diff --git a/docs/integrations/secret-syncs/aws-secrets-manager.mdx b/docs/integrations/secret-syncs/aws-secrets-manager.mdx index 50fa75828..b8df0e8b3 100644 --- a/docs/integrations/secret-syncs/aws-secrets-manager.mdx +++ b/docs/integrations/secret-syncs/aws-secrets-manager.mdx @@ -43,6 +43,9 @@ description: "Learn how to configure an AWS Secrets Manager Sync for Infisical." - **Overwrite Destination Secrets**: Removes any secrets at the destination endpoint not present in Infisical. - **Import Secrets (Prioritize Infisical)**: Imports secrets from the destination endpoint before syncing, prioritizing values from Infisical over Secrets Manager when keys conflict. - **Import Secrets (Prioritize AWS Secrets Manager)**: Imports secrets from the destination endpoint before syncing, prioritizing values from Secrets Manager over Infisical when keys conflict. + - **KMS Key**: The AWS KMS key ID or alias to encrypt secrets with. + - **Tags**: Optional tags to add to secrets synced by Infisical. + - **Sync Secret Metadata as Tags**: If enabled, metadata attached to secrets will be added as tags to secrets synced by Infisical. - **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 Secrets Manager Sync, then click **Next**. diff --git a/docs/integrations/secret-syncs/overview.mdx b/docs/integrations/secret-syncs/overview.mdx index cc93d8036..bbe333efc 100644 --- a/docs/integrations/secret-syncs/overview.mdx +++ b/docs/integrations/secret-syncs/overview.mdx @@ -77,6 +77,13 @@ via the UI or API for the third-party service you intend to sync secrets to. - Destination: The App Connection to utilize and the destination endpoint to deploy secrets to. These can vary between services. - Options: Customize how secrets should be synced. Examples include adding a suffix or prefix to your secrets, or importing secrets from the destination on the initial sync. + + Secret Syncs are the source of truth for connected third-party services. Any secret, + including associated data, not present or imported in Infisical before syncing will be + overwritten, and changes made directly in the connected service outside of infisical may also + be overwritten by future syncs. + + Some third-party services do not support importing secrets. diff --git a/frontend/src/components/secret-syncs/forms/CreateSecretSyncForm.tsx b/frontend/src/components/secret-syncs/forms/CreateSecretSyncForm.tsx index e7721f113..cd395a400 100644 --- a/frontend/src/components/secret-syncs/forms/CreateSecretSyncForm.tsx +++ b/frontend/src/components/secret-syncs/forms/CreateSecretSyncForm.tsx @@ -1,11 +1,13 @@ import { useState } from "react"; import { Controller, FormProvider, useForm } from "react-hook-form"; +import { faInfoCircle } from "@fortawesome/free-solid-svg-icons"; +import { FontAwesomeIcon } from "@fortawesome/react-fontawesome"; import { Tab } from "@headlessui/react"; import { zodResolver } from "@hookform/resolvers/zod"; import { twMerge } from "tailwind-merge"; import { createNotification } from "@app/components/notifications"; -import { Button, Checkbox, FormControl, Switch } from "@app/components/v2"; +import { Button, FormControl, Switch } from "@app/components/v2"; import { useWorkspace } from "@app/context"; import { SECRET_SYNC_MAP } from "@app/helpers/secretSyncs"; import { @@ -16,10 +18,10 @@ import { useSecretSyncOption } from "@app/hooks/api/secretSyncs"; +import { SecretSyncOptionsFields } from "./SecretSyncOptionsFields/SecretSyncOptionsFields"; import { SecretSyncFormSchema, TSecretSyncForm } from "./schemas"; import { SecretSyncDestinationFields } from "./SecretSyncDestinationFields"; import { SecretSyncDetailsFields } from "./SecretSyncDetailsFields"; -import { SecretSyncOptionsFields } from "./SecretSyncOptionsFields"; import { SecretSyncReviewFields } from "./SecretSyncReviewFields"; import { SecretSyncSourceFields } from "./SecretSyncSourceFields"; @@ -32,7 +34,7 @@ type Props = { const FORM_TABS: { name: string; key: string; fields: (keyof TSecretSyncForm)[] }[] = [ { name: "Source", key: "source", fields: ["secretPath", "environment"] }, { name: "Destination", key: "destination", fields: ["connection", "destinationConfig"] }, - { name: "Options", key: "options", fields: ["syncOptions"] }, + { name: "Sync Options", key: "options", fields: ["syncOptions"] }, { name: "Details", key: "details", fields: ["name", "description"] }, { name: "Review", key: "review", fields: [] } ]; @@ -42,8 +44,9 @@ export const CreateSecretSyncForm = ({ destination, onComplete, onCancel }: Prop const { currentWorkspace } = useWorkspace(); const { name: destinationName } = SECRET_SYNC_MAP[destination]; + const [showConfirmation, setShowConfirmation] = useState(false); + const [selectedTabIndex, setSelectedTabIndex] = useState(0); - const [confirmOverwrite, setConfirmOverwrite] = useState(false); const { syncOption } = useSecretSyncOption(destination); @@ -77,6 +80,7 @@ export const CreateSecretSyncForm = ({ destination, onComplete, onCancel }: Prop onComplete(secretSync); } catch (err: any) { console.error(err); + setShowConfirmation(false); createNotification({ title: `Failed to add ${destinationName} Sync`, text: err.message, @@ -94,7 +98,7 @@ export const CreateSecretSyncForm = ({ destination, onComplete, onCancel }: Prop setSelectedTabIndex((prev) => prev - 1); }; - const { handleSubmit, trigger, watch, control } = formMethods; + const { handleSubmit, trigger, control } = formMethods; const isStepValid = async (index: number) => trigger(FORM_TABS[index].fields); @@ -102,7 +106,7 @@ export const CreateSecretSyncForm = ({ destination, onComplete, onCancel }: Prop const handleNext = async () => { if (isFinalStep) { - handleSubmit(onSubmit)(); + setShowConfirmation(true); return; } @@ -123,7 +127,42 @@ export const CreateSecretSyncForm = ({ destination, onComplete, onCancel }: Prop return isEnabled; }; - const initialSyncBehavior = watch("syncOptions.initialSyncBehavior"); + if (showConfirmation) + return ( + <> +
+
+ + Secret Sync Behavior +
+

+ Secret Syncs are the source of truth for connected third-party services. Any secret, + including associated data, not present or imported in Infisical before syncing will be + overwritten, and changes made directly in the connected service outside of infisical may + also be overwritten by future syncs. +

+
+
+ + + +
+ + ); return (
@@ -174,7 +213,7 @@ export const CreateSecretSyncForm = ({ destination, onComplete, onCancel }: Prop errorText={error?.message} > - {isFinalStep && - initialSyncBehavior === SecretSyncInitialSyncBehavior.OverwriteDestination && ( - setConfirmOverwrite(Boolean(isChecked))} - > -

- I understand all secrets present in the configured {destinationName} destination will - be removed if they are not present within Infisical. -

-
- )} +
- {selectedTabIndex > 0 && ( diff --git a/frontend/src/components/secret-syncs/forms/EditSecretSyncForm.tsx b/frontend/src/components/secret-syncs/forms/EditSecretSyncForm.tsx index 6d15e8007..25d992735 100644 --- a/frontend/src/components/secret-syncs/forms/EditSecretSyncForm.tsx +++ b/frontend/src/components/secret-syncs/forms/EditSecretSyncForm.tsx @@ -8,10 +8,10 @@ import { Button, ModalClose } from "@app/components/v2"; import { SECRET_SYNC_MAP } from "@app/helpers/secretSyncs"; import { TSecretSync, useUpdateSecretSync } from "@app/hooks/api/secretSyncs"; +import { SecretSyncOptionsFields } from "./SecretSyncOptionsFields/SecretSyncOptionsFields"; import { TSecretSyncForm, UpdateSecretSyncFormSchema } from "./schemas"; import { SecretSyncDestinationFields } from "./SecretSyncDestinationFields"; import { SecretSyncDetailsFields } from "./SecretSyncDetailsFields"; -import { SecretSyncOptionsFields } from "./SecretSyncOptionsFields"; import { SecretSyncSourceFields } from "./SecretSyncSourceFields"; type Props = { diff --git a/frontend/src/components/secret-syncs/forms/SecretSyncDestinationFields/AwsParameterStoreSyncFields.tsx b/frontend/src/components/secret-syncs/forms/SecretSyncDestinationFields/AwsParameterStoreSyncFields.tsx index 09076ea9c..47975ca37 100644 --- a/frontend/src/components/secret-syncs/forms/SecretSyncDestinationFields/AwsParameterStoreSyncFields.tsx +++ b/frontend/src/components/secret-syncs/forms/SecretSyncDestinationFields/AwsParameterStoreSyncFields.tsx @@ -8,13 +8,17 @@ import { TSecretSyncForm } from "../schemas"; import { AwsRegionSelect } from "./shared"; export const AwsParameterStoreSyncFields = () => { - const { control } = useFormContext< + const { control, setValue } = useFormContext< TSecretSyncForm & { destination: SecretSync.AWSParameterStore } >(); return ( <> - + { + setValue("syncOptions.keyId", undefined); + }} + /> ( diff --git a/frontend/src/components/secret-syncs/forms/SecretSyncDestinationFields/AwsSecretsManagerSyncFields.tsx b/frontend/src/components/secret-syncs/forms/SecretSyncDestinationFields/AwsSecretsManagerSyncFields.tsx index d971f4899..fe1a4c287 100644 --- a/frontend/src/components/secret-syncs/forms/SecretSyncDestinationFields/AwsSecretsManagerSyncFields.tsx +++ b/frontend/src/components/secret-syncs/forms/SecretSyncDestinationFields/AwsSecretsManagerSyncFields.tsx @@ -9,7 +9,7 @@ import { TSecretSyncForm } from "../schemas"; import { AwsRegionSelect } from "./shared"; export const AwsSecretsManagerSyncFields = () => { - const { control, watch } = useFormContext< + const { control, watch, setValue } = useFormContext< TSecretSyncForm & { destination: SecretSync.AWSSecretsManager } >(); @@ -59,7 +59,10 @@ export const AwsSecretsManagerSyncFields = () => { > + + )} + /> +
+
+ {i === 0 && ( + + )} + ( + + + + )} + /> +
+ + tagFields.remove(i)} + > + + + + + ))} + +
+ +
+ ( + + +

+ Sync Secret Metadata as Resource Tags{" "} + +

+ If enabled, metadata attached to secrets will be added as resource tags to + parameters synced by Infisical. +

+

+ Manually configured tags from the field above will take precedence over + secret metadata when tag keys conflict. +

+ + } + > + + +

+
+
+ )} + /> + + ); +}; diff --git a/frontend/src/components/secret-syncs/forms/SecretSyncOptionsFields/AwsSecretsManagerSyncOptionsFields.tsx b/frontend/src/components/secret-syncs/forms/SecretSyncOptionsFields/AwsSecretsManagerSyncOptionsFields.tsx new file mode 100644 index 000000000..5e4768dda --- /dev/null +++ b/frontend/src/components/secret-syncs/forms/SecretSyncOptionsFields/AwsSecretsManagerSyncOptionsFields.tsx @@ -0,0 +1,208 @@ +import { Fragment } from "react"; +import { Controller, useFieldArray, useFormContext, useWatch } from "react-hook-form"; +import { SingleValue } from "react-select"; +import { faPlus, faQuestionCircle, faTrash } from "@fortawesome/free-solid-svg-icons"; +import { FontAwesomeIcon } from "@fortawesome/react-fontawesome"; + +import { + Button, + FilterableSelect, + FormControl, + FormLabel, + IconButton, + Input, + Switch, + Tooltip +} from "@app/components/v2"; +import { + TAwsConnectionKmsKey, + useListAwsConnectionKmsKeys +} from "@app/hooks/api/appConnections/aws"; +import { SecretSync } from "@app/hooks/api/secretSyncs"; +import { AwsSecretsManagerSyncMappingBehavior } from "@app/hooks/api/secretSyncs/types/aws-secrets-manager-sync"; + +import { TSecretSyncForm } from "../schemas"; + +export const AwsSecretsManagerSyncOptionsFields = () => { + const { control, watch } = useFormContext< + TSecretSyncForm & { destination: SecretSync.AWSSecretsManager } + >(); + + const region = watch("destinationConfig.region"); + const connectionId = useWatch({ name: "connection.id", control }); + const mappingBehavior = watch("destinationConfig.mappingBehavior"); + + const { data: kmsKeys = [], isPending: isKmsKeysPending } = useListAwsConnectionKmsKeys( + { + connectionId, + region, + destination: SecretSync.AWSSecretsManager + }, + { enabled: Boolean(connectionId && region) } + ); + + const tagFields = useFieldArray({ + control, + name: "syncOptions.tags" + }); + + return ( + <> + ( + + org.alias === value) ?? null} + onChange={(option) => + onChange((option as SingleValue)?.alias ?? null) + } + // eslint-disable-next-line react/no-unstable-nested-components + noOptionsMessage={({ inputValue }) => + inputValue ? undefined : ( +

+ To configure a KMS key, ensure the following permissions are present on the + selected IAM role:{" "} + + "kms:ListAliases" + + ,{" "} + + "kms:DescribeKey" + + ,{" "} + + "kms:Encrypt" + + ,{" "} + + "kms:Decrypt" + + . +

+ ) + } + options={kmsKeys} + placeholder="Leave blank to use default KMS key" + getOptionLabel={(option) => + option.alias === "alias/aws/secretsmanager" + ? `${option.alias} (Default)` + : option.alias + } + getOptionValue={(option) => option.alias} + /> +
+ )} + /> + +
+ {tagFields.fields.map(({ id: tagFieldId }, i) => ( + +
+ {i === 0 && Key} + ( + + + + )} + /> +
+
+ {i === 0 && ( + + )} + ( + + + + )} + /> +
+ + tagFields.remove(i)} + > + + + +
+ ))} +
+
+ +
+ {mappingBehavior === AwsSecretsManagerSyncMappingBehavior.OneToOne && ( + ( + + +

+ Sync Secret Metadata as Tags{" "} + +

+ If enabled, metadata attached to secrets will be added as tags to secrets + synced by Infisical. +

+

+ Manually configured tags from the field above will take precedence over + secret metadata when tag keys conflict. +

+ + } + > + + +

+
+
+ )} + /> + )} + + ); +}; diff --git a/frontend/src/components/secret-syncs/forms/SecretSyncOptionsFields.tsx b/frontend/src/components/secret-syncs/forms/SecretSyncOptionsFields/SecretSyncOptionsFields.tsx similarity index 81% rename from frontend/src/components/secret-syncs/forms/SecretSyncOptionsFields.tsx rename to frontend/src/components/secret-syncs/forms/SecretSyncOptionsFields/SecretSyncOptionsFields.tsx index 6bfef2f71..ad9a26408 100644 --- a/frontend/src/components/secret-syncs/forms/SecretSyncOptionsFields.tsx +++ b/frontend/src/components/secret-syncs/forms/SecretSyncOptionsFields/SecretSyncOptionsFields.tsx @@ -1,12 +1,15 @@ +import { ReactNode } from "react"; import { Controller, useFormContext } from "react-hook-form"; import { faTriangleExclamation } from "@fortawesome/free-solid-svg-icons"; import { FontAwesomeIcon } from "@fortawesome/react-fontawesome"; import { FormControl, Select, SelectItem } from "@app/components/v2"; import { SECRET_SYNC_INITIAL_SYNC_BEHAVIOR_MAP, SECRET_SYNC_MAP } from "@app/helpers/secretSyncs"; -import { useSecretSyncOption } from "@app/hooks/api/secretSyncs"; +import { SecretSync, useSecretSyncOption } from "@app/hooks/api/secretSyncs"; -import { TSecretSyncForm } from "./schemas"; +import { TSecretSyncForm } from "../schemas"; +import { AwsParameterStoreSyncOptionsFields } from "./AwsParameterStoreSyncOptionsFields"; +import { AwsSecretsManagerSyncOptionsFields } from "./AwsSecretsManagerSyncOptionsFields"; type Props = { hideInitialSync?: boolean; @@ -21,6 +24,26 @@ export const SecretSyncOptionsFields = ({ hideInitialSync }: Props) => { const { syncOption } = useSecretSyncOption(destination); + let AdditionalSyncOptionsFieldsComponent: ReactNode; + + switch (destination) { + case SecretSync.AWSParameterStore: + AdditionalSyncOptionsFieldsComponent = ; + break; + case SecretSync.AWSSecretsManager: + AdditionalSyncOptionsFieldsComponent = ; + break; + case SecretSync.GitHub: + case SecretSync.GCPSecretManager: + case SecretSync.AzureKeyVault: + case SecretSync.AzureAppConfiguration: + case SecretSync.Databricks: + AdditionalSyncOptionsFieldsComponent = null; + break; + default: + throw new Error(`Unhandled Additional Sync Options Fields: ${destination}`); + } + return ( <>

Configure how secrets should be synced.

@@ -91,6 +114,7 @@ export const SecretSyncOptionsFields = ({ hideInitialSync }: Props) => { )} )} + {AdditionalSyncOptionsFieldsComponent} {/* ( { +export const AwsParameterStoreSyncOptionsReviewFields = () => { const { watch } = useFormContext< TSecretSyncForm & { destination: SecretSync.AWSParameterStore } >(); - const [region, path] = watch(["destinationConfig.region", "destinationConfig.path"]); + const [{ keyId, tags, syncSecretMetadataAsTags }] = watch(["syncOptions"]); + + return ( + <> + {keyId && {keyId}} + {tags && tags.length > 0 && ( + + + + Key + Value + + + {tags.map((tag) => ( + + {tag.key} + {tag.value} + + ))} + + + } + > +
+ + + + {tags.length} Tag{tags.length > 1 ? "s" : ""} + + +
+
+
+ )} + {syncSecretMetadataAsTags && ( + + Enabled + + )} + + ); +}; + +export const AwsParameterStoreDestinationReviewFields = () => { + const { watch } = useFormContext< + TSecretSyncForm & { destination: SecretSync.AWSParameterStore } + >(); + + const [{ region, path }] = watch(["destinationConfig"]); const awsRegion = AWS_REGIONS.find((r) => r.slug === region); diff --git a/frontend/src/components/secret-syncs/forms/SecretSyncReviewFields/AwsSecretsManagerSyncReviewFields.tsx b/frontend/src/components/secret-syncs/forms/SecretSyncReviewFields/AwsSecretsManagerSyncReviewFields.tsx index d59fe9500..f492792de 100644 --- a/frontend/src/components/secret-syncs/forms/SecretSyncReviewFields/AwsSecretsManagerSyncReviewFields.tsx +++ b/frontend/src/components/secret-syncs/forms/SecretSyncReviewFields/AwsSecretsManagerSyncReviewFields.tsx @@ -1,8 +1,10 @@ import { useFormContext } from "react-hook-form"; +import { faEye } from "@fortawesome/free-solid-svg-icons"; +import { FontAwesomeIcon } from "@fortawesome/react-fontawesome"; import { SecretSyncLabel } from "@app/components/secret-syncs"; import { TSecretSyncForm } from "@app/components/secret-syncs/forms/schemas"; -import { Badge } from "@app/components/v2"; +import { Badge, Table, TBody, Td, Th, THead, Tooltip, Tr } from "@app/components/v2"; import { AWS_REGIONS } from "@app/helpers/appConnections"; import { SecretSync } from "@app/hooks/api/secretSyncs"; import { AwsSecretsManagerSyncMappingBehavior } from "@app/hooks/api/secretSyncs/types/aws-secrets-manager-sync"; @@ -37,3 +39,55 @@ export const AwsSecretsManagerSyncReviewFields = () => { ); }; + +export const AwsSecretsManagerSyncOptionsReviewFields = () => { + const { watch } = useFormContext< + TSecretSyncForm & { destination: SecretSync.AWSSecretsManager } + >(); + + const [{ keyId, tags, syncSecretMetadataAsTags }] = watch(["syncOptions"]); + + return ( + <> + {keyId && {keyId}} + {tags && tags.length > 0 && ( + + + + Key + Value + + + {tags.map((tag) => ( + + {tag.key} + {tag.value} + + ))} + + + } + > +
+ + + + {tags.length} Tag{tags.length > 1 ? "s" : ""} + + +
+
+
+ )} + {syncSecretMetadataAsTags && ( + + Enabled + + )} + + ); +}; diff --git a/frontend/src/components/secret-syncs/forms/SecretSyncReviewFields/SecretSyncReviewFields.tsx b/frontend/src/components/secret-syncs/forms/SecretSyncReviewFields/SecretSyncReviewFields.tsx index 61408cf93..2846433ec 100644 --- a/frontend/src/components/secret-syncs/forms/SecretSyncReviewFields/SecretSyncReviewFields.tsx +++ b/frontend/src/components/secret-syncs/forms/SecretSyncReviewFields/SecretSyncReviewFields.tsx @@ -3,15 +3,21 @@ import { useFormContext } from "react-hook-form"; import { SecretSyncLabel } from "@app/components/secret-syncs"; import { TSecretSyncForm } from "@app/components/secret-syncs/forms/schemas"; -import { AwsSecretsManagerSyncReviewFields } from "@app/components/secret-syncs/forms/SecretSyncReviewFields/AwsSecretsManagerSyncReviewFields"; -import { DatabricksSyncReviewFields } from "@app/components/secret-syncs/forms/SecretSyncReviewFields/DatabricksSyncReviewFields"; import { Badge } from "@app/components/v2"; import { SECRET_SYNC_INITIAL_SYNC_BEHAVIOR_MAP, SECRET_SYNC_MAP } from "@app/helpers/secretSyncs"; import { SecretSync } from "@app/hooks/api/secretSyncs"; -import { AwsParameterStoreSyncReviewFields } from "./AwsParameterStoreSyncReviewFields"; +import { + AwsParameterStoreDestinationReviewFields, + AwsParameterStoreSyncOptionsReviewFields +} from "./AwsParameterStoreSyncReviewFields"; +import { + AwsSecretsManagerSyncOptionsReviewFields, + AwsSecretsManagerSyncReviewFields +} from "./AwsSecretsManagerSyncReviewFields"; import { AzureAppConfigurationSyncReviewFields } from "./AzureAppConfigurationSyncReviewFields"; import { AzureKeyVaultSyncReviewFields } from "./AzureKeyVaultSyncReviewFields"; +import { DatabricksSyncReviewFields } from "./DatabricksSyncReviewFields"; import { GcpSyncReviewFields } from "./GcpSyncReviewFields"; import { GitHubSyncReviewFields } from "./GitHubSyncReviewFields"; @@ -19,6 +25,7 @@ export const SecretSyncReviewFields = () => { const { watch } = useFormContext(); let DestinationFieldsComponent: ReactNode; + let AdditionalSyncOptionsFieldsComponent: ReactNode; const { name, @@ -38,10 +45,12 @@ export const SecretSyncReviewFields = () => { switch (destination) { case SecretSync.AWSParameterStore: - DestinationFieldsComponent = ; + DestinationFieldsComponent = ; + AdditionalSyncOptionsFieldsComponent = ; break; case SecretSync.AWSSecretsManager: DestinationFieldsComponent = ; + AdditionalSyncOptionsFieldsComponent = ; break; case SecretSync.GitHub: DestinationFieldsComponent = ; @@ -84,7 +93,7 @@ export const SecretSyncReviewFields = () => {
- Options + Sync Options
@@ -97,6 +106,7 @@ export const SecretSyncReviewFields = () => { {/* {prependPrefix} {appendSuffix} */} + {AdditionalSyncOptionsFieldsComponent}
diff --git a/frontend/src/components/secret-syncs/forms/schemas/aws-parameter-store-sync-destination-schema.ts b/frontend/src/components/secret-syncs/forms/schemas/aws-parameter-store-sync-destination-schema.ts index 68abb7793..e2ccb9854 100644 --- a/frontend/src/components/secret-syncs/forms/schemas/aws-parameter-store-sync-destination-schema.ts +++ b/frontend/src/components/secret-syncs/forms/schemas/aws-parameter-store-sync-destination-schema.ts @@ -1,16 +1,45 @@ import { z } from "zod"; +import { BaseSecretSyncSchema } from "@app/components/secret-syncs/forms/schemas/base-secret-sync-schema"; import { SecretSync } from "@app/hooks/api/secretSyncs"; -export const AwsParameterStoreSyncDestinationSchema = z.object({ - destination: z.literal(SecretSync.AWSParameterStore), - destinationConfig: z.object({ - path: z - .string() - .trim() - .min(1, "Parameter Store Path required") - .max(2048, "Cannot exceed 2048 characters") - .regex(/^\/([/]|(([\w-]+\/)+))?$/, 'Invalid path - must follow "/example/path/" format'), - region: z.string().min(1, "Region required") +export const AwsParameterStoreSyncDestinationSchema = BaseSecretSyncSchema( + z.object({ + keyId: z.string().optional(), + tags: z + .object({ + key: z + .string() + .regex( + /^([\p{L}\p{Z}\p{N}_.:/=+\-@]*)$/u, + "Keys can only contain Unicode letters, digits, white space and any of the following: _.:/=+@-" + ) + .min(1, "Key required") + .max(128, "Tag key cannot exceed 128 characters"), + value: z + .string() + .regex( + /^([\p{L}\p{Z}\p{N}_.:/=+\-@]*)$/u, + "Values can only contain Unicode letters, digits, white space and any of the following: _.:/=+@-" + ) + .max(256, "Tag value cannot exceed 256 characters") + }) + .array() + .max(50) + .optional(), + syncSecretMetadataAsTags: z.boolean().optional() }) -}); +).merge( + z.object({ + destination: z.literal(SecretSync.AWSParameterStore), + destinationConfig: z.object({ + path: z + .string() + .trim() + .min(1, "Parameter Store Path required") + .max(2048, "Cannot exceed 2048 characters") + .regex(/^\/([/]|(([\w-]+\/)+))?$/, 'Invalid path - must follow "/example/path/" format'), + region: z.string().min(1, "Region required") + }) + }) +); diff --git a/frontend/src/components/secret-syncs/forms/schemas/aws-secrets-manager-sync-destination-schema.ts b/frontend/src/components/secret-syncs/forms/schemas/aws-secrets-manager-sync-destination-schema.ts index b1f43e737..1bb161034 100644 --- a/frontend/src/components/secret-syncs/forms/schemas/aws-secrets-manager-sync-destination-schema.ts +++ b/frontend/src/components/secret-syncs/forms/schemas/aws-secrets-manager-sync-destination-schema.ts @@ -1,30 +1,59 @@ import { z } from "zod"; +import { BaseSecretSyncSchema } from "@app/components/secret-syncs/forms/schemas/base-secret-sync-schema"; import { SecretSync } from "@app/hooks/api/secretSyncs"; import { AwsSecretsManagerSyncMappingBehavior } from "@app/hooks/api/secretSyncs/types/aws-secrets-manager-sync"; -export const AwsSecretsManagerSyncDestinationSchema = z.object({ - destination: z.literal(SecretSync.AWSSecretsManager), - destinationConfig: z - .discriminatedUnion("mappingBehavior", [ - z.object({ - mappingBehavior: z.literal(AwsSecretsManagerSyncMappingBehavior.OneToOne) - }), - z.object({ - mappingBehavior: z.literal(AwsSecretsManagerSyncMappingBehavior.ManyToOne), - secretName: z +export const AwsSecretsManagerSyncDestinationSchema = BaseSecretSyncSchema( + z.object({ + keyId: z.string().optional(), + tags: z + .object({ + key: z .string() .regex( - /^[a-zA-Z0-9/_+=.@-]+$/, - "Secret name must contain only alphanumeric characters and the characters /_+=.@-" + /^([\p{L}\p{Z}\p{N}_.:/=+\-@]*)$/u, + "Keys can only contain Unicode letters, digits, white space and any of the following: _.:/=+@-" ) - .min(1, "Secret name is required") - .max(256, "Secret name cannot exceed 256 characters") + .min(1, "Key required") + .max(128, "Tag key cannot exceed 128 characters"), + value: z + .string() + .regex( + /^([\p{L}\p{Z}\p{N}_.:/=+\-@]*)$/u, + "Values can only contain Unicode letters, digits, white space and any of the following: _.:/=+@-" + ) + .max(256, "Tag value cannot exceed 256 characters") }) - ]) - .and( - z.object({ - region: z.string().min(1, "Region required") - }) - ) -}); + .array() + .max(50) + .optional(), + syncSecretMetadataAsTags: z.boolean().optional() + }) +).merge( + z.object({ + destination: z.literal(SecretSync.AWSSecretsManager), + destinationConfig: z + .discriminatedUnion("mappingBehavior", [ + z.object({ + mappingBehavior: z.literal(AwsSecretsManagerSyncMappingBehavior.OneToOne) + }), + z.object({ + mappingBehavior: z.literal(AwsSecretsManagerSyncMappingBehavior.ManyToOne), + secretName: z + .string() + .regex( + /^[a-zA-Z0-9/_+=.@-]+$/, + "Secret name must contain only alphanumeric characters and the characters /_+=.@-" + ) + .min(1, "Secret name is required") + .max(256, "Secret name cannot exceed 256 characters") + }) + ]) + .and( + z.object({ + region: z.string().min(1, "Region required") + }) + ) + }) +); diff --git a/frontend/src/components/secret-syncs/forms/schemas/azure-app-configuration-sync-destination-schema.ts b/frontend/src/components/secret-syncs/forms/schemas/azure-app-configuration-sync-destination-schema.ts index d472ddb04..5fb9d2895 100644 --- a/frontend/src/components/secret-syncs/forms/schemas/azure-app-configuration-sync-destination-schema.ts +++ b/frontend/src/components/secret-syncs/forms/schemas/azure-app-configuration-sync-destination-schema.ts @@ -1,19 +1,22 @@ import { z } from "zod"; +import { BaseSecretSyncSchema } from "@app/components/secret-syncs/forms/schemas/base-secret-sync-schema"; import { SecretSync } from "@app/hooks/api/secretSyncs"; -export const AzureAppConfigurationSyncDestinationSchema = z.object({ - destination: z.literal(SecretSync.AzureAppConfiguration), - destinationConfig: z.object({ - configurationUrl: z - .string() - .trim() - .min(1, { message: "Azure App Configuration URL is required" }) - .url() - .refine( - (val) => val.endsWith(".azconfig.io"), - "URL should have the following format: https://resource-name-here.azconfig.io" - ), - label: z.string().optional() +export const AzureAppConfigurationSyncDestinationSchema = BaseSecretSyncSchema().merge( + z.object({ + destination: z.literal(SecretSync.AzureAppConfiguration), + destinationConfig: z.object({ + configurationUrl: z + .string() + .trim() + .min(1, { message: "Azure App Configuration URL is required" }) + .url() + .refine( + (val) => val.endsWith(".azconfig.io"), + "URL should have the following format: https://resource-name-here.azconfig.io" + ), + label: z.string().optional() + }) }) -}); +); diff --git a/frontend/src/components/secret-syncs/forms/schemas/azure-key-vault-sync-destination-schema.ts b/frontend/src/components/secret-syncs/forms/schemas/azure-key-vault-sync-destination-schema.ts index 11fa6279b..30ea3b300 100644 --- a/frontend/src/components/secret-syncs/forms/schemas/azure-key-vault-sync-destination-schema.ts +++ b/frontend/src/components/secret-syncs/forms/schemas/azure-key-vault-sync-destination-schema.ts @@ -1,10 +1,16 @@ import { z } from "zod"; +import { BaseSecretSyncSchema } from "@app/components/secret-syncs/forms/schemas/base-secret-sync-schema"; import { SecretSync } from "@app/hooks/api/secretSyncs"; -export const AzureKeyVaultSyncDestinationSchema = z.object({ - destination: z.literal(SecretSync.AzureKeyVault), - destinationConfig: z.object({ - vaultBaseUrl: z.string().url("Invalid vault base URL format").min(1, "Vault base URL required") +export const AzureKeyVaultSyncDestinationSchema = BaseSecretSyncSchema().merge( + z.object({ + destination: z.literal(SecretSync.AzureKeyVault), + destinationConfig: z.object({ + vaultBaseUrl: z + .string() + .url("Invalid vault base URL format") + .min(1, "Vault base URL required") + }) }) -}); +); diff --git a/frontend/src/components/secret-syncs/forms/schemas/base-secret-sync-schema.ts b/frontend/src/components/secret-syncs/forms/schemas/base-secret-sync-schema.ts new file mode 100644 index 000000000..b898fe91f --- /dev/null +++ b/frontend/src/components/secret-syncs/forms/schemas/base-secret-sync-schema.ts @@ -0,0 +1,39 @@ +import { AnyZodObject, z } from "zod"; + +import { SecretSyncInitialSyncBehavior } from "@app/hooks/api/secretSyncs"; +import { slugSchema } from "@app/lib/schemas"; + +export const BaseSecretSyncSchema = ( + additionalSyncOptions?: T +) => { + const baseSyncOptionsSchema = z.object({ + initialSyncBehavior: z.nativeEnum(SecretSyncInitialSyncBehavior) + // scott: removed temporarily for evaluation of template formatting + // prependPrefix: z + // .string() + // .trim() + // .transform((str) => str.toUpperCase()) + // .optional(), + // appendSuffix: z + // .string() + // .trim() + // .transform((str) => str.toUpperCase()) + // .optional() + }); + + const syncOptionsSchema = additionalSyncOptions + ? baseSyncOptionsSchema.merge(additionalSyncOptions) + : (baseSyncOptionsSchema as T extends AnyZodObject + ? z.ZodObject> + : typeof baseSyncOptionsSchema); + + return z.object({ + name: slugSchema({ field: "Name" }), + description: z.string().trim().max(256, "Cannot exceed 256 characters").optional(), + connection: z.object({ name: z.string(), id: z.string().uuid() }), + environment: z.object({ slug: z.string(), id: z.string(), name: z.string() }), + secretPath: z.string().min(1, "Secret path required"), + syncOptions: syncOptionsSchema, + isAutoSyncEnabled: z.boolean() + }); +}; diff --git a/frontend/src/components/secret-syncs/forms/schemas/databricks-sync-destination-schema.ts b/frontend/src/components/secret-syncs/forms/schemas/databricks-sync-destination-schema.ts index 0a8f5e723..4319f616d 100644 --- a/frontend/src/components/secret-syncs/forms/schemas/databricks-sync-destination-schema.ts +++ b/frontend/src/components/secret-syncs/forms/schemas/databricks-sync-destination-schema.ts @@ -1,10 +1,13 @@ import { z } from "zod"; +import { BaseSecretSyncSchema } from "@app/components/secret-syncs/forms/schemas/base-secret-sync-schema"; import { SecretSync } from "@app/hooks/api/secretSyncs"; -export const DatabricksSyncDestinationSchema = z.object({ - destination: z.literal(SecretSync.Databricks), - destinationConfig: z.object({ - scope: z.string().trim().min(1, "Databricks scope required") +export const DatabricksSyncDestinationSchema = BaseSecretSyncSchema().merge( + z.object({ + destination: z.literal(SecretSync.Databricks), + destinationConfig: z.object({ + scope: z.string().trim().min(1, "Databricks scope required") + }) }) -}); +); 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 6ffa3ca86..4225c6619 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,12 +1,15 @@ import { z } from "zod"; +import { BaseSecretSyncSchema } from "@app/components/secret-syncs/forms/schemas/base-secret-sync-schema"; 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") +export const GcpSyncDestinationSchema = BaseSecretSyncSchema().merge( + 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/components/secret-syncs/forms/schemas/github-sync-destination-schema.ts b/frontend/src/components/secret-syncs/forms/schemas/github-sync-destination-schema.ts index ccebc946c..54a204528 100644 --- a/frontend/src/components/secret-syncs/forms/schemas/github-sync-destination-schema.ts +++ b/frontend/src/components/secret-syncs/forms/schemas/github-sync-destination-schema.ts @@ -1,45 +1,48 @@ import { z } from "zod"; +import { BaseSecretSyncSchema } from "@app/components/secret-syncs/forms/schemas/base-secret-sync-schema"; import { SecretSync } from "@app/hooks/api/secretSyncs"; import { GitHubSyncScope, GitHubSyncVisibility } from "@app/hooks/api/secretSyncs/types/github-sync"; -export const GitHubSyncDestinationSchema = z.object({ - destination: z.literal(SecretSync.GitHub), - destinationConfig: z - .discriminatedUnion("scope", [ - z.object({ - scope: z.literal(GitHubSyncScope.Organization), - org: z.string().min(1, "Organization name required"), - visibility: z.nativeEnum(GitHubSyncVisibility), - selectedRepositoryIds: z.number().array().optional() - }), - z.object({ - scope: z.literal(GitHubSyncScope.Repository), - owner: z.string().min(1, "Repository owner name required"), - repo: z.string().min(1, "Repository name required") - }), - z.object({ - scope: z.literal(GitHubSyncScope.RepositoryEnvironment), - owner: z.string().min(1, "Repository owner name required"), - repo: z.string().min(1, "Repository name required"), - env: z.string().min(1, "Environment name required") - }) - ]) - .superRefine((options, ctx) => { - if (options.scope === GitHubSyncScope.Organization) { - if ( - options.visibility === GitHubSyncVisibility.Selected && - !options.selectedRepositoryIds?.length - ) { - ctx.addIssue({ - code: z.ZodIssueCode.custom, - message: "Select at least 1 repository", - path: ["selectedRepositoryIds"] - }); +export const GitHubSyncDestinationSchema = BaseSecretSyncSchema().merge( + z.object({ + destination: z.literal(SecretSync.GitHub), + destinationConfig: z + .discriminatedUnion("scope", [ + z.object({ + scope: z.literal(GitHubSyncScope.Organization), + org: z.string().min(1, "Organization name required"), + visibility: z.nativeEnum(GitHubSyncVisibility), + selectedRepositoryIds: z.number().array().optional() + }), + z.object({ + scope: z.literal(GitHubSyncScope.Repository), + owner: z.string().min(1, "Repository owner name required"), + repo: z.string().min(1, "Repository name required") + }), + z.object({ + scope: z.literal(GitHubSyncScope.RepositoryEnvironment), + owner: z.string().min(1, "Repository owner name required"), + repo: z.string().min(1, "Repository name required"), + env: z.string().min(1, "Environment name required") + }) + ]) + .superRefine((options, ctx) => { + if (options.scope === GitHubSyncScope.Organization) { + if ( + options.visibility === GitHubSyncVisibility.Selected && + !options.selectedRepositoryIds?.length + ) { + ctx.addIssue({ + code: z.ZodIssueCode.custom, + message: "Select at least 1 repository", + path: ["selectedRepositoryIds"] + }); + } } - } - }) -}); + }) + }) +); diff --git a/frontend/src/components/secret-syncs/forms/schemas/secret-sync-schema.ts b/frontend/src/components/secret-syncs/forms/schemas/secret-sync-schema.ts index f7d11dba4..55ee6cb3d 100644 --- a/frontend/src/components/secret-syncs/forms/schemas/secret-sync-schema.ts +++ b/frontend/src/components/secret-syncs/forms/schemas/secret-sync-schema.ts @@ -3,37 +3,12 @@ import { z } from "zod"; import { AwsSecretsManagerSyncDestinationSchema } from "@app/components/secret-syncs/forms/schemas/aws-secrets-manager-sync-destination-schema"; import { DatabricksSyncDestinationSchema } from "@app/components/secret-syncs/forms/schemas/databricks-sync-destination-schema"; import { GitHubSyncDestinationSchema } from "@app/components/secret-syncs/forms/schemas/github-sync-destination-schema"; -import { SecretSyncInitialSyncBehavior } from "@app/hooks/api/secretSyncs"; -import { slugSchema } from "@app/lib/schemas"; import { AwsParameterStoreSyncDestinationSchema } from "./aws-parameter-store-sync-destination-schema"; import { AzureAppConfigurationSyncDestinationSchema } from "./azure-app-configuration-sync-destination-schema"; import { AzureKeyVaultSyncDestinationSchema } from "./azure-key-vault-sync-destination-schema"; import { GcpSyncDestinationSchema } from "./gcp-sync-destination-schema"; -const BaseSecretSyncSchema = z.object({ - name: slugSchema({ field: "Name" }), - description: z.string().trim().max(256, "Cannot exceed 256 characters").optional(), - connection: z.object({ name: z.string(), id: z.string().uuid() }), - environment: z.object({ slug: z.string(), id: z.string(), name: z.string() }), - secretPath: z.string().min(1, "Secret path required"), - syncOptions: z.object({ - initialSyncBehavior: z.nativeEnum(SecretSyncInitialSyncBehavior) - // scott: removed temporarily for evaluation of template formatting - // prependPrefix: z - // .string() - // .trim() - // .transform((str) => str.toUpperCase()) - // .optional(), - // appendSuffix: z - // .string() - // .trim() - // .transform((str) => str.toUpperCase()) - // .optional() - }), - isAutoSyncEnabled: z.boolean() -}); - const SecretSyncUnionSchema = z.discriminatedUnion("destination", [ AwsParameterStoreSyncDestinationSchema, AwsSecretsManagerSyncDestinationSchema, @@ -44,8 +19,8 @@ const SecretSyncUnionSchema = z.discriminatedUnion("destination", [ DatabricksSyncDestinationSchema ]); -export const SecretSyncFormSchema = SecretSyncUnionSchema.and(BaseSecretSyncSchema); +export const SecretSyncFormSchema = SecretSyncUnionSchema; -export const UpdateSecretSyncFormSchema = SecretSyncUnionSchema.and(BaseSecretSyncSchema.partial()); +export const UpdateSecretSyncFormSchema = SecretSyncUnionSchema; export type TSecretSyncForm = z.infer; diff --git a/frontend/src/hooks/api/appConnections/aws/index.ts b/frontend/src/hooks/api/appConnections/aws/index.ts new file mode 100644 index 000000000..2c1906d36 --- /dev/null +++ b/frontend/src/hooks/api/appConnections/aws/index.ts @@ -0,0 +1,2 @@ +export * from "./queries"; +export * from "./types"; diff --git a/frontend/src/hooks/api/appConnections/aws/queries.tsx b/frontend/src/hooks/api/appConnections/aws/queries.tsx new file mode 100644 index 000000000..87965507b --- /dev/null +++ b/frontend/src/hooks/api/appConnections/aws/queries.tsx @@ -0,0 +1,42 @@ +import { useQuery, UseQueryOptions } from "@tanstack/react-query"; + +import { apiRequest } from "@app/config/request"; +import { appConnectionKeys } from "@app/hooks/api/appConnections"; + +import { + TAwsConnectionKmsKey, + TAwsConnectionListKmsKeysResponse, + TListAwsConnectionKmsKeys +} from "./types"; + +const awsConnectionKeys = { + all: [...appConnectionKeys.all, "aws"] as const, + listKmsKeys: (params: TListAwsConnectionKmsKeys) => + [...awsConnectionKeys.all, "kms-keys", params] as const +}; + +export const useListAwsConnectionKmsKeys = ( + { connectionId, ...params }: TListAwsConnectionKmsKeys, + options?: Omit< + UseQueryOptions< + TAwsConnectionKmsKey[], + unknown, + TAwsConnectionKmsKey[], + ReturnType + >, + "queryKey" | "queryFn" + > +) => { + return useQuery({ + queryKey: awsConnectionKeys.listKmsKeys({ connectionId, ...params }), + queryFn: async () => { + const { data } = await apiRequest.get( + `/api/v1/app-connections/aws/${connectionId}/kms-keys`, + { params } + ); + + return data.kmsKeys; + }, + ...options + }); +}; diff --git a/frontend/src/hooks/api/appConnections/aws/types.ts b/frontend/src/hooks/api/appConnections/aws/types.ts new file mode 100644 index 000000000..7661b131d --- /dev/null +++ b/frontend/src/hooks/api/appConnections/aws/types.ts @@ -0,0 +1,16 @@ +import { SecretSync } from "@app/hooks/api/secretSyncs"; + +export type TListAwsConnectionKmsKeys = { + connectionId: string; + region: string; + destination: SecretSync.AWSParameterStore | SecretSync.AWSSecretsManager; +}; + +export type TAwsConnectionKmsKey = { + alias: string; + id: string; +}; + +export type TAwsConnectionListKmsKeysResponse = { + kmsKeys: TAwsConnectionKmsKey[]; +}; diff --git a/frontend/src/hooks/api/secretSyncs/types/aws-parameter-store-sync.ts b/frontend/src/hooks/api/secretSyncs/types/aws-parameter-store-sync.ts index 26c7aed68..32217cf47 100644 --- a/frontend/src/hooks/api/secretSyncs/types/aws-parameter-store-sync.ts +++ b/frontend/src/hooks/api/secretSyncs/types/aws-parameter-store-sync.ts @@ -1,6 +1,6 @@ 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"; +import { RootSyncOptions, TRootSecretSync } from "@app/hooks/api/secretSyncs/types/root-sync"; export type TAwsParameterStoreSync = TRootSecretSync & { destination: SecretSync.AWSParameterStore; @@ -13,4 +13,9 @@ export type TAwsParameterStoreSync = TRootSecretSync & { name: string; id: string; }; + syncOptions: RootSyncOptions & { + keyId?: string; + tags?: { key: string; value?: string }[]; + syncSecretMetadataAsTags?: boolean; + }; }; diff --git a/frontend/src/hooks/api/secretSyncs/types/aws-secrets-manager-sync.ts b/frontend/src/hooks/api/secretSyncs/types/aws-secrets-manager-sync.ts index 1bcf7c7df..0951adae0 100644 --- a/frontend/src/hooks/api/secretSyncs/types/aws-secrets-manager-sync.ts +++ b/frontend/src/hooks/api/secretSyncs/types/aws-secrets-manager-sync.ts @@ -1,6 +1,6 @@ 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"; +import { RootSyncOptions, TRootSecretSync } from "@app/hooks/api/secretSyncs/types/root-sync"; export type TAwsSecretsManagerSync = TRootSecretSync & { destination: SecretSync.AWSSecretsManager; @@ -19,6 +19,11 @@ export type TAwsSecretsManagerSync = TRootSecretSync & { name: string; id: string; }; + syncOptions: RootSyncOptions & { + keyId?: string; + tags?: { key: string; value?: string }[]; + syncSecretMetadataAsTags?: boolean; + }; }; export enum AwsSecretsManagerSyncMappingBehavior { OneToOne = "one-to-one", diff --git a/frontend/src/hooks/api/secretSyncs/types/root-sync.ts b/frontend/src/hooks/api/secretSyncs/types/root-sync.ts index 947b58e4a..49cf9c978 100644 --- a/frontend/src/hooks/api/secretSyncs/types/root-sync.ts +++ b/frontend/src/hooks/api/secretSyncs/types/root-sync.ts @@ -1,6 +1,12 @@ import { AppConnection } from "@app/hooks/api/appConnections/enums"; import { SecretSyncInitialSyncBehavior, SecretSyncStatus } from "@app/hooks/api/secretSyncs"; +export type RootSyncOptions = { + initialSyncBehavior: SecretSyncInitialSyncBehavior; + // prependPrefix?: string; + // appendSuffix?: string; +}; + export type TRootSecretSync = { id: string; name: string; @@ -24,11 +30,7 @@ export type TRootSecretSync = { lastRemoveJobId: string | null; lastRemovedAt: Date | null; lastRemoveMessage: string | null; - syncOptions: { - initialSyncBehavior: SecretSyncInitialSyncBehavior; - // prependPrefix?: string; - // appendSuffix?: string; - }; + syncOptions: RootSyncOptions; connection: { app: AppConnection; id: string; diff --git a/frontend/src/pages/secret-manager/IntegrationsListPage/components/SecretSyncsTab/SecretSyncsTab.tsx b/frontend/src/pages/secret-manager/IntegrationsListPage/components/SecretSyncsTab/SecretSyncsTab.tsx index f44ea439b..d9c237a3d 100644 --- a/frontend/src/pages/secret-manager/IntegrationsListPage/components/SecretSyncsTab/SecretSyncsTab.tsx +++ b/frontend/src/pages/secret-manager/IntegrationsListPage/components/SecretSyncsTab/SecretSyncsTab.tsx @@ -19,7 +19,7 @@ export const SecretSyncsTab = () => { const { data: secretSyncs = [], isPending: isSecretSyncsPending } = useListSecretSyncs( currentWorkspace.id, { - refetchInterval: 4000 + refetchInterval: 30000 } ); diff --git a/frontend/src/pages/secret-manager/SecretSyncDetailsByIDPage/SecretSyncDetailsByIDPage.tsx b/frontend/src/pages/secret-manager/SecretSyncDetailsByIDPage/SecretSyncDetailsByIDPage.tsx index db3787b49..3b837257f 100644 --- a/frontend/src/pages/secret-manager/SecretSyncDetailsByIDPage/SecretSyncDetailsByIDPage.tsx +++ b/frontend/src/pages/secret-manager/SecretSyncDetailsByIDPage/SecretSyncDetailsByIDPage.tsx @@ -37,7 +37,7 @@ const PageContent = () => { const { handlePopUpToggle, popUp, handlePopUpOpen } = usePopUp(["editSync"] as const); const { data: secretSync, isPending } = useGetSecretSync(destination, syncId, { - refetchInterval: 4000 + refetchInterval: 30000 }); if (isPending) { @@ -66,7 +66,7 @@ const PageContent = () => { const handleEditSource = () => handlePopUpOpen("editSync", SecretSyncEditFields.Source); - // const handleEditOptions = () => handlePopUpOpen("editSync", SecretSyncEditFields.Options); + const handleEditOptions = () => handlePopUpOpen("editSync", SecretSyncEditFields.Options); const handleEditDestination = () => handlePopUpOpen("editSync", SecretSyncEditFields.Destination); @@ -108,10 +108,7 @@ const PageContent = () => {
- +
{ - const { - destination, - syncOptions: { - // appendSuffix, - // prependPrefix, - initialSyncBehavior - } - } = secretSync; - - return ( -
-
-
-

Sync Options

- {/* - {(isAllowed) => ( - - - - )} - */} -
-
-
- - {SECRET_SYNC_INITIAL_SYNC_BEHAVIOR_MAP[initialSyncBehavior](destination).name} - - {/* {prependPrefix} - {appendSuffix} */} -
-
-
-
- ); -}; diff --git a/frontend/src/pages/secret-manager/SecretSyncDetailsByIDPage/components/SecretSyncOptionsSection/AwsParameterStoreSyncOptionsSection.tsx b/frontend/src/pages/secret-manager/SecretSyncDetailsByIDPage/components/SecretSyncOptionsSection/AwsParameterStoreSyncOptionsSection.tsx new file mode 100644 index 000000000..1b898bc70 --- /dev/null +++ b/frontend/src/pages/secret-manager/SecretSyncDetailsByIDPage/components/SecretSyncOptionsSection/AwsParameterStoreSyncOptionsSection.tsx @@ -0,0 +1,60 @@ +import { faEye } from "@fortawesome/free-solid-svg-icons"; +import { FontAwesomeIcon } from "@fortawesome/react-fontawesome"; + +import { SecretSyncLabel } from "@app/components/secret-syncs"; +import { Badge, Table, TBody, Td, Th, THead, Tooltip, Tr } from "@app/components/v2"; +import { TAwsParameterStoreSync } from "@app/hooks/api/secretSyncs/types/aws-parameter-store-sync"; + +type Props = { + secretSync: TAwsParameterStoreSync; +}; + +export const AwsParameterStoreSyncOptionsSection = ({ secretSync }: Props) => { + const { + syncOptions: { keyId, tags, syncSecretMetadataAsTags } + } = secretSync; + + return ( + <> + {keyId && {keyId}} + {tags && tags.length > 0 && ( + + + + Key + Value + + + {tags.map((tag) => ( + + {tag.key} + {tag.value} + + ))} + + + } + > +
+ + + + {tags.length} Tag{tags.length > 1 ? "s" : ""} + + +
+
+
+ )} + {syncSecretMetadataAsTags && ( + + Enabled + + )} + + ); +}; diff --git a/frontend/src/pages/secret-manager/SecretSyncDetailsByIDPage/components/SecretSyncOptionsSection/AwsSecretsManagerSyncOptionsSection.tsx b/frontend/src/pages/secret-manager/SecretSyncDetailsByIDPage/components/SecretSyncOptionsSection/AwsSecretsManagerSyncOptionsSection.tsx new file mode 100644 index 000000000..8e103ba18 --- /dev/null +++ b/frontend/src/pages/secret-manager/SecretSyncDetailsByIDPage/components/SecretSyncOptionsSection/AwsSecretsManagerSyncOptionsSection.tsx @@ -0,0 +1,60 @@ +import { faEye } from "@fortawesome/free-solid-svg-icons"; +import { FontAwesomeIcon } from "@fortawesome/react-fontawesome"; + +import { SecretSyncLabel } from "@app/components/secret-syncs"; +import { Badge, Table, TBody, Td, Th, THead, Tooltip, Tr } from "@app/components/v2"; +import { TAwsSecretsManagerSync } from "@app/hooks/api/secretSyncs/types/aws-secrets-manager-sync"; + +type Props = { + secretSync: TAwsSecretsManagerSync; +}; + +export const AwsSecretsManagerSyncOptionsSection = ({ secretSync }: Props) => { + const { + syncOptions: { keyId, tags, syncSecretMetadataAsTags } + } = secretSync; + + return ( + <> + {keyId && {keyId}} + {tags && tags.length > 0 && ( + + + + Key + Value + + + {tags.map((tag) => ( + + {tag.key} + {tag.value} + + ))} + + + } + > +
+ + + + {tags.length} Tag{tags.length > 1 ? "s" : ""} + + +
+
+
+ )} + {syncSecretMetadataAsTags && ( + + Enabled + + )} + + ); +}; diff --git a/frontend/src/pages/secret-manager/SecretSyncDetailsByIDPage/components/SecretSyncOptionsSection/SecretSyncOptionsSection.tsx b/frontend/src/pages/secret-manager/SecretSyncDetailsByIDPage/components/SecretSyncOptionsSection/SecretSyncOptionsSection.tsx new file mode 100644 index 000000000..df6927b48 --- /dev/null +++ b/frontend/src/pages/secret-manager/SecretSyncDetailsByIDPage/components/SecretSyncOptionsSection/SecretSyncOptionsSection.tsx @@ -0,0 +1,92 @@ +import { ReactNode } from "react"; +import { faEdit } from "@fortawesome/free-solid-svg-icons"; +import { FontAwesomeIcon } from "@fortawesome/react-fontawesome"; + +import { ProjectPermissionCan } from "@app/components/permissions"; +import { SecretSyncLabel } from "@app/components/secret-syncs"; +import { IconButton } from "@app/components/v2"; +import { ProjectPermissionSub } from "@app/context"; +import { ProjectPermissionSecretSyncActions } from "@app/context/ProjectPermissionContext/types"; +import { SECRET_SYNC_INITIAL_SYNC_BEHAVIOR_MAP } from "@app/helpers/secretSyncs"; +import { SecretSync, TSecretSync } from "@app/hooks/api/secretSyncs"; + +import { AwsParameterStoreSyncOptionsSection } from "./AwsParameterStoreSyncOptionsSection"; +import { AwsSecretsManagerSyncOptionsSection } from "./AwsSecretsManagerSyncOptionsSection"; + +type Props = { + secretSync: TSecretSync; + onEditOptions: VoidFunction; +}; + +export const SecretSyncOptionsSection = ({ secretSync, onEditOptions }: Props) => { + const { + destination, + syncOptions: { + // appendSuffix, + // prependPrefix, + initialSyncBehavior + } + } = secretSync; + + let AdditionalSyncOptionsComponent: ReactNode; + + switch (destination) { + case SecretSync.AWSParameterStore: + AdditionalSyncOptionsComponent = ( + + ); + break; + case SecretSync.AWSSecretsManager: + AdditionalSyncOptionsComponent = ( + + ); + break; + case SecretSync.GitHub: + case SecretSync.GCPSecretManager: + case SecretSync.AzureKeyVault: + case SecretSync.AzureAppConfiguration: + case SecretSync.Databricks: + AdditionalSyncOptionsComponent = null; + break; + default: + throw new Error(`Unhandled Destination Review Fields: ${destination}`); + } + + return ( +
+
+
+

Sync Options

+ {AdditionalSyncOptionsComponent && ( + + {(isAllowed) => ( + + + + )} + + )} +
+
+
+ + {SECRET_SYNC_INITIAL_SYNC_BEHAVIOR_MAP[initialSyncBehavior](destination).name} + + {/* {prependPrefix} + {appendSuffix} */} + {AdditionalSyncOptionsComponent} +
+
+
+
+ ); +}; diff --git a/frontend/src/pages/secret-manager/SecretSyncDetailsByIDPage/components/SecretSyncOptionsSection/index.ts b/frontend/src/pages/secret-manager/SecretSyncDetailsByIDPage/components/SecretSyncOptionsSection/index.ts new file mode 100644 index 000000000..51e35c596 --- /dev/null +++ b/frontend/src/pages/secret-manager/SecretSyncDetailsByIDPage/components/SecretSyncOptionsSection/index.ts @@ -0,0 +1 @@ +export * from "./SecretSyncOptionsSection";