Fix: Refactored aws elasticache to separate provider

This commit is contained in:
Daniel Hougaard
2024-08-28 17:57:19 +04:00
parent a598665b2f
commit 21750a8c20
17 changed files with 1061 additions and 685 deletions
@@ -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
};
};
@@ -1,3 +1,4 @@
import { AwsElastiCacheDatabaseProvider } from "./aws-elasticache";
import { AwsIamProvider } from "./aws-iam"; import { AwsIamProvider } from "./aws-iam";
import { CassandraProvider } from "./cassandra"; import { CassandraProvider } from "./cassandra";
import { DynamicSecretProviders } from "./models"; import { DynamicSecretProviders } from "./models";
@@ -8,5 +9,6 @@ export const buildDynamicSecretProviders = () => ({
[DynamicSecretProviders.SqlDatabase]: SqlDatabaseProvider(), [DynamicSecretProviders.SqlDatabase]: SqlDatabaseProvider(),
[DynamicSecretProviders.Cassandra]: CassandraProvider(), [DynamicSecretProviders.Cassandra]: CassandraProvider(),
[DynamicSecretProviders.AwsIam]: AwsIamProvider(), [DynamicSecretProviders.AwsIam]: AwsIamProvider(),
[DynamicSecretProviders.Redis]: RedisDatabaseProvider() [DynamicSecretProviders.Redis]: RedisDatabaseProvider(),
[DynamicSecretProviders.AwsElastiCache]: AwsElastiCacheDatabaseProvider()
}); });
@@ -7,51 +7,28 @@ export enum SqlProviders {
MsSQL = "mssql" MsSQL = "mssql"
} }
export enum RedisProviders { export const DynamicSecretRedisDBSchema = z.object({
Redis = "redis", host: z.string().trim().toLowerCase(),
Elasticache = "elasticache" port: z.number(),
} username: z.string().trim(), // this is often "default".
password: z.string().trim().optional(),
export const DynamicSecretRedisDBSchema = z creationStatement: z.string().trim(),
.object({ revocationStatement: z.string().trim(),
client: z.nativeEnum(RedisProviders), renewStatement: z.string().trim().optional(),
host: z.string().trim().toLowerCase(), ca: z.string().optional()
port: z.number(), });
username: z.string().trim(), // this is often "default".
password: z.string().trim().optional(),
elastiCacheIamUsername: z.string().trim().optional(), export const DynamicSecretAwsElastiCacheSchema = z.object({
elastiCacheRegion: z.string().trim().optional(), clusterName: z.string().trim().min(1),
accessKeyId: z.string().trim().min(1),
secretAccessKey: z.string().trim().min(1),
creationStatement: z.string().trim(), region: z.string().trim(),
revocationStatement: z.string().trim(), creationStatement: z.string().trim(),
renewStatement: z.string().trim().optional(), revocationStatement: z.string().trim(),
ca: z.string().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"]
}
);
export const DynamicSecretSqlDBSchema = z.object({ export const DynamicSecretSqlDBSchema = z.object({
client: z.nativeEnum(SqlProviders), client: z.nativeEnum(SqlProviders),
@@ -94,14 +71,16 @@ export enum DynamicSecretProviders {
SqlDatabase = "sql-database", SqlDatabase = "sql-database",
Cassandra = "cassandra", Cassandra = "cassandra",
AwsIam = "aws-iam", AwsIam = "aws-iam",
Redis = "redis" Redis = "redis",
AwsElastiCache = "aws-elasticache"
} }
export const DynamicSecretProviderSchema = z.discriminatedUnion("type", [ export const DynamicSecretProviderSchema = z.discriminatedUnion("type", [
z.object({ type: z.literal(DynamicSecretProviders.SqlDatabase), inputs: DynamicSecretSqlDBSchema }), z.object({ type: z.literal(DynamicSecretProviders.SqlDatabase), inputs: DynamicSecretSqlDBSchema }),
z.object({ type: z.literal(DynamicSecretProviders.Cassandra), inputs: DynamicSecretCassandraSchema }), z.object({ type: z.literal(DynamicSecretProviders.Cassandra), inputs: DynamicSecretCassandraSchema }),
z.object({ type: z.literal(DynamicSecretProviders.AwsIam), inputs: DynamicSecretAwsIamSchema }), 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 = { export type TDynamicProviderFns = {
@@ -4,25 +4,19 @@ import { Redis } from "ioredis";
import { customAlphabet } from "nanoid"; import { customAlphabet } from "nanoid";
import { z } from "zod"; import { z } from "zod";
import { CreateElastiCacheUserSchema, ElastiCacheConnector, ElastiCacheUserManager } from "@app/lib/aws";
import { getConfig } from "@app/lib/config/env"; import { getConfig } from "@app/lib/config/env";
import { BadRequestError } from "@app/lib/errors"; import { BadRequestError } from "@app/lib/errors";
import { getDbConnectionHost } from "@app/lib/knex"; import { getDbConnectionHost } from "@app/lib/knex";
import { alphaNumericNanoId } from "@app/lib/nanoid"; import { alphaNumericNanoId } from "@app/lib/nanoid";
import { DynamicSecretRedisDBSchema, RedisProviders, TDynamicProviderFns } from "./models"; import { DynamicSecretRedisDBSchema, TDynamicProviderFns } from "./models";
const generatePassword = () => { const generatePassword = () => {
const charset = "abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789-_.~!*$#"; const charset = "abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789-_.~!*$#";
return customAlphabet(charset, 64)(); return customAlphabet(charset, 64)();
}; };
const generateUsername = (provider: RedisProviders) => { const generateUsername = () => {
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-"
}
return alphaNumericNanoId(32); return alphaNumericNanoId(32);
}; };
@@ -60,22 +54,9 @@ export const RedisDatabaseProvider = (): TDynamicProviderFns => {
const validateProviderInputs = async (inputs: unknown) => { const validateProviderInputs = async (inputs: unknown) => {
const appCfg = getConfig(); const appCfg = getConfig();
const isCloud = Boolean(appCfg.LICENSE_SERVER_KEY); // quick and dirty way to check if its cloud or not 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 dbHost = appCfg.DB_HOST || getDbConnectionHost(appCfg.DB_CONNECTION_URI);
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 providerInputs = await DynamicSecretRedisDBSchema.parseAsync(inputs);
if ( if (
isCloud && isCloud &&
// localhost // localhost
@@ -85,62 +66,36 @@ export const RedisDatabaseProvider = (): TDynamicProviderFns => {
providerInputs.host.match(/^192\.168\.\d+\.\d+/)) providerInputs.host.match(/^192\.168\.\d+\.\d+/))
) )
throw new BadRequestError({ message: "Invalid db host" }); 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" }); throw new BadRequestError({ message: "Invalid db host" });
return providerInputs; return providerInputs;
}; };
const getClient = async (providerInputs: z.infer<typeof DynamicSecretRedisDBSchema>) => { const getClient = async (providerInputs: z.infer<typeof DynamicSecretRedisDBSchema>) => {
let connection: Redis | null = null; let connection: Redis | null = null;
try { try {
if (providerInputs.client === RedisProviders.Elasticache) { connection = new Redis({
const connectionUri = await ElastiCacheConnector( username: providerInputs.username,
{ host: providerInputs.host,
host: providerInputs.host, port: providerInputs.port,
port: providerInputs.port, password: providerInputs.password,
userId: providerInputs.elastiCacheIamUsername! ...(providerInputs.ca && {
}, tls: {
{ rejectUnauthorized: false,
accessKeyId: providerInputs.username, ca: providerInputs.ca
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" });
}
let result: string; let result: string;
if (providerInputs.password && providerInputs.client === RedisProviders.Redis) { if (providerInputs.password) {
result = await connection.auth(providerInputs.username, providerInputs.password, () => {}); result = await connection.auth(providerInputs.username, providerInputs.password, () => {});
if (result !== "OK") { } else {
throw new BadRequestError({ message: `Invalid credentials, Redis returned ${result} status` }); result = await connection.auth(providerInputs.username, () => {});
} }
if (result !== "OK") {
throw new BadRequestError({ message: `Invalid credentials, Redis returned ${result} status` });
} }
return connection; return connection;
@@ -164,54 +119,25 @@ export const RedisDatabaseProvider = (): TDynamicProviderFns => {
}; };
const create = async (inputs: unknown, expireAt: number) => { const create = async (inputs: unknown, expireAt: number) => {
console.log(inputs);
const providerInputs = await validateProviderInputs(inputs); const providerInputs = await validateProviderInputs(inputs);
const connection = await getClient(providerInputs); const connection = await getClient(providerInputs);
const leaseUsername = generateUsername(providerInputs.client); const username = generateUsername();
const leasePassword = generatePassword(); const password = generatePassword();
const leaseExpiration = new Date(expireAt).toISOString(); const expiration = new Date(expireAt).toISOString();
if (providerInputs.client === RedisProviders.Redis) { const creationStatement = handlebars.compile(providerInputs.creationStatement, { noEscape: true })({
const creationStatement = handlebars.compile(providerInputs.creationStatement, { noEscape: true })({ username,
username: leaseUsername, password,
password: leasePassword, expiration
expiration: leaseExpiration });
});
const queries = creationStatement.toString().split(";").filter(Boolean);
await executeTransactions(connection, queries); const queries = creationStatement.toString().split(";").filter(Boolean);
await connection.quit(); await executeTransactions(connection, queries);
return { entityId: leaseUsername, data: { DB_USERNAME: leaseUsername, DB_PASSWORD: leasePassword } };
}
if (providerInputs.client === RedisProviders.Elasticache) {
const parsedCreationData = CreateElastiCacheUserSchema.parse(JSON.parse(providerInputs.creationStatement));
await ElastiCacheUserManager( await connection.quit();
{ return { entityId: username, data: { DB_USERNAME: username, DB_PASSWORD: password } };
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" });
}; };
const revoke = async (inputs: unknown, entityId: string) => { const revoke = async (inputs: unknown, entityId: string) => {
@@ -220,19 +146,6 @@ export const RedisDatabaseProvider = (): TDynamicProviderFns => {
const username = entityId; 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 revokeStatement = handlebars.compile(providerInputs.revocationStatement)({ username });
const queries = revokeStatement.toString().split(";").filter(Boolean); const queries = revokeStatement.toString().split(";").filter(Boolean);
+13 -96
View File
@@ -1,4 +1,3 @@
import { Sha256 } from "@aws-crypto/sha256-js";
import { import {
CreateUserCommand, CreateUserCommand,
CreateUserGroupCommand, CreateUserGroupCommand,
@@ -9,9 +8,6 @@ import {
ModifyReplicationGroupCommand, ModifyReplicationGroupCommand,
ModifyUserGroupCommand ModifyUserGroupCommand
} from "@aws-sdk/client-elasticache"; } 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"; import { z } from "zod";
type TElastiCacheRedisUser = { type TElastiCacheRedisUser = {
@@ -20,11 +16,6 @@ type TElastiCacheRedisUser = {
}; };
type TBasicAWSCredentials = { accessKeyId: string; secretAccessKey: string }; 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({ export const CreateElastiCacheUserSchema = z.object({
UserId: z.string().trim().min(1), UserId: z.string().trim().min(1),
@@ -99,10 +90,10 @@ export const ElastiCacheUserManager = (credentials: TBasicAWSCredentials, region
await elastiCache.send(addUserToGroupCommand); await elastiCache.send(addUserToGroupCommand);
}; };
const createUser = async (creationInput: TCreateElastiCacheUserInput) => { const createUser = async (creationInput: TCreateElastiCacheUserInput, clusterName: string) => {
await ensureInfisicalGroupExists("newtest-redis-oss"); // TODO: Make this not hardcoded (currently hardcoded for testing) 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() 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 { return {
@@ -118,91 +109,17 @@ export const ElastiCacheUserManager = (credentials: TBasicAWSCredentials, region
return { userId: deletionInput.UserId }; return { userId: deletionInput.UserId };
}; };
const verifyCredentials = async (clusterName: string) => {
await elastiCache.send(
new DescribeReplicationGroupsCommand({
ReplicationGroupId: clusterName
})
);
};
return { return {
createUser, 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<string, string> = {
[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 };
};
@@ -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: 2. Create an AWS IAM user with the following permissions:
```json ```json
{ {
@@ -21,10 +18,15 @@ The Infisical Redis dynamic secret allows you to generate Redis Database credent
"Sid": "", "Sid": "",
"Effect": "Allow", "Effect": "Allow",
"Action": [ "Action": [
"elasticache:ModifyUser",
"elasticache:DescribeUsers", "elasticache:DescribeUsers",
"elasticache:ModifyUser",
"elasticache:CreateUser", "elasticache:CreateUser",
"elasticache:DeleteUser" "elasticache:CreateUserGroup",
"elasticache:DeleteUser",
"elasticache:DescribeReplicationGroups",
"elasticache:DescribeUserGroups",
"elasticache:ModifyReplicationGroup",
"elasticache:ModifyUserGroup"
], ],
"Resource": "arn:aws:elasticache:<region>:<account-id>:user:*" "Resource": "arn:aws:elasticache:<region>:<account-id>: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) ![Add Dynamic Secret Button](../../../images/platform/dynamic-secrets/add-dynamic-secret-button-redis.png)
</Step> </Step>
<Step title="Select 'Redis'"> <Step title="Select 'Redis'">
![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)
</Step> </Step>
<Step title="Provide the inputs for dynamic secret parameters"> <Step title="Provide the inputs for dynamic secret parameters">
<ParamField path="Secret Name" type="string" required> <ParamField path="Secret Name" type="string" required>
@@ -75,18 +77,10 @@ The Infisical Redis dynamic secret allows you to generate Redis Database credent
</ParamField> </ParamField>
<ParamField path="AWS Region" type="string" required> <ParamField path="Region" type="string" required>
The region that the ElastiCache cluster is located in. _(e.g. us-east-1)_ The region that the ElastiCache cluster is located in. _(e.g. us-east-1)_
</ParamField> </ParamField>
<ParamField path="Host" type="string" required>
The database host, this can be an IP address or a domain name as long as Infisical can reach it.
</ParamField>
<ParamField path="Port" type="number" required>
The database port, this is the port that the Redis instance is listening on.
</ParamField>
<ParamField path="Access Key ID" type="string" required> <ParamField path="Access Key ID" type="string" required>
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. 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.
</ParamField> </ParamField>
Binary file not shown.

Before

Width:  |  Height:  |  Size: 170 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 157 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 195 KiB

After

Width:  |  Height:  |  Size: 192 KiB

+14 -6
View File
@@ -19,7 +19,8 @@ export enum DynamicSecretProviders {
SqlDatabase = "sql-database", SqlDatabase = "sql-database",
Cassandra = "cassandra", Cassandra = "cassandra",
AwsIam = "aws-iam", AwsIam = "aws-iam",
Redis = "redis" Redis = "redis",
AwsElastiCache = "aws-elasticache"
} }
export enum SqlProviders { export enum SqlProviders {
@@ -29,11 +30,6 @@ export enum SqlProviders {
MsSQL = "mssql" MsSQL = "mssql"
} }
export enum RedisProviders {
Redis = "redis",
Elasticache = "elasticache"
}
export type TDynamicSecretProvider = export type TDynamicSecretProvider =
| { | {
type: DynamicSecretProviders.SqlDatabase; type: DynamicSecretProviders.SqlDatabase;
@@ -89,6 +85,18 @@ export type TDynamicSecretProvider =
revocationStatement: string; revocationStatement: string;
ca?: string | undefined; 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 = { export type TCreateDynamicSecretDTO = {
@@ -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<typeof formSchema>;
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<TForm>({
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 (
<div>
<form onSubmit={handleSubmit(handleCreateDynamicSecret)} autoComplete="off">
<div>
<div className="flex items-center space-x-2">
<div className="flex-grow">
<Controller
control={control}
defaultValue=""
name="name"
render={({ field, fieldState: { error } }) => (
<FormControl
label="Secret Name"
isError={Boolean(error)}
errorText={error?.message}
>
<Input {...field} placeholder="dynamic-secret" />
</FormControl>
)}
/>
</div>
<div className="w-32">
<Controller
control={control}
name="defaultTTL"
defaultValue="1h"
render={({ field, fieldState: { error } }) => (
<FormControl
label={<TtlFormLabel label="Default TTL" />}
isError={Boolean(error?.message)}
errorText={error?.message}
>
<Input {...field} />
</FormControl>
)}
/>
</div>
<div className="w-32">
<Controller
control={control}
name="maxTTL"
defaultValue="24h"
render={({ field, fieldState: { error } }) => (
<FormControl
label={<TtlFormLabel label="Max TTL" />}
isError={Boolean(error?.message)}
errorText={error?.message}
>
<Input {...field} />
</FormControl>
)}
/>
</div>
</div>
<div>
<div className="mb-4 mt-4 border-b border-mineshaft-500 pb-2 pl-1 font-medium text-mineshaft-200">
Configuration
</div>
<div className="flex flex-col">
<div className="flex items-center space-x-2">
<Controller
control={control}
name="provider.clusterName"
defaultValue=""
render={({ field, fieldState: { error } }) => (
<FormControl
label="Cluster name"
className="flex-grow"
isError={Boolean(error?.message)}
errorText={error?.message}
>
<Input {...field} placeholder="redis-oss-cluster" />
</FormControl>
)}
/>
<Controller
control={control}
defaultValue=""
name="provider.region"
render={({ field, fieldState: { error } }) => (
<FormControl
label="Region"
isError={Boolean(error?.message)}
errorText={error?.message}
>
<Input placeholder="us-east-1" {...field} type="text" />
</FormControl>
)}
/>
</div>
<div className="flex w-full items-center space-x-2">
<Controller
control={control}
name="provider.accessKeyId"
defaultValue=""
render={({ field, fieldState: { error } }) => (
<FormControl
label="Access Key ID"
className="w-full"
isError={Boolean(error?.message)}
errorText={error?.message}
>
<Input {...field} autoComplete="off" />
</FormControl>
)}
/>
<Controller
control={control}
name="provider.secretAccessKey"
render={({ field, fieldState: { error } }) => (
<FormControl
label="Secret Access Key"
className="w-full"
isError={Boolean(error?.message)}
errorText={error?.message}
>
<Input {...field} type="password" autoComplete="new-password" />
</FormControl>
)}
/>
</div>
<div>
<Controller
control={control}
name="provider.ca"
render={({ field, fieldState: { error } }) => (
<FormControl
isOptional
label="CA(SSL)"
isError={Boolean(error?.message)}
errorText={error?.message}
>
<SecretInput
{...field}
containerClassName="text-bunker-300 hover:border-primary-400/50 border border-mineshaft-600 bg-mineshaft-900 px-2 py-1.5"
/>
</FormControl>
)}
/>
<Accordion type="single" collapsible className="mb-2 w-full bg-mineshaft-700">
<AccordionItem value="advance-statements">
<AccordionTrigger>Modify ElastiCache Statements</AccordionTrigger>
<AccordionContent>
<Controller
control={control}
name="provider.creationStatement"
render={({ field, fieldState: { error } }) => (
<FormControl
label="Creation Statement"
isError={Boolean(error?.message)}
errorText={error?.message}
helperText="username, password and expiration are dynamically provisioned"
>
<TextArea
{...field}
reSize="none"
rows={3}
className="border-mineshaft-600 bg-mineshaft-900 text-sm"
/>
</FormControl>
)}
/>
<Controller
control={control}
name="provider.revocationStatement"
render={({ field, fieldState: { error } }) => (
<FormControl
label="Revocation Statement"
isError={Boolean(error?.message)}
errorText={error?.message}
helperText="username is dynamically provisioned"
>
<TextArea
{...field}
reSize="none"
rows={3}
className="border-mineshaft-600 bg-mineshaft-900 text-sm"
/>
</FormControl>
)}
/>
</AccordionContent>
</AccordionItem>
</Accordion>
</div>
</div>
</div>
</div>
<div className="mt-4 flex items-center space-x-4">
<Button type="submit" isLoading={isSubmitting}>
Submit
</Button>
<Button variant="outline_bg" onClick={onCancel}>
Cancel
</Button>
</div>
</form>
</div>
);
};
@@ -7,6 +7,7 @@ import { AnimatePresence, motion } from "framer-motion";
import { Modal, ModalContent } from "@app/components/v2"; import { Modal, ModalContent } from "@app/components/v2";
import { DynamicSecretProviders } from "@app/hooks/api/dynamicSecret/types"; import { DynamicSecretProviders } from "@app/hooks/api/dynamicSecret/types";
import { AwsElastiCacheInputForm } from "./AwsElastiCacheInputForm";
import { AwsIamInputForm } from "./AwsIamInputForm"; import { AwsIamInputForm } from "./AwsIamInputForm";
import { CassandraInputForm } from "./CassandraInputForm"; import { CassandraInputForm } from "./CassandraInputForm";
import { RedisInputForm } from "./RedisInputForm"; import { RedisInputForm } from "./RedisInputForm";
@@ -41,6 +42,11 @@ const DYNAMIC_SECRET_LIST = [
provider: DynamicSecretProviders.Redis, provider: DynamicSecretProviders.Redis,
title: "Redis" title: "Redis"
}, },
{
icon: faAws,
provider: DynamicSecretProviders.AwsElastiCache,
title: "AWS ElastiCache"
},
{ {
icon: faAws, icon: faAws,
provider: DynamicSecretProviders.AwsIam, provider: DynamicSecretProviders.AwsIam,
@@ -142,6 +148,24 @@ export const CreateDynamicSecretForm = ({
/> />
</motion.div> </motion.div>
)} )}
{wizardStep === WizardSteps.ProviderInputs &&
selectedProvider === DynamicSecretProviders.AwsElastiCache && (
<motion.div
key="dynamic-aws-elasticache-step"
transition={{ duration: 0.1 }}
initial={{ opacity: 0, translateX: 30 }}
animate={{ opacity: 1, translateX: 0 }}
exit={{ opacity: 0, translateX: -30 }}
>
<AwsElastiCacheInputForm
onCompleted={handleFormReset}
onCancel={handleFormReset}
projectSlug={projectSlug}
secretPath={secretPath}
environment={environment}
/>
</motion.div>
)}
{wizardStep === WizardSteps.ProviderInputs && {wizardStep === WizardSteps.ProviderInputs &&
selectedProvider === DynamicSecretProviders.Cassandra && ( selectedProvider === DynamicSecretProviders.Cassandra && (
<motion.div <motion.div
@@ -1,4 +1,3 @@
import { useEffect } from "react";
import { Controller, useForm } from "react-hook-form"; import { Controller, useForm } from "react-hook-form";
import { zodResolver } from "@hookform/resolvers/zod"; import { zodResolver } from "@hookform/resolvers/zod";
import ms from "ms"; import ms from "ms";
@@ -15,54 +14,22 @@ import {
FormControl, FormControl,
Input, Input,
SecretInput, SecretInput,
Select,
SelectItem,
TextArea TextArea
} from "@app/components/v2"; } from "@app/components/v2";
import { useCreateDynamicSecret } from "@app/hooks/api"; import { useCreateDynamicSecret } from "@app/hooks/api";
import { DynamicSecretProviders, RedisProviders } from "@app/hooks/api/dynamicSecret/types"; import { DynamicSecretProviders } from "@app/hooks/api/dynamicSecret/types";
const formSchema = z.object({ const formSchema = z.object({
provider: z provider: z.object({
.object({ host: z.string().toLowerCase().min(1),
client: z.nativeEnum(RedisProviders), port: z.coerce.number(),
host: z.string().toLowerCase().min(1), username: z.string().min(1),
port: z.coerce.number(), password: z.string().min(1).optional(),
username: z.string().min(1), // In case of Elasticache, this is accessKeyId creationStatement: z.string().min(1),
password: z.string().min(1).optional(), // In case of Elasticache, this is secretAccessKey renewStatement: z.string().optional(),
revocationStatement: z.string().min(1),
elastiCacheIamUsername: z.string().trim().optional(), ca: z.string().optional()
elastiCacheRegion: z.string().trim().optional(), }),
creationStatement: z.string().min(1),
renewStatement: z.string().optional(),
revocationStatement: z.string().min(1),
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: "AWS region is required when using ElastiCache",
path: ["elastiCacheRegion"]
}
),
defaultTTL: z.string().superRefine((val, ctx) => { defaultTTL: z.string().superRefine((val, ctx) => {
const valMs = ms(val); const valMs = ms(val);
if (valMs < 60 * 1000) if (valMs < 60 * 1000)
@@ -105,15 +72,15 @@ export const RedisInputForm = ({
const { const {
control, control,
formState: { isSubmitting }, formState: { isSubmitting },
handleSubmit, handleSubmit
setValue,
watch
} = useForm<TForm>({ } = useForm<TForm>({
resolver: zodResolver(formSchema), resolver: zodResolver(formSchema),
defaultValues: { defaultValues: {
provider: { provider: {
client: RedisProviders.Redis, username: "default",
username: "default" port: 6379,
creationStatement: "ACL SETUSER {{username}} on >{{password}} ~* &* +@all",
revocationStatement: "ACL DELUSER {{username}}"
} }
} }
}); });
@@ -142,52 +109,6 @@ export const RedisInputForm = ({
} }
}; };
const getRedisStatements = (type: RedisProviders) => {
const defaultRedisStatements = {
creationStatement: "ACL SETUSER {{username}} on >{{password}} ~* &* +@all",
revocationStatement: "ACL DELUSER {{username}}",
renewStatement: ""
};
if (type === RedisProviders.Redis) {
return defaultRedisStatements;
}
if (type === RedisProviders.Elasticache) {
return {
creationStatement: `{
"UserId": "{{username}}",
"UserName": "{{username}}",
"Engine": "redis",
"Passwords": ["{{password}}"],
"AccessString": "on ~* +@all"
}`,
revocationStatement: `{
"UserId": "{{username}}"
}`,
renewStatement: ""
};
}
return defaultRedisStatements;
};
const selectedProvider = watch("provider.client");
const handleDatabaseChange = (type: RedisProviders) => {
const redisStatement = getRedisStatements(type);
setValue("provider.creationStatement", redisStatement.creationStatement);
setValue("provider.renewStatement", redisStatement.renewStatement);
setValue("provider.revocationStatement", redisStatement.revocationStatement);
if (type === RedisProviders.Elasticache) {
setValue("provider.username", "");
}
};
useEffect(() => {
handleDatabaseChange(selectedProvider);
}, []);
return ( return (
<div> <div>
<form onSubmit={handleSubmit(handleCreateDynamicSecret)} autoComplete="off"> <form onSubmit={handleSubmit(handleCreateDynamicSecret)} autoComplete="off">
@@ -247,88 +168,45 @@ export const RedisInputForm = ({
Configuration Configuration
</div> </div>
<div className="flex flex-col"> <div className="flex flex-col">
<div className="flex w-full items-center gap-2"> <div className="flex items-center space-x-2">
<Controller <Controller
control={control} control={control}
name="provider.client" name="provider.host"
defaultValue={RedisProviders.Redis} defaultValue=""
render={({ field: { value, onChange }, fieldState: { error } }) => ( render={({ field, fieldState: { error } }) => (
<FormControl <FormControl
label="Service" label="Host"
className="w-full" className="flex-grow"
isError={Boolean(error?.message)} isError={Boolean(error?.message)}
errorText={error?.message} errorText={error?.message}
> >
<Select <Input {...field} />
value={value}
onValueChange={(val) => {
onChange(val);
handleDatabaseChange(val as RedisProviders);
}}
className="w-full border border-mineshaft-500"
>
<SelectItem value={RedisProviders.Redis}>Redis</SelectItem>
<SelectItem value={RedisProviders.Elasticache}>AWS ElastiCache</SelectItem>
</Select>
</FormControl> </FormControl>
)} )}
/> />
{selectedProvider === RedisProviders.Elasticache && (
<Controller
control={control}
name="provider.elastiCacheRegion"
defaultValue=""
render={({ field, fieldState: { error } }) => (
<FormControl
label="AWS Region"
className="w-full"
isError={Boolean(error?.message)}
errorText={error?.message}
>
<Input {...field} placeholder="us-east-1" />
</FormControl>
)}
/>
)}
</div>
<Controller
control={control}
name="provider.host"
defaultValue=""
render={({ field, fieldState: { error } }) => (
<FormControl
label="Host"
className="flex-grow"
isError={Boolean(error?.message)}
errorText={error?.message}
>
<Input {...field} />
</FormControl>
)}
/>
<Controller
control={control}
name="provider.port"
defaultValue={6379}
render={({ field, fieldState: { error } }) => (
<FormControl
label="Port"
isError={Boolean(error?.message)}
errorText={error?.message}
>
<Input {...field} type="number" />
</FormControl>
)}
/>
</div>
{selectedProvider === RedisProviders.Elasticache && (
<div className="flex w-full">
<Controller <Controller
control={control} control={control}
name="provider.elastiCacheIamUsername" name="provider.port"
defaultValue={5432}
render={({ field, fieldState: { error } }) => ( render={({ field, fieldState: { error } }) => (
<FormControl <FormControl
label="Redis Username" label="Port"
isError={Boolean(error?.message)}
errorText={error?.message}
>
<Input {...field} type="number" />
</FormControl>
)}
/>
</div>
<div className="flex w-full items-center space-x-2">
<Controller
control={control}
name="provider.username"
defaultValue=""
render={({ field, fieldState: { error } }) => (
<FormControl
label="User"
className="w-full" className="w-full"
isError={Boolean(error?.message)} isError={Boolean(error?.message)}
errorText={error?.message} errorText={error?.message}
@@ -337,132 +215,105 @@ export const RedisInputForm = ({
</FormControl> </FormControl>
)} )}
/> />
<Controller
control={control}
name="provider.password"
render={({ field, fieldState: { error } }) => (
<FormControl
tooltipText="Required if your Redis instance is password protected."
label="Password"
className="w-full"
isError={Boolean(error?.message)}
errorText={error?.message}
>
<Input {...field} type="password" autoComplete="new-password" />
</FormControl>
)}
/>
</div>
<div>
<Controller
control={control}
name="provider.ca"
render={({ field, fieldState: { error } }) => (
<FormControl
isOptional
label="CA(SSL)"
isError={Boolean(error?.message)}
errorText={error?.message}
>
<SecretInput
{...field}
containerClassName="text-bunker-300 hover:border-primary-400/50 border border-mineshaft-600 bg-mineshaft-900 px-2 py-1.5"
/>
</FormControl>
)}
/>
<Accordion type="single" collapsible className="mb-2 w-full bg-mineshaft-700">
<AccordionItem value="advance-statements">
<AccordionTrigger>Modify Redis Statements</AccordionTrigger>
<AccordionContent>
<Controller
control={control}
name="provider.creationStatement"
render={({ field, fieldState: { error } }) => (
<FormControl
label="Creation Statement"
isError={Boolean(error?.message)}
errorText={error?.message}
helperText="username, password and expiration are dynamically provisioned"
>
<TextArea
{...field}
reSize="none"
rows={3}
className="border-mineshaft-600 bg-mineshaft-900 text-sm"
/>
</FormControl>
)}
/>
<Controller
control={control}
name="provider.revocationStatement"
render={({ field, fieldState: { error } }) => (
<FormControl
label="Revocation Statement"
isError={Boolean(error?.message)}
errorText={error?.message}
helperText="username is dynamically provisioned"
>
<TextArea
{...field}
reSize="none"
rows={3}
className="border-mineshaft-600 bg-mineshaft-900 text-sm"
/>
</FormControl>
)}
/>
<Controller
control={control}
name="provider.renewStatement"
render={({ field, fieldState: { error } }) => (
<FormControl
label="Renew Statement"
helperText="username and expiration are dynamically provisioned"
isError={Boolean(error?.message)}
errorText={error?.message}
>
<TextArea
{...field}
reSize="none"
rows={3}
className="border-mineshaft-600 bg-mineshaft-900 text-sm"
/>
</FormControl>
)}
/>
</AccordionContent>
</AccordionItem>
</Accordion>
</div> </div>
)}
<div className="flex space-x-2">
<Controller
control={control}
name="provider.username"
defaultValue=""
render={({ field, fieldState: { error } }) => (
<FormControl
label={
selectedProvider === RedisProviders.Elasticache ? "Access Key ID" : "Username"
}
className="w-full"
isError={Boolean(error?.message)}
errorText={error?.message}
>
<Input {...field} autoComplete="off" />
</FormControl>
)}
/>
<Controller
control={control}
name="provider.password"
render={({ field, fieldState: { error } }) => (
<FormControl
className="w-full"
tooltipText={
selectedProvider === RedisProviders.Redis
? "Required if your Redis server is password protected."
: undefined
}
label={
selectedProvider === RedisProviders.Elasticache
? "Secret Access Key"
: "Username"
}
isError={Boolean(error?.message)}
errorText={error?.message}
>
<Input {...field} type="password" autoComplete="new-password" />
</FormControl>
)}
/>
</div>
<div>
<Controller
control={control}
name="provider.ca"
render={({ field, fieldState: { error } }) => (
<FormControl
isOptional
label="CA(SSL)"
isError={Boolean(error?.message)}
errorText={error?.message}
>
<SecretInput
{...field}
containerClassName="text-bunker-300 hover:border-primary-400/50 border border-mineshaft-600 bg-mineshaft-900 px-2 py-1.5"
/>
</FormControl>
)}
/>
<Accordion type="single" collapsible className="mb-2 w-full bg-mineshaft-700">
<AccordionItem value="advance-statements">
<AccordionTrigger>Modify Redis Statements</AccordionTrigger>
<AccordionContent>
<Controller
control={control}
name="provider.creationStatement"
render={({ field, fieldState: { error } }) => (
<FormControl
label="Creation Statement"
isError={Boolean(error?.message)}
errorText={error?.message}
helperText="username, password and expiration are dynamically provisioned"
>
<TextArea
{...field}
reSize="none"
rows={3}
className="border-mineshaft-600 bg-mineshaft-900 text-sm"
/>
</FormControl>
)}
/>
<Controller
control={control}
name="provider.revocationStatement"
render={({ field, fieldState: { error } }) => (
<FormControl
label="Revocation Statement"
isError={Boolean(error?.message)}
errorText={error?.message}
helperText="username is dynamically provisioned"
>
<TextArea
{...field}
reSize="none"
rows={3}
className="border-mineshaft-600 bg-mineshaft-900 text-sm"
/>
</FormControl>
)}
/>
<Controller
control={control}
name="provider.renewStatement"
render={({ field, fieldState: { error } }) => (
<FormControl
label="Renew Statement"
helperText="username and expiration are dynamically provisioned"
isError={Boolean(error?.message)}
errorText={error?.message}
>
<TextArea
{...field}
reSize="none"
rows={3}
className="border-mineshaft-600 bg-mineshaft-900 text-sm"
/>
</FormControl>
)}
/>
</AccordionContent>
</AccordionItem>
</Accordion>
</div> </div>
</div> </div>
</div> </div>
@@ -1,3 +1,4 @@
import { ReactNode } from "react";
import { Controller, useForm } from "react-hook-form"; import { Controller, useForm } from "react-hook-form";
import { faCheck, faCopy } from "@fortawesome/free-solid-svg-icons"; import { faCheck, faCopy } from "@fortawesome/free-solid-svg-icons";
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome"; import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
@@ -20,7 +21,7 @@ const OutputDisplay = ({
}: { }: {
value: string; value: string;
label: string; label: string;
helperText?: string; helperText?: ReactNode;
}) => { }) => {
const [copyText, isCopying, setCopyText] = useTimedReset<string>({ const [copyText, isCopying, setCopyText] = useTimedReset<string>({
initialState: "Copy to clipboard" initialState: "Copy to clipboard"
@@ -108,6 +109,35 @@ const renderOutputForm = (provider: DynamicSecretProviders, data: unknown) => {
); );
} }
if (provider === DynamicSecretProviders.AwsElastiCache) {
const { DB_USERNAME, DB_PASSWORD } = data as {
DB_USERNAME: string;
DB_PASSWORD: string;
};
return (
<div>
<OutputDisplay label="Cluster Username" value={DB_USERNAME} />
<OutputDisplay
label="Cluster Password"
value={DB_PASSWORD}
helperText={
<div className="space-y-4">
<p>
Important: Copy these credentials now. You will not be able to see them again after
you close the modal.
</p>
<p className="font-medium">
Please note that it may take a few minutes before the credentials are available for
use.
</p>
</div>
}
/>
</div>
);
}
return null; return null;
}; };
@@ -0,0 +1,319 @@
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 { useUpdateDynamicSecret } from "@app/hooks/api";
import { TDynamicSecret } from "@app/hooks/api/dynamicSecret/types";
const formSchema = z.object({
inputs: 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()
})
.partial(),
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" });
})
.nullable(),
newName: z
.string()
.refine((val) => val.toLowerCase() === val, "Must be lowercase")
.optional()
});
type TForm = z.infer<typeof formSchema>;
type Props = {
onClose: () => void;
dynamicSecret: TDynamicSecret & { inputs: unknown };
secretPath: string;
environment: string;
projectSlug: string;
};
export const EditDynamicSecretAwsElastiCacheProviderForm = ({
onClose,
dynamicSecret,
environment,
secretPath,
projectSlug
}: Props) => {
const {
control,
formState: { isSubmitting },
handleSubmit
} = useForm<TForm>({
resolver: zodResolver(formSchema),
values: {
defaultTTL: dynamicSecret.defaultTTL,
maxTTL: dynamicSecret.maxTTL,
newName: dynamicSecret.name,
inputs: {
...(dynamicSecret.inputs as TForm["inputs"])
}
}
});
const updateDynamicSecret = useUpdateDynamicSecret();
const handleUpdateDynamicSecret = async ({ inputs, maxTTL, defaultTTL, newName }: TForm) => {
// wait till previous request is finished
if (updateDynamicSecret.isLoading) return;
try {
await updateDynamicSecret.mutateAsync({
name: dynamicSecret.name,
path: secretPath,
projectSlug,
environmentSlug: environment,
data: {
maxTTL: maxTTL || undefined,
defaultTTL,
inputs,
newName: newName === dynamicSecret.name ? undefined : newName
}
});
onClose();
createNotification({
type: "success",
text: "Successfully updated dynamic secret"
});
} catch (err) {
createNotification({
type: "error",
text: "Failed to update dynamic secret"
});
}
};
return (
<div>
<form onSubmit={handleSubmit(handleUpdateDynamicSecret)} autoComplete="off">
<div className="flex items-center space-x-2">
<div className="flex-grow">
<Controller
control={control}
name="newName"
render={({ field, fieldState: { error } }) => (
<FormControl
label="Secret Name"
isError={Boolean(error)}
errorText={error?.message}
>
<Input {...field} placeholder="DYN-1" />
</FormControl>
)}
/>
</div>
<div className="w-32">
<Controller
control={control}
name="defaultTTL"
render={({ field, fieldState: { error } }) => (
<FormControl
label={<TtlFormLabel label="Default TTL" />}
isError={Boolean(error?.message)}
errorText={error?.message}
>
<Input {...field} />
</FormControl>
)}
/>
</div>
<div className="w-32">
<Controller
control={control}
name="maxTTL"
render={({ field, fieldState: { error } }) => (
<FormControl
label={<TtlFormLabel label="Max TTL" />}
isError={Boolean(error?.message)}
errorText={error?.message}
>
<Input {...field} value={field.value || ""} />
</FormControl>
)}
/>
</div>
</div>
<div>
<div className="mb-4 mt-4 border-b border-mineshaft-500 pb-2 pl-1 font-medium text-mineshaft-200">
Configuration
</div>
<div className="flex flex-col">
<div className="flex items-center space-x-2">
<Controller
control={control}
name="inputs.clusterName"
defaultValue=""
render={({ field, fieldState: { error } }) => (
<FormControl
label="Cluster name"
className="flex-grow"
isError={Boolean(error?.message)}
errorText={error?.message}
>
<Input {...field} placeholder="redis-oss-cluster" />
</FormControl>
)}
/>
<Controller
control={control}
defaultValue=""
name="inputs.region"
render={({ field, fieldState: { error } }) => (
<FormControl
label="Region"
isError={Boolean(error?.message)}
errorText={error?.message}
>
<Input placeholder="us-east-1" {...field} type="text" />
</FormControl>
)}
/>
</div>
<div className="flex w-full items-center space-x-2">
<Controller
control={control}
name="inputs.accessKeyId"
defaultValue=""
render={({ field, fieldState: { error } }) => (
<FormControl
label="Access Key ID"
className="w-full"
isError={Boolean(error?.message)}
errorText={error?.message}
>
<Input {...field} autoComplete="off" />
</FormControl>
)}
/>
<Controller
control={control}
name="inputs.secretAccessKey"
render={({ field, fieldState: { error } }) => (
<FormControl
label="Secret Access Key"
className="w-full"
isError={Boolean(error?.message)}
errorText={error?.message}
>
<Input {...field} type="password" autoComplete="new-password" />
</FormControl>
)}
/>
</div>
<div>
<Controller
control={control}
name="inputs.ca"
render={({ field, fieldState: { error } }) => (
<FormControl
isOptional
label="CA(SSL)"
isError={Boolean(error?.message)}
errorText={error?.message}
>
<SecretInput
{...field}
containerClassName="text-bunker-300 hover:border-primary-400/50 border border-mineshaft-600 bg-mineshaft-900 px-2 py-1.5"
/>
</FormControl>
)}
/>
<Accordion type="single" collapsible className="mb-2 w-full bg-mineshaft-700">
<AccordionItem value="advance-statements">
<AccordionTrigger>Modify ElastiCache Statements</AccordionTrigger>
<AccordionContent>
<Controller
control={control}
name="inputs.creationStatement"
render={({ field, fieldState: { error } }) => (
<FormControl
label="Creation Statement"
isError={Boolean(error?.message)}
errorText={error?.message}
helperText="username, password and expiration are dynamically provisioned"
>
<TextArea
{...field}
reSize="none"
rows={3}
className="border-mineshaft-600 bg-mineshaft-900 text-sm"
/>
</FormControl>
)}
/>
<Controller
control={control}
name="inputs.revocationStatement"
render={({ field, fieldState: { error } }) => (
<FormControl
label="Revocation Statement"
isError={Boolean(error?.message)}
errorText={error?.message}
helperText="username is dynamically provisioned"
>
<TextArea
{...field}
reSize="none"
rows={3}
className="border-mineshaft-600 bg-mineshaft-900 text-sm"
/>
</FormControl>
)}
/>
</AccordionContent>
</AccordionItem>
</Accordion>
</div>
</div>
</div>
<div className="mt-4 flex items-center space-x-4">
<Button type="submit" isLoading={isSubmitting}>
Save
</Button>
<Button variant="outline_bg" onClick={onClose}>
Cancel
</Button>
</div>
</form>
</div>
);
};
@@ -4,6 +4,7 @@ import { Spinner } from "@app/components/v2";
import { useGetDynamicSecretDetails } from "@app/hooks/api"; import { useGetDynamicSecretDetails } from "@app/hooks/api";
import { DynamicSecretProviders } from "@app/hooks/api/dynamicSecret/types"; import { DynamicSecretProviders } from "@app/hooks/api/dynamicSecret/types";
import { EditDynamicSecretAwsElastiCacheProviderForm } from "./EditDynamicSecretAwsElastiCacheProviderForm";
import { EditDynamicSecretAwsIamForm } from "./EditDynamicSecretAwsIamForm"; import { EditDynamicSecretAwsIamForm } from "./EditDynamicSecretAwsIamForm";
import { EditDynamicSecretCassandraForm } from "./EditDynamicSecretCassandraForm"; import { EditDynamicSecretCassandraForm } from "./EditDynamicSecretCassandraForm";
import { EditDynamicSecretRedisProviderForm } from "./EditDynamicSecretRedisProviderForm"; import { EditDynamicSecretRedisProviderForm } from "./EditDynamicSecretRedisProviderForm";
@@ -110,6 +111,23 @@ export const EditDynamicSecretForm = ({
/> />
</motion.div> </motion.div>
)} )}
{dynamicSecretDetails?.type === DynamicSecretProviders.AwsElastiCache && (
<motion.div
key="redis-provider-edit"
transition={{ duration: 0.1 }}
initial={{ opacity: 0, translateX: 30 }}
animate={{ opacity: 1, translateX: 0 }}
exit={{ opacity: 0, translateX: -30 }}
>
<EditDynamicSecretAwsElastiCacheProviderForm
onClose={onClose}
projectSlug={projectSlug}
secretPath={secretPath}
dynamicSecret={dynamicSecretDetails}
environment={environment}
/>
</motion.div>
)}
</AnimatePresence> </AnimatePresence>
); );
}; };
@@ -14,55 +14,25 @@ import {
FormControl, FormControl,
Input, Input,
SecretInput, SecretInput,
Select,
SelectItem,
TextArea TextArea
} from "@app/components/v2"; } from "@app/components/v2";
import { useUpdateDynamicSecret } from "@app/hooks/api"; import { useUpdateDynamicSecret } from "@app/hooks/api";
import { RedisProviders, TDynamicSecret } from "@app/hooks/api/dynamicSecret/types"; import { TDynamicSecret } from "@app/hooks/api/dynamicSecret/types";
const formSchema = z.object({ const formSchema = z.object({
inputs: z inputs: z
.object({ .object({
client: z.nativeEnum(RedisProviders),
host: z.string().toLowerCase().min(1), host: z.string().toLowerCase().min(1),
port: z.coerce.number(), port: z.coerce.number(),
username: z.string().min(1), // In case of Elasticache, this is accessKeyId username: z.string().min(1),
password: z.string().min(1).optional(), // In case of Elasticache, this is secretAccessKey password: z.string().min(1).optional(),
elastiCacheIamUsername: z.string().trim().optional(),
elastiCacheRegion: z.string().trim().optional(),
creationStatement: z.string().min(1), creationStatement: z.string().min(1),
renewStatement: z.string().optional(), renewStatement: z.string().optional(),
revocationStatement: z.string().min(1), revocationStatement: z.string().min(1),
ca: z.string().optional() ca: z.string().optional()
}) })
.partial() .partial(),
.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: "AWS region is required when using ElastiCache",
path: ["elastiCacheRegion"]
}
),
defaultTTL: z.string().superRefine((val, ctx) => { defaultTTL: z.string().superRefine((val, ctx) => {
const valMs = ms(val); const valMs = ms(val);
if (valMs < 60 * 1000) if (valMs < 60 * 1000)
@@ -109,8 +79,7 @@ export const EditDynamicSecretRedisProviderForm = ({
const { const {
control, control,
formState: { isSubmitting }, formState: { isSubmitting },
handleSubmit, handleSubmit
watch
} = useForm<TForm>({ } = useForm<TForm>({
resolver: zodResolver(formSchema), resolver: zodResolver(formSchema),
values: { values: {
@@ -154,8 +123,6 @@ export const EditDynamicSecretRedisProviderForm = ({
} }
}; };
const selectedProvider = watch("inputs.client");
return ( return (
<div> <div>
<form onSubmit={handleSubmit(handleUpdateDynamicSecret)} autoComplete="off"> <form onSubmit={handleSubmit(handleUpdateDynamicSecret)} autoComplete="off">
@@ -211,47 +178,6 @@ export const EditDynamicSecretRedisProviderForm = ({
Configuration Configuration
</div> </div>
<div className="flex flex-col"> <div className="flex flex-col">
<div className="flex w-full items-center gap-2">
<Controller
control={control}
name="inputs.client"
defaultValue={RedisProviders.Redis}
render={({ field: { value, onChange }, fieldState: { error } }) => (
<FormControl
label="Service"
className="w-full"
isError={Boolean(error?.message)}
errorText={error?.message}
>
<Select
value={value}
onValueChange={(val) => onChange(val)}
className="w-full border border-mineshaft-500"
>
<SelectItem value={RedisProviders.Redis}>Redis</SelectItem>
<SelectItem value={RedisProviders.Elasticache}>AWS ElastiCache</SelectItem>
</Select>
</FormControl>
)}
/>
{selectedProvider === RedisProviders.Elasticache && (
<Controller
control={control}
name="inputs.elastiCacheRegion"
defaultValue=""
render={({ field, fieldState: { error } }) => (
<FormControl
label="AWS Region"
className="w-full"
isError={Boolean(error?.message)}
errorText={error?.message}
>
<Input {...field} placeholder="us-east-1" />
</FormControl>
)}
/>
)}
</div>
<Controller <Controller
control={control} control={control}
name="inputs.host" name="inputs.host"
@@ -282,24 +208,6 @@ export const EditDynamicSecretRedisProviderForm = ({
)} )}
/> />
</div> </div>
{selectedProvider === RedisProviders.Elasticache && (
<div className="flex w-full">
<Controller
control={control}
name="inputs.elastiCacheIamUsername"
render={({ field, fieldState: { error } }) => (
<FormControl
label="Redis Username"
className="w-full"
isError={Boolean(error?.message)}
errorText={error?.message}
>
<Input {...field} autoComplete="off" />
</FormControl>
)}
/>
</div>
)}
<div className="flex space-x-2"> <div className="flex space-x-2">
<Controller <Controller
control={control} control={control}
@@ -307,9 +215,7 @@ export const EditDynamicSecretRedisProviderForm = ({
defaultValue="" defaultValue=""
render={({ field, fieldState: { error } }) => ( render={({ field, fieldState: { error } }) => (
<FormControl <FormControl
label={ label="Username"
selectedProvider === RedisProviders.Elasticache ? "Access Key ID" : "Username"
}
className="w-full" className="w-full"
isError={Boolean(error?.message)} isError={Boolean(error?.message)}
errorText={error?.message} errorText={error?.message}
@@ -324,16 +230,8 @@ export const EditDynamicSecretRedisProviderForm = ({
render={({ field, fieldState: { error } }) => ( render={({ field, fieldState: { error } }) => (
<FormControl <FormControl
className="w-full" className="w-full"
tooltipText={ tooltipText="Required if your Redis server is password protected."
selectedProvider === RedisProviders.Redis label="Username"
? "Required if your Redis server is password protected."
: undefined
}
label={
selectedProvider === RedisProviders.Elasticache
? "Secret Access Key"
: "Username"
}
isError={Boolean(error?.message)} isError={Boolean(error?.message)}
errorText={error?.message} errorText={error?.message}
> >