diff --git a/backend/src/ee/services/dynamic-secret/providers/aws-elasticache.ts b/backend/src/ee/services/dynamic-secret/providers/aws-elasticache.ts new file mode 100644 index 000000000..78eacbb03 --- /dev/null +++ b/backend/src/ee/services/dynamic-secret/providers/aws-elasticache.ts @@ -0,0 +1,105 @@ +import handlebars from "handlebars"; +import { customAlphabet } from "nanoid"; + +import { CreateElastiCacheUserSchema, DeleteElasticCacheUserSchema, ElastiCacheUserManager } from "@app/lib/aws"; +import { BadRequestError } from "@app/lib/errors"; + +import { DynamicSecretAwsElastiCacheSchema, TDynamicProviderFns } from "./models"; + +const generatePassword = () => { + const charset = "abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789-_.~!*$#"; + return customAlphabet(charset, 64)(); +}; + +const generateUsername = () => { + const charset = "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789-"; + return `inf-${customAlphabet(charset, 32)()}`; // Username must start with an ascii letter, so we prepend the username with "inf-" +}; + +export const AwsElastiCacheDatabaseProvider = (): TDynamicProviderFns => { + const validateProviderInputs = async (inputs: unknown) => { + const providerInputs = DynamicSecretAwsElastiCacheSchema.parse(inputs); + + JSON.parse(providerInputs.creationStatement); + JSON.parse(providerInputs.revocationStatement); + + return providerInputs; + }; + + const validateConnection = async (inputs: unknown) => { + const providerInputs = await validateProviderInputs(inputs); + await ElastiCacheUserManager( + { + accessKeyId: providerInputs.accessKeyId, + secretAccessKey: providerInputs.secretAccessKey + }, + providerInputs.region + ).verifyCredentials(providerInputs.clusterName); + return true; + }; + + const create = async (inputs: unknown, expireAt: number) => { + const providerInputs = await validateProviderInputs(inputs); + if (!(await validateConnection(providerInputs))) { + throw new BadRequestError({ message: "Failed to establish connection" }); + } + + const leaseUsername = generateUsername(); + const leasePassword = generatePassword(); + const leaseExpiration = new Date(expireAt).toISOString(); + + const creationStatement = handlebars.compile(providerInputs.creationStatement, { noEscape: true })({ + username: leaseUsername, + password: leasePassword, + expiration: leaseExpiration + }); + + const parsedStatement = CreateElastiCacheUserSchema.parse(JSON.parse(creationStatement)); + + await ElastiCacheUserManager( + { + accessKeyId: providerInputs.accessKeyId, + secretAccessKey: providerInputs.secretAccessKey + }, + providerInputs.region + ).createUser(parsedStatement, providerInputs.clusterName); + + return { + entityId: leaseUsername, + data: { + DB_USERNAME: leaseUsername, + DB_PASSWORD: leasePassword + } + }; + }; + + const revoke = async (inputs: unknown, entityId: string) => { + const providerInputs = await validateProviderInputs(inputs); + + const revokeStatement = handlebars.compile(providerInputs.revocationStatement)({ username: entityId }); + const parsedStatement = DeleteElasticCacheUserSchema.parse(JSON.parse(revokeStatement)); + + await ElastiCacheUserManager( + { + accessKeyId: providerInputs.accessKeyId, + secretAccessKey: providerInputs.secretAccessKey + }, + providerInputs.region + ).deleteUser(parsedStatement); + + return { entityId }; + }; + + const renew = async (inputs: unknown, entityId: string) => { + // Do nothing + return { entityId }; + }; + + return { + validateProviderInputs, + validateConnection, + create, + revoke, + renew + }; +}; diff --git a/backend/src/ee/services/dynamic-secret/providers/index.ts b/backend/src/ee/services/dynamic-secret/providers/index.ts index c69bd9609..1ee020625 100644 --- a/backend/src/ee/services/dynamic-secret/providers/index.ts +++ b/backend/src/ee/services/dynamic-secret/providers/index.ts @@ -1,3 +1,4 @@ +import { AwsElastiCacheDatabaseProvider } from "./aws-elasticache"; import { AwsIamProvider } from "./aws-iam"; import { CassandraProvider } from "./cassandra"; import { DynamicSecretProviders } from "./models"; @@ -8,5 +9,6 @@ export const buildDynamicSecretProviders = () => ({ [DynamicSecretProviders.SqlDatabase]: SqlDatabaseProvider(), [DynamicSecretProviders.Cassandra]: CassandraProvider(), [DynamicSecretProviders.AwsIam]: AwsIamProvider(), - [DynamicSecretProviders.Redis]: RedisDatabaseProvider() + [DynamicSecretProviders.Redis]: RedisDatabaseProvider(), + [DynamicSecretProviders.AwsElastiCache]: AwsElastiCacheDatabaseProvider() }); diff --git a/backend/src/ee/services/dynamic-secret/providers/models.ts b/backend/src/ee/services/dynamic-secret/providers/models.ts index cc1009c7d..60cab6f80 100644 --- a/backend/src/ee/services/dynamic-secret/providers/models.ts +++ b/backend/src/ee/services/dynamic-secret/providers/models.ts @@ -7,51 +7,28 @@ export enum SqlProviders { MsSQL = "mssql" } -export enum RedisProviders { - Redis = "redis", - Elasticache = "elasticache" -} +export const DynamicSecretRedisDBSchema = z.object({ + host: z.string().trim().toLowerCase(), + port: z.number(), + username: z.string().trim(), // this is often "default". + password: z.string().trim().optional(), -export const DynamicSecretRedisDBSchema = z - .object({ - client: z.nativeEnum(RedisProviders), - host: z.string().trim().toLowerCase(), - port: z.number(), - username: z.string().trim(), // this is often "default". - password: z.string().trim().optional(), + creationStatement: z.string().trim(), + revocationStatement: z.string().trim(), + renewStatement: z.string().trim().optional(), + ca: z.string().optional() +}); - elastiCacheIamUsername: z.string().trim().optional(), - elastiCacheRegion: z.string().trim().optional(), +export const DynamicSecretAwsElastiCacheSchema = z.object({ + clusterName: z.string().trim().min(1), + accessKeyId: z.string().trim().min(1), + secretAccessKey: z.string().trim().min(1), - creationStatement: z.string().trim(), - revocationStatement: z.string().trim(), - renewStatement: z.string().trim().optional(), - ca: z.string().optional() - }) - .refine( - (data) => { - if (data.client === RedisProviders.Elasticache) { - return !!data.elastiCacheIamUsername; - } - return true; - }, - { - message: "elastiCacheIamUsername is required when client is ElastiCache", - path: ["elastiCacheIamUsername"] - } - ) - .refine( - (data) => { - if (data.client === RedisProviders.Elasticache) { - return !!data.elastiCacheRegion; - } - return true; - }, - { - message: "elastiCacheRegion is required when client is ElastiCache", - path: ["elastiCacheRegion"] - } - ); + region: z.string().trim(), + creationStatement: z.string().trim(), + revocationStatement: z.string().trim(), + ca: z.string().optional() +}); export const DynamicSecretSqlDBSchema = z.object({ client: z.nativeEnum(SqlProviders), @@ -94,14 +71,16 @@ export enum DynamicSecretProviders { SqlDatabase = "sql-database", Cassandra = "cassandra", AwsIam = "aws-iam", - Redis = "redis" + Redis = "redis", + AwsElastiCache = "aws-elasticache" } export const DynamicSecretProviderSchema = z.discriminatedUnion("type", [ z.object({ type: z.literal(DynamicSecretProviders.SqlDatabase), inputs: DynamicSecretSqlDBSchema }), z.object({ type: z.literal(DynamicSecretProviders.Cassandra), inputs: DynamicSecretCassandraSchema }), z.object({ type: z.literal(DynamicSecretProviders.AwsIam), inputs: DynamicSecretAwsIamSchema }), - z.object({ type: z.literal(DynamicSecretProviders.Redis), inputs: DynamicSecretRedisDBSchema }) + z.object({ type: z.literal(DynamicSecretProviders.Redis), inputs: DynamicSecretRedisDBSchema }), + z.object({ type: z.literal(DynamicSecretProviders.AwsElastiCache), inputs: DynamicSecretAwsElastiCacheSchema }) ]); export type TDynamicProviderFns = { diff --git a/backend/src/ee/services/dynamic-secret/providers/redis.ts b/backend/src/ee/services/dynamic-secret/providers/redis.ts index de836bec9..23eb454c5 100644 --- a/backend/src/ee/services/dynamic-secret/providers/redis.ts +++ b/backend/src/ee/services/dynamic-secret/providers/redis.ts @@ -4,25 +4,19 @@ import { Redis } from "ioredis"; import { customAlphabet } from "nanoid"; import { z } from "zod"; -import { CreateElastiCacheUserSchema, ElastiCacheConnector, ElastiCacheUserManager } from "@app/lib/aws"; import { getConfig } from "@app/lib/config/env"; import { BadRequestError } from "@app/lib/errors"; import { getDbConnectionHost } from "@app/lib/knex"; import { alphaNumericNanoId } from "@app/lib/nanoid"; -import { DynamicSecretRedisDBSchema, RedisProviders, TDynamicProviderFns } from "./models"; +import { DynamicSecretRedisDBSchema, TDynamicProviderFns } from "./models"; const generatePassword = () => { const charset = "abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789-_.~!*$#"; return customAlphabet(charset, 64)(); }; -const generateUsername = (provider: RedisProviders) => { - if (provider === RedisProviders.Elasticache) { - const charset = "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789-"; - return `inf-${customAlphabet(charset, 32)()}`; // Username must start with an ascii letter, so we prepend the username with "inf-" - } - +const generateUsername = () => { return alphaNumericNanoId(32); }; @@ -60,22 +54,9 @@ export const RedisDatabaseProvider = (): TDynamicProviderFns => { const validateProviderInputs = async (inputs: unknown) => { const appCfg = getConfig(); const isCloud = Boolean(appCfg.LICENSE_SERVER_KEY); // quick and dirty way to check if its cloud or not - const dbHost = appCfg.REDIS_URL || getDbConnectionHost(appCfg.REDIS_URL); - - const providerInputs = DynamicSecretRedisDBSchema.parse(inputs); - - if (providerInputs.client === RedisProviders.Elasticache) { - JSON.parse(providerInputs.creationStatement); - JSON.parse(providerInputs.revocationStatement); - if (providerInputs.renewStatement) { - JSON.parse(providerInputs.renewStatement); - } - - if (!providerInputs.elastiCacheRegion) { - throw new BadRequestError({ message: "elastiCacheRegion is required when client is ElastiCache" }); - } - } + const dbHost = appCfg.DB_HOST || getDbConnectionHost(appCfg.DB_CONNECTION_URI); + const providerInputs = await DynamicSecretRedisDBSchema.parseAsync(inputs); if ( isCloud && // localhost @@ -85,62 +66,36 @@ export const RedisDatabaseProvider = (): TDynamicProviderFns => { providerInputs.host.match(/^192\.168\.\d+\.\d+/)) ) throw new BadRequestError({ message: "Invalid db host" }); - if (providerInputs.host === "localhost" || dbHost === providerInputs.host) + if (providerInputs.host === "localhost" || providerInputs.host === "127.0.0.1" || dbHost === providerInputs.host) throw new BadRequestError({ message: "Invalid db host" }); return providerInputs; }; const getClient = async (providerInputs: z.infer) => { let connection: Redis | null = null; - try { - if (providerInputs.client === RedisProviders.Elasticache) { - const connectionUri = await ElastiCacheConnector( - { - host: providerInputs.host, - port: providerInputs.port, - userId: providerInputs.elastiCacheIamUsername! - }, - { - accessKeyId: providerInputs.username, - secretAccessKey: providerInputs.password! - }, - providerInputs.elastiCacheRegion! - ).createConnectionUri(); - - connection = new Redis(connectionUri, { - ...(providerInputs.ca && { - tls: { - rejectUnauthorized: false, - ca: providerInputs.ca - } - }) - }); - } else if (providerInputs.client === RedisProviders.Redis) { - connection = new Redis({ - username: providerInputs.username, - host: providerInputs.host, - port: providerInputs.port, - password: providerInputs.password || undefined, - ...(providerInputs.ca && { - tls: { - rejectUnauthorized: false, - ca: providerInputs.ca - } - }) - }); - } - - if (connection === null) { - throw new BadRequestError({ message: "Failed to obtain a valid Redis client" }); - } + connection = new Redis({ + username: providerInputs.username, + host: providerInputs.host, + port: providerInputs.port, + password: providerInputs.password, + ...(providerInputs.ca && { + tls: { + rejectUnauthorized: false, + ca: providerInputs.ca + } + }) + }); let result: string; - if (providerInputs.password && providerInputs.client === RedisProviders.Redis) { + if (providerInputs.password) { result = await connection.auth(providerInputs.username, providerInputs.password, () => {}); - if (result !== "OK") { - throw new BadRequestError({ message: `Invalid credentials, Redis returned ${result} status` }); - } + } else { + result = await connection.auth(providerInputs.username, () => {}); + } + + if (result !== "OK") { + throw new BadRequestError({ message: `Invalid credentials, Redis returned ${result} status` }); } return connection; @@ -164,54 +119,25 @@ export const RedisDatabaseProvider = (): TDynamicProviderFns => { }; const create = async (inputs: unknown, expireAt: number) => { - console.log(inputs); const providerInputs = await validateProviderInputs(inputs); const connection = await getClient(providerInputs); - const leaseUsername = generateUsername(providerInputs.client); - const leasePassword = generatePassword(); - const leaseExpiration = new Date(expireAt).toISOString(); + const username = generateUsername(); + const password = generatePassword(); + const expiration = new Date(expireAt).toISOString(); - if (providerInputs.client === RedisProviders.Redis) { - const creationStatement = handlebars.compile(providerInputs.creationStatement, { noEscape: true })({ - username: leaseUsername, - password: leasePassword, - expiration: leaseExpiration - }); - const queries = creationStatement.toString().split(";").filter(Boolean); + const creationStatement = handlebars.compile(providerInputs.creationStatement, { noEscape: true })({ + username, + password, + expiration + }); - await executeTransactions(connection, queries); + const queries = creationStatement.toString().split(";").filter(Boolean); - await connection.quit(); - return { entityId: leaseUsername, data: { DB_USERNAME: leaseUsername, DB_PASSWORD: leasePassword } }; - } - if (providerInputs.client === RedisProviders.Elasticache) { - const parsedCreationData = CreateElastiCacheUserSchema.parse(JSON.parse(providerInputs.creationStatement)); + await executeTransactions(connection, queries); - await ElastiCacheUserManager( - { - accessKeyId: providerInputs.username, - secretAccessKey: providerInputs.password! - }, - providerInputs.elastiCacheRegion! - ).createUser({ - AccessString: parsedCreationData.AccessString, - Engine: parsedCreationData.Engine, - UserId: leaseUsername, - UserName: leaseUsername, - Passwords: [leasePassword] - }); - - return { - entityId: leaseUsername, - data: { - DB_USERNAME: leaseUsername, - DB_PASSWORD: leasePassword - } - }; - } - - throw new BadRequestError({ message: "Invalid client type" }); + await connection.quit(); + return { entityId: username, data: { DB_USERNAME: username, DB_PASSWORD: password } }; }; const revoke = async (inputs: unknown, entityId: string) => { @@ -220,19 +146,6 @@ export const RedisDatabaseProvider = (): TDynamicProviderFns => { const username = entityId; - if (providerInputs.client === RedisProviders.Elasticache) { - await ElastiCacheUserManager( - { - accessKeyId: providerInputs.username, - secretAccessKey: providerInputs.password! - }, - providerInputs.elastiCacheRegion! - ).deleteUser({ UserId: username }); - - await connection.quit(); - return { entityId: username }; - } - const revokeStatement = handlebars.compile(providerInputs.revocationStatement)({ username }); const queries = revokeStatement.toString().split(";").filter(Boolean); diff --git a/backend/src/lib/aws/elasticache.ts b/backend/src/lib/aws/elasticache.ts index eced5e717..bf963d5e6 100644 --- a/backend/src/lib/aws/elasticache.ts +++ b/backend/src/lib/aws/elasticache.ts @@ -1,4 +1,3 @@ -import { Sha256 } from "@aws-crypto/sha256-js"; import { CreateUserCommand, CreateUserGroupCommand, @@ -9,9 +8,6 @@ import { ModifyReplicationGroupCommand, ModifyUserGroupCommand } from "@aws-sdk/client-elasticache"; -import { QueryParameterBag } from "@aws-sdk/types"; -import { HttpRequest } from "@smithy/protocol-http"; -import { SignatureV4 } from "@smithy/signature-v4"; import { z } from "zod"; type TElastiCacheRedisUser = { @@ -20,11 +16,6 @@ type TElastiCacheRedisUser = { }; type TBasicAWSCredentials = { accessKeyId: string; secretAccessKey: string }; -type TElastiCacheConnection = { - host: string; - port: number; - userId: string; // the redis user configured for IAM auth -}; export const CreateElastiCacheUserSchema = z.object({ UserId: z.string().trim().min(1), @@ -99,10 +90,10 @@ export const ElastiCacheUserManager = (credentials: TBasicAWSCredentials, region await elastiCache.send(addUserToGroupCommand); }; - const createUser = async (creationInput: TCreateElastiCacheUserInput) => { - await ensureInfisicalGroupExists("newtest-redis-oss"); // TODO: Make this not hardcoded (currently hardcoded for testing) + const createUser = async (creationInput: TCreateElastiCacheUserInput, clusterName: string) => { + await ensureInfisicalGroupExists(clusterName); - await elastiCache.send(new CreateUserCommand({ ...creationInput })); // First create the user + await elastiCache.send(new CreateUserCommand(creationInput)); // First create the user await addUserToInfisicalGroup(creationInput.UserId); // Then add the user to the group. We know the group is already a part of the cluster because of ensureInfisicalGroupExists() return { @@ -118,91 +109,17 @@ export const ElastiCacheUserManager = (credentials: TBasicAWSCredentials, region return { userId: deletionInput.UserId }; }; + const verifyCredentials = async (clusterName: string) => { + await elastiCache.send( + new DescribeReplicationGroupsCommand({ + ReplicationGroupId: clusterName + }) + ); + }; + return { createUser, - deleteUser + deleteUser, + verifyCredentials }; }; - -export const ElastiCacheConnector = ( - connection: TElastiCacheConnection, - credentials: TBasicAWSCredentials, - region: string, - isServerless = false -) => { - const constants = { - REQUEST_METHOD: "GET", - PARAM_ACTION: "Action", - PARAM_USER: "User", - PARAM_RESOURCE_TYPE: "ResourceType", - RESOURCE_TYPE_SERVERLESS_CACHE: "ServerlessCache", - ACTION_NAME: "connect", - SERVICE_NAME: "elasticache", - TOKEN_EXPIRY_SECONDS: 900 - }; - - const getSignableRequest = () => { - const query: Record = { - [constants.PARAM_ACTION]: constants.ACTION_NAME, - [constants.PARAM_USER]: connection.userId - }; - - if (isServerless) { - query[constants.PARAM_RESOURCE_TYPE] = constants.RESOURCE_TYPE_SERVERLESS_CACHE; - } - - return new HttpRequest({ - method: constants.REQUEST_METHOD, - hostname: `${connection.host}:${connection.port}`, - headers: { - host: `${connection.host}:${connection.port}` - }, - path: "/", - query - }); - }; - - const sign = async (request: HttpRequest) => { - const signer = new SignatureV4({ - credentials, - region, - service: constants.SERVICE_NAME, - sha256: Sha256 - }); - - const expiresIn = constants.TOKEN_EXPIRY_SECONDS; - const signedRequest = await signer.presign(request, { expiresIn }); - - // Create a new HttpRequest object with the signed properties - return new HttpRequest({ - method: signedRequest.method, - hostname: signedRequest.hostname, - headers: signedRequest.headers, - path: signedRequest.path, - query: signedRequest.query - }); - }; - - const queryToString = (query: QueryParameterBag) => { - return Object.entries(query) - .map(([key, value]) => { - if (Array.isArray(value)) { - return value.map((v) => `${encodeURIComponent(key)}=${encodeURIComponent(v)}`).join("&"); - } - if (value !== null) { - return `${encodeURIComponent(key)}=${encodeURIComponent(value)}`; - } - return encodeURIComponent(key); - }) - .join("&"); - }; - - const createConnectionUri = async () => { - const request = getSignableRequest(); - const signedRequest = await sign(request); - - return `redis://${signedRequest.hostname}${signedRequest.path}?${queryToString(signedRequest.query)}`; - }; - - return { createConnectionUri }; -}; diff --git a/docs/documentation/platform/dynamic-secrets/aws-elasticache.mdx b/docs/documentation/platform/dynamic-secrets/aws-elasticache.mdx index 401491a11..532fe0c94 100644 --- a/docs/documentation/platform/dynamic-secrets/aws-elasticache.mdx +++ b/docs/documentation/platform/dynamic-secrets/aws-elasticache.mdx @@ -9,9 +9,6 @@ The Infisical Redis dynamic secret allows you to generate Redis Database credent -1. Infisical's ElastiCache integration requires you to create a new user in the AWS ElastiCache service. This user must use IAM authentication. -![aws-iam-user](/images/platform/dynamic-secrets/aws-elasticache-iam-user.png) - 2. Create an AWS IAM user with the following permissions: ```json { @@ -21,10 +18,15 @@ The Infisical Redis dynamic secret allows you to generate Redis Database credent "Sid": "", "Effect": "Allow", "Action": [ - "elasticache:ModifyUser", "elasticache:DescribeUsers", + "elasticache:ModifyUser", "elasticache:CreateUser", - "elasticache:DeleteUser" + "elasticache:CreateUserGroup", + "elasticache:DeleteUser", + "elasticache:DescribeReplicationGroups", + "elasticache:DescribeUserGroups", + "elasticache:ModifyReplicationGroup", + "elasticache:ModifyUserGroup" ], "Resource": "arn:aws:elasticache:::user:*" } @@ -59,7 +61,7 @@ The Infisical Redis dynamic secret allows you to generate Redis Database credent ![Add Dynamic Secret Button](../../../images/platform/dynamic-secrets/add-dynamic-secret-button-redis.png) - ![Dynamic Secret Modal](../../../images/platform/dynamic-secrets/dynamic-secret-modal-redis.png) + ![Dynamic Secret Modal](../../../images/platform/dynamic-secrets/dynamic-secret-modal-aws-elasti-cache) @@ -75,18 +77,10 @@ The Infisical Redis dynamic secret allows you to generate Redis Database credent - + The region that the ElastiCache cluster is located in. _(e.g. us-east-1)_ - - The database host, this can be an IP address or a domain name as long as Infisical can reach it. - - - - The database port, this is the port that the Redis instance is listening on. - - This is the access key ID of the AWS IAM user you created in the prerequisites. This will be used to provision and manage the dynamic secret leases. diff --git a/docs/images/platform/dynamic-secrets/aws-elasticache-iam-user.png b/docs/images/platform/dynamic-secrets/aws-elasticache-iam-user.png deleted file mode 100644 index 60cc4c921..000000000 Binary files a/docs/images/platform/dynamic-secrets/aws-elasticache-iam-user.png and /dev/null differ diff --git a/docs/images/platform/dynamic-secrets/dynamic-secret-modal-aws-elasti-cache.png b/docs/images/platform/dynamic-secrets/dynamic-secret-modal-aws-elasti-cache.png new file mode 100644 index 000000000..bf461c6a6 Binary files /dev/null and b/docs/images/platform/dynamic-secrets/dynamic-secret-modal-aws-elasti-cache.png differ diff --git a/docs/images/platform/dynamic-secrets/modify-elasticache-statement.png b/docs/images/platform/dynamic-secrets/modify-elasticache-statement.png index 60cbae987..c8cd662d0 100644 Binary files a/docs/images/platform/dynamic-secrets/modify-elasticache-statement.png and b/docs/images/platform/dynamic-secrets/modify-elasticache-statement.png differ diff --git a/frontend/src/hooks/api/dynamicSecret/types.ts b/frontend/src/hooks/api/dynamicSecret/types.ts index ef7bcceb9..ff881e506 100644 --- a/frontend/src/hooks/api/dynamicSecret/types.ts +++ b/frontend/src/hooks/api/dynamicSecret/types.ts @@ -19,7 +19,8 @@ export enum DynamicSecretProviders { SqlDatabase = "sql-database", Cassandra = "cassandra", AwsIam = "aws-iam", - Redis = "redis" + Redis = "redis", + AwsElastiCache = "aws-elasticache" } export enum SqlProviders { @@ -29,11 +30,6 @@ export enum SqlProviders { MsSQL = "mssql" } -export enum RedisProviders { - Redis = "redis", - Elasticache = "elasticache" -} - export type TDynamicSecretProvider = | { type: DynamicSecretProviders.SqlDatabase; @@ -89,6 +85,18 @@ export type TDynamicSecretProvider = revocationStatement: string; ca?: string | undefined; }; + } + | { + type: DynamicSecretProviders.AwsElastiCache; + inputs: { + clusterName: string; + accessKeyId: string; + secretAccessKey: string; + region: string; + creationStatement: string; + revocationStatement: string; + ca?: string | undefined; + }; }; export type TCreateDynamicSecretDTO = { diff --git a/frontend/src/views/SecretMainPage/components/ActionBar/CreateDynamicSecretForm/AwsElastiCacheInputForm.tsx b/frontend/src/views/SecretMainPage/components/ActionBar/CreateDynamicSecretForm/AwsElastiCacheInputForm.tsx new file mode 100644 index 000000000..72db480db --- /dev/null +++ b/frontend/src/views/SecretMainPage/components/ActionBar/CreateDynamicSecretForm/AwsElastiCacheInputForm.tsx @@ -0,0 +1,318 @@ +import { Controller, useForm } from "react-hook-form"; +import { zodResolver } from "@hookform/resolvers/zod"; +import ms from "ms"; +import { z } from "zod"; + +import { TtlFormLabel } from "@app/components/features"; +import { createNotification } from "@app/components/notifications"; +import { + Accordion, + AccordionContent, + AccordionItem, + AccordionTrigger, + Button, + FormControl, + Input, + SecretInput, + TextArea +} from "@app/components/v2"; +import { useCreateDynamicSecret } from "@app/hooks/api"; +import { DynamicSecretProviders } from "@app/hooks/api/dynamicSecret/types"; + +const formSchema = z.object({ + provider: z.object({ + clusterName: z.string().trim().min(1), + accessKeyId: z.string().trim().min(1), + secretAccessKey: z.string().trim().min(1), + + region: z.string().trim(), + creationStatement: z.string().trim(), + revocationStatement: z.string().trim(), + ca: z.string().optional() + }), + defaultTTL: z.string().superRefine((val, ctx) => { + const valMs = ms(val); + if (valMs < 60 * 1000) + ctx.addIssue({ code: z.ZodIssueCode.custom, message: "TTL must be a greater than 1min" }); + // a day + if (valMs > 24 * 60 * 60 * 1000) + ctx.addIssue({ code: z.ZodIssueCode.custom, message: "TTL must be less than a day" }); + }), + maxTTL: z + .string() + .optional() + .superRefine((val, ctx) => { + if (!val) return; + const valMs = ms(val); + if (valMs < 60 * 1000) + ctx.addIssue({ code: z.ZodIssueCode.custom, message: "TTL must be a greater than 1min" }); + // a day + if (valMs > 24 * 60 * 60 * 1000) + ctx.addIssue({ code: z.ZodIssueCode.custom, message: "TTL must be less than a day" }); + }), + name: z.string().refine((val) => val.toLowerCase() === val, "Must be lowercase") +}); +type TForm = z.infer; + +type Props = { + onCompleted: () => void; + onCancel: () => void; + secretPath: string; + projectSlug: string; + environment: string; +}; + +export const AwsElastiCacheInputForm = ({ + onCompleted, + onCancel, + environment, + secretPath, + projectSlug +}: Props) => { + const { + control, + formState: { isSubmitting, errors }, + handleSubmit + } = useForm({ + resolver: zodResolver(formSchema), + defaultValues: { + provider: { + creationStatement: `{ + "UserId": "{{username}}", + "UserName": "{{username}}", + "Engine": "redis", + "Passwords": ["{{password}}"], + "AccessString": "on ~* +@all" +}`, + revocationStatement: `{ + "UserId": "{{username}}" +}` + } + } + }); + + const createDynamicSecret = useCreateDynamicSecret(); + + console.log("formState", errors); + const handleCreateDynamicSecret = async ({ name, maxTTL, provider, defaultTTL }: TForm) => { + // wait till previous request is finished + if (createDynamicSecret.isLoading) return; + try { + await createDynamicSecret.mutateAsync({ + provider: { type: DynamicSecretProviders.AwsElastiCache, inputs: provider }, + maxTTL, + name, + path: secretPath, + defaultTTL, + projectSlug, + environmentSlug: environment + }); + onCompleted(); + } catch (err) { + createNotification({ + type: "error", + text: "Failed to create dynamic secret" + }); + } + }; + + return ( +
+
+
+
+
+ ( + + + + )} + /> +
+
+ ( + } + isError={Boolean(error?.message)} + errorText={error?.message} + > + + + )} + /> +
+
+ ( + } + isError={Boolean(error?.message)} + errorText={error?.message} + > + + + )} + /> +
+
+
+
+ Configuration +
+
+
+ ( + + + + )} + /> + ( + + + + )} + /> +
+
+ ( + + + + )} + /> + ( + + + + )} + /> +
+
+ ( + + + + )} + /> + + + Modify ElastiCache Statements + + ( + +