diff --git a/backend/package-lock.json b/backend/package-lock.json index 27ee940d3..b3eef6bd1 100644 --- a/backend/package-lock.json +++ b/backend/package-lock.json @@ -9,6 +9,7 @@ "version": "1.0.0", "license": "ISC", "dependencies": { + "@aws-sdk/client-elasticache": "^3.637.0", "@aws-sdk/client-iam": "^3.525.0", "@aws-sdk/client-kms": "^3.609.0", "@aws-sdk/client-secrets-manager": "^3.504.0", @@ -352,6 +353,309 @@ "node": ">=16.0.0" } }, + "node_modules/@aws-sdk/client-elasticache": { + "version": "3.637.0", + "resolved": "https://registry.npmjs.org/@aws-sdk/client-elasticache/-/client-elasticache-3.637.0.tgz", + "integrity": "sha512-e54OYm33DqmcsVHr1l+Eudt5d9PqcjDDJdQHLJrNGdrUkwmpuqnw3czkGjD5IP34XkcpQ5Gs1DSRAp07E8Zglw==", + "license": "Apache-2.0", + "dependencies": { + "@aws-crypto/sha256-browser": "5.2.0", + "@aws-crypto/sha256-js": "5.2.0", + "@aws-sdk/client-sso-oidc": "3.637.0", + "@aws-sdk/client-sts": "3.637.0", + "@aws-sdk/core": "3.635.0", + "@aws-sdk/credential-provider-node": "3.637.0", + "@aws-sdk/middleware-host-header": "3.620.0", + "@aws-sdk/middleware-logger": "3.609.0", + "@aws-sdk/middleware-recursion-detection": "3.620.0", + "@aws-sdk/middleware-user-agent": "3.637.0", + "@aws-sdk/region-config-resolver": "3.614.0", + "@aws-sdk/types": "3.609.0", + "@aws-sdk/util-endpoints": "3.637.0", + "@aws-sdk/util-user-agent-browser": "3.609.0", + "@aws-sdk/util-user-agent-node": "3.614.0", + "@smithy/config-resolver": "^3.0.5", + "@smithy/core": "^2.4.0", + "@smithy/fetch-http-handler": "^3.2.4", + "@smithy/hash-node": "^3.0.3", + "@smithy/invalid-dependency": "^3.0.3", + "@smithy/middleware-content-length": "^3.0.5", + "@smithy/middleware-endpoint": "^3.1.0", + "@smithy/middleware-retry": "^3.0.15", + "@smithy/middleware-serde": "^3.0.3", + "@smithy/middleware-stack": "^3.0.3", + "@smithy/node-config-provider": "^3.1.4", + "@smithy/node-http-handler": "^3.1.4", + "@smithy/protocol-http": "^4.1.0", + "@smithy/smithy-client": "^3.2.0", + "@smithy/types": "^3.3.0", + "@smithy/url-parser": "^3.0.3", + "@smithy/util-base64": "^3.0.0", + "@smithy/util-body-length-browser": "^3.0.0", + "@smithy/util-body-length-node": "^3.0.0", + "@smithy/util-defaults-mode-browser": "^3.0.15", + "@smithy/util-defaults-mode-node": "^3.0.15", + "@smithy/util-endpoints": "^2.0.5", + "@smithy/util-middleware": "^3.0.3", + "@smithy/util-retry": "^3.0.3", + "@smithy/util-utf8": "^3.0.0", + "@smithy/util-waiter": "^3.1.2", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=16.0.0" + } + }, + "node_modules/@aws-sdk/client-elasticache/node_modules/@aws-sdk/client-sso": { + "version": "3.637.0", + "resolved": "https://registry.npmjs.org/@aws-sdk/client-sso/-/client-sso-3.637.0.tgz", + "integrity": "sha512-+KjLvgX5yJYROWo3TQuwBJlHCY0zz9PsLuEolmXQn0BVK1L/m9GteZHtd+rEdAoDGBpE0Xqjy1oz5+SmtsaRUw==", + "license": "Apache-2.0", + "dependencies": { + "@aws-crypto/sha256-browser": "5.2.0", + "@aws-crypto/sha256-js": "5.2.0", + "@aws-sdk/core": "3.635.0", + "@aws-sdk/middleware-host-header": "3.620.0", + "@aws-sdk/middleware-logger": "3.609.0", + "@aws-sdk/middleware-recursion-detection": "3.620.0", + "@aws-sdk/middleware-user-agent": "3.637.0", + "@aws-sdk/region-config-resolver": "3.614.0", + "@aws-sdk/types": "3.609.0", + "@aws-sdk/util-endpoints": "3.637.0", + "@aws-sdk/util-user-agent-browser": "3.609.0", + "@aws-sdk/util-user-agent-node": "3.614.0", + "@smithy/config-resolver": "^3.0.5", + "@smithy/core": "^2.4.0", + "@smithy/fetch-http-handler": "^3.2.4", + "@smithy/hash-node": "^3.0.3", + "@smithy/invalid-dependency": "^3.0.3", + "@smithy/middleware-content-length": "^3.0.5", + "@smithy/middleware-endpoint": "^3.1.0", + "@smithy/middleware-retry": "^3.0.15", + "@smithy/middleware-serde": "^3.0.3", + "@smithy/middleware-stack": "^3.0.3", + "@smithy/node-config-provider": "^3.1.4", + "@smithy/node-http-handler": "^3.1.4", + "@smithy/protocol-http": "^4.1.0", + "@smithy/smithy-client": "^3.2.0", + "@smithy/types": "^3.3.0", + "@smithy/url-parser": "^3.0.3", + "@smithy/util-base64": "^3.0.0", + "@smithy/util-body-length-browser": "^3.0.0", + "@smithy/util-body-length-node": "^3.0.0", + "@smithy/util-defaults-mode-browser": "^3.0.15", + "@smithy/util-defaults-mode-node": "^3.0.15", + "@smithy/util-endpoints": "^2.0.5", + "@smithy/util-middleware": "^3.0.3", + "@smithy/util-retry": "^3.0.3", + "@smithy/util-utf8": "^3.0.0", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=16.0.0" + } + }, + "node_modules/@aws-sdk/client-elasticache/node_modules/@aws-sdk/client-sso-oidc": { + "version": "3.637.0", + "resolved": "https://registry.npmjs.org/@aws-sdk/client-sso-oidc/-/client-sso-oidc-3.637.0.tgz", + "integrity": "sha512-27bHALN6Qb6m6KZmPvRieJ/QRlj1lyac/GT2Rn5kJpre8Mpp+yxrtvp3h9PjNBty4lCeFEENfY4dGNSozBuBcw==", + "license": "Apache-2.0", + "dependencies": { + "@aws-crypto/sha256-browser": "5.2.0", + "@aws-crypto/sha256-js": "5.2.0", + "@aws-sdk/core": "3.635.0", + "@aws-sdk/credential-provider-node": "3.637.0", + "@aws-sdk/middleware-host-header": "3.620.0", + "@aws-sdk/middleware-logger": "3.609.0", + "@aws-sdk/middleware-recursion-detection": "3.620.0", + "@aws-sdk/middleware-user-agent": "3.637.0", + "@aws-sdk/region-config-resolver": "3.614.0", + "@aws-sdk/types": "3.609.0", + "@aws-sdk/util-endpoints": "3.637.0", + "@aws-sdk/util-user-agent-browser": "3.609.0", + "@aws-sdk/util-user-agent-node": "3.614.0", + "@smithy/config-resolver": "^3.0.5", + "@smithy/core": "^2.4.0", + "@smithy/fetch-http-handler": "^3.2.4", + "@smithy/hash-node": "^3.0.3", + "@smithy/invalid-dependency": "^3.0.3", + "@smithy/middleware-content-length": "^3.0.5", + "@smithy/middleware-endpoint": "^3.1.0", + "@smithy/middleware-retry": "^3.0.15", + "@smithy/middleware-serde": "^3.0.3", + "@smithy/middleware-stack": "^3.0.3", + "@smithy/node-config-provider": "^3.1.4", + "@smithy/node-http-handler": "^3.1.4", + "@smithy/protocol-http": "^4.1.0", + "@smithy/smithy-client": "^3.2.0", + "@smithy/types": "^3.3.0", + "@smithy/url-parser": "^3.0.3", + "@smithy/util-base64": "^3.0.0", + "@smithy/util-body-length-browser": "^3.0.0", + "@smithy/util-body-length-node": "^3.0.0", + "@smithy/util-defaults-mode-browser": "^3.0.15", + "@smithy/util-defaults-mode-node": "^3.0.15", + "@smithy/util-endpoints": "^2.0.5", + "@smithy/util-middleware": "^3.0.3", + "@smithy/util-retry": "^3.0.3", + "@smithy/util-utf8": "^3.0.0", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=16.0.0" + }, + "peerDependencies": { + "@aws-sdk/client-sts": "^3.637.0" + } + }, + "node_modules/@aws-sdk/client-elasticache/node_modules/@aws-sdk/client-sts": { + "version": "3.637.0", + "resolved": "https://registry.npmjs.org/@aws-sdk/client-sts/-/client-sts-3.637.0.tgz", + "integrity": "sha512-xUi7x4qDubtA8QREtlblPuAcn91GS/09YVEY/RwU7xCY0aqGuFwgszAANlha4OUIqva8oVj2WO4gJuG+iaSnhw==", + "license": "Apache-2.0", + "dependencies": { + "@aws-crypto/sha256-browser": "5.2.0", + "@aws-crypto/sha256-js": "5.2.0", + "@aws-sdk/client-sso-oidc": "3.637.0", + "@aws-sdk/core": "3.635.0", + "@aws-sdk/credential-provider-node": "3.637.0", + "@aws-sdk/middleware-host-header": "3.620.0", + "@aws-sdk/middleware-logger": "3.609.0", + "@aws-sdk/middleware-recursion-detection": "3.620.0", + "@aws-sdk/middleware-user-agent": "3.637.0", + "@aws-sdk/region-config-resolver": "3.614.0", + "@aws-sdk/types": "3.609.0", + "@aws-sdk/util-endpoints": "3.637.0", + "@aws-sdk/util-user-agent-browser": "3.609.0", + "@aws-sdk/util-user-agent-node": "3.614.0", + "@smithy/config-resolver": "^3.0.5", + "@smithy/core": "^2.4.0", + "@smithy/fetch-http-handler": "^3.2.4", + "@smithy/hash-node": "^3.0.3", + "@smithy/invalid-dependency": "^3.0.3", + "@smithy/middleware-content-length": "^3.0.5", + "@smithy/middleware-endpoint": "^3.1.0", + "@smithy/middleware-retry": "^3.0.15", + "@smithy/middleware-serde": "^3.0.3", + "@smithy/middleware-stack": "^3.0.3", + "@smithy/node-config-provider": "^3.1.4", + "@smithy/node-http-handler": "^3.1.4", + "@smithy/protocol-http": "^4.1.0", + "@smithy/smithy-client": "^3.2.0", + "@smithy/types": "^3.3.0", + "@smithy/url-parser": "^3.0.3", + "@smithy/util-base64": "^3.0.0", + "@smithy/util-body-length-browser": "^3.0.0", + "@smithy/util-body-length-node": "^3.0.0", + "@smithy/util-defaults-mode-browser": "^3.0.15", + "@smithy/util-defaults-mode-node": "^3.0.15", + "@smithy/util-endpoints": "^2.0.5", + "@smithy/util-middleware": "^3.0.3", + "@smithy/util-retry": "^3.0.3", + "@smithy/util-utf8": "^3.0.0", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=16.0.0" + } + }, + "node_modules/@aws-sdk/client-elasticache/node_modules/@aws-sdk/credential-provider-ini": { + "version": "3.637.0", + "resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-ini/-/credential-provider-ini-3.637.0.tgz", + "integrity": "sha512-h+PFCWfZ0Q3Dx84SppET/TFpcQHmxFW8/oV9ArEvMilw4EBN+IlxgbL0CnHwjHW64szcmrM0mbebjEfHf4FXmw==", + "license": "Apache-2.0", + "dependencies": { + "@aws-sdk/credential-provider-env": "3.620.1", + "@aws-sdk/credential-provider-http": "3.635.0", + "@aws-sdk/credential-provider-process": "3.620.1", + "@aws-sdk/credential-provider-sso": "3.637.0", + "@aws-sdk/credential-provider-web-identity": "3.621.0", + "@aws-sdk/types": "3.609.0", + "@smithy/credential-provider-imds": "^3.2.0", + "@smithy/property-provider": "^3.1.3", + "@smithy/shared-ini-file-loader": "^3.1.4", + "@smithy/types": "^3.3.0", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=16.0.0" + }, + "peerDependencies": { + "@aws-sdk/client-sts": "^3.637.0" + } + }, + "node_modules/@aws-sdk/client-elasticache/node_modules/@aws-sdk/credential-provider-node": { + "version": "3.637.0", + "resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-node/-/credential-provider-node-3.637.0.tgz", + "integrity": "sha512-yoEhoxJJfs7sPVQ6Is939BDQJZpZCoUgKr/ySse4YKOZ24t4VqgHA6+wV7rYh+7IW24Rd91UTvEzSuHYTlxlNA==", + "license": "Apache-2.0", + "dependencies": { + "@aws-sdk/credential-provider-env": "3.620.1", + "@aws-sdk/credential-provider-http": "3.635.0", + "@aws-sdk/credential-provider-ini": "3.637.0", + "@aws-sdk/credential-provider-process": "3.620.1", + "@aws-sdk/credential-provider-sso": "3.637.0", + "@aws-sdk/credential-provider-web-identity": "3.621.0", + "@aws-sdk/types": "3.609.0", + "@smithy/credential-provider-imds": "^3.2.0", + "@smithy/property-provider": "^3.1.3", + "@smithy/shared-ini-file-loader": "^3.1.4", + "@smithy/types": "^3.3.0", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=16.0.0" + } + }, + "node_modules/@aws-sdk/client-elasticache/node_modules/@aws-sdk/credential-provider-sso": { + "version": "3.637.0", + "resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-sso/-/credential-provider-sso-3.637.0.tgz", + "integrity": "sha512-Mvz+h+e62/tl+dVikLafhv+qkZJ9RUb8l2YN/LeKMWkxQylPT83CPk9aimVhCV89zth1zpREArl97+3xsfgQvA==", + "license": "Apache-2.0", + "dependencies": { + "@aws-sdk/client-sso": "3.637.0", + "@aws-sdk/token-providers": "3.614.0", + "@aws-sdk/types": "3.609.0", + "@smithy/property-provider": "^3.1.3", + "@smithy/shared-ini-file-loader": "^3.1.4", + "@smithy/types": "^3.3.0", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=16.0.0" + } + }, + "node_modules/@aws-sdk/client-elasticache/node_modules/@aws-sdk/middleware-user-agent": { + "version": "3.637.0", + "resolved": "https://registry.npmjs.org/@aws-sdk/middleware-user-agent/-/middleware-user-agent-3.637.0.tgz", + "integrity": "sha512-EYo0NE9/da/OY8STDsK2LvM4kNa79DBsf4YVtaG4P5pZ615IeFsD8xOHZeuJmUrSMlVQ8ywPRX7WMucUybsKug==", + "license": "Apache-2.0", + "dependencies": { + "@aws-sdk/types": "3.609.0", + "@aws-sdk/util-endpoints": "3.637.0", + "@smithy/protocol-http": "^4.1.0", + "@smithy/types": "^3.3.0", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=16.0.0" + } + }, + "node_modules/@aws-sdk/client-elasticache/node_modules/@aws-sdk/util-endpoints": { + "version": "3.637.0", + "resolved": "https://registry.npmjs.org/@aws-sdk/util-endpoints/-/util-endpoints-3.637.0.tgz", + "integrity": "sha512-pAqOKUHeVWHEXXDIp/qoMk/6jyxIb6GGjnK1/f8dKHtKIEs4tKsnnL563gceEvdad53OPXIt86uoevCcCzmBnw==", + "license": "Apache-2.0", + "dependencies": { + "@aws-sdk/types": "3.609.0", + "@smithy/types": "^3.3.0", + "@smithy/util-endpoints": "^2.0.5", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=16.0.0" + } + }, "node_modules/@aws-sdk/client-iam": { "version": "3.635.0", "resolved": "https://registry.npmjs.org/@aws-sdk/client-iam/-/client-iam-3.635.0.tgz", diff --git a/backend/package.json b/backend/package.json index 043394769..86d92c086 100644 --- a/backend/package.json +++ b/backend/package.json @@ -106,6 +106,7 @@ "vitest": "^1.2.2" }, "dependencies": { + "@aws-sdk/client-elasticache": "^3.637.0", "@aws-sdk/client-iam": "^3.525.0", "@aws-sdk/client-kms": "^3.609.0", "@aws-sdk/client-secrets-manager": "^3.504.0", 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..2cb862029 --- /dev/null +++ b/backend/src/ee/services/dynamic-secret/providers/aws-elasticache.ts @@ -0,0 +1,226 @@ +import { + CreateUserCommand, + CreateUserGroupCommand, + DeleteUserCommand, + DescribeReplicationGroupsCommand, + DescribeUserGroupsCommand, + ElastiCache, + ModifyReplicationGroupCommand, + ModifyUserGroupCommand +} from "@aws-sdk/client-elasticache"; +import handlebars from "handlebars"; +import { customAlphabet } from "nanoid"; +import { z } from "zod"; + +import { BadRequestError } from "@app/lib/errors"; + +import { DynamicSecretAwsElastiCacheSchema, TDynamicProviderFns } from "./models"; + +const CreateElastiCacheUserSchema = z.object({ + UserId: z.string().trim().min(1), + UserName: z.string().trim().min(1), + Engine: z.string().default("redis"), + Passwords: z.array(z.string().trim().min(1)).min(1).max(1), // Minimum password length is 16 characters, required by AWS. + AccessString: z.string().trim().min(1) // Example: "on ~* +@all" +}); + +const DeleteElasticCacheUserSchema = z.object({ + UserId: z.string().trim().min(1) +}); + +type TElastiCacheRedisUser = { userId: string; password: string }; +type TBasicAWSCredentials = { accessKeyId: string; secretAccessKey: string }; + +type TCreateElastiCacheUserInput = z.infer; +type TDeleteElastiCacheUserInput = z.infer; + +const ElastiCacheUserManager = (credentials: TBasicAWSCredentials, region: string) => { + const elastiCache = new ElastiCache({ + region, + credentials + }); + const infisicalGroup = "infisical-managed-group-elasticache"; + + const ensureInfisicalGroupExists = async (clusterName: string) => { + const replicationGroups = await elastiCache.send(new DescribeUserGroupsCommand()); + + const existingGroup = replicationGroups.UserGroups?.find((group) => group.UserGroupId === infisicalGroup); + + let newlyCreatedGroup = false; + if (!existingGroup) { + const createGroupCommand = new CreateUserGroupCommand({ + UserGroupId: infisicalGroup, + UserIds: ["default"], + Engine: "redis" + }); + + await elastiCache.send(createGroupCommand); + newlyCreatedGroup = true; + } + + if (existingGroup || newlyCreatedGroup) { + const replicationGroup = ( + await elastiCache.send( + new DescribeReplicationGroupsCommand({ + ReplicationGroupId: clusterName + }) + ) + ).ReplicationGroups?.[0]; + + if (!replicationGroup?.UserGroupIds?.includes(infisicalGroup)) { + // If the replication group doesn't have the infisical user group, we need to associate it + const modifyGroupCommand = new ModifyReplicationGroupCommand({ + UserGroupIdsToAdd: [infisicalGroup], + UserGroupIdsToRemove: [], + ApplyImmediately: true, + ReplicationGroupId: clusterName + }); + await elastiCache.send(modifyGroupCommand); + } + } + }; + + const addUserToInfisicalGroup = async (userId: string) => { + // figure out if the default user is already in the group, if it is, then we shouldn't add it again + + const addUserToGroupCommand = new ModifyUserGroupCommand({ + UserGroupId: infisicalGroup, + UserIdsToAdd: [userId], + UserIdsToRemove: [] + }); + + await elastiCache.send(addUserToGroupCommand); + }; + + const createUser = async (creationInput: TCreateElastiCacheUserInput, clusterName: string) => { + await ensureInfisicalGroupExists(clusterName); + + 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 { + userId: creationInput.UserId, + password: creationInput.Passwords[0] + }; + }; + + const deleteUser = async ( + deletionInput: TDeleteElastiCacheUserInput + ): Promise> => { + await elastiCache.send(new DeleteUserCommand(deletionInput)); + return { userId: deletionInput.UserId }; + }; + + const verifyCredentials = async (clusterName: string) => { + await elastiCache.send( + new DescribeReplicationGroupsCommand({ + ReplicationGroupId: clusterName + }) + ); + }; + + return { + createUser, + deleteUser, + verifyCredentials + }; +}; + +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); + + // We need to ensure the that the creation & revocation statements are valid and can be used to create and revoke users. + // We can't return the parsed statements here because we need to use the handlebars template to generate the username and password, before we can use the parsed statements. + CreateElastiCacheUserSchema.parse(JSON.parse(providerInputs.creationStatement)); + DeleteElasticCacheUserSchema.parse(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 beb6c428e..1ee020625 100644 --- a/backend/src/ee/services/dynamic-secret/providers/index.ts +++ b/backend/src/ee/services/dynamic-secret/providers/index.ts @@ -1,10 +1,14 @@ +import { AwsElastiCacheDatabaseProvider } from "./aws-elasticache"; import { AwsIamProvider } from "./aws-iam"; import { CassandraProvider } from "./cassandra"; import { DynamicSecretProviders } from "./models"; +import { RedisDatabaseProvider } from "./redis"; import { SqlDatabaseProvider } from "./sql-database"; export const buildDynamicSecretProviders = () => ({ [DynamicSecretProviders.SqlDatabase]: SqlDatabaseProvider(), [DynamicSecretProviders.Cassandra]: CassandraProvider(), - [DynamicSecretProviders.AwsIam]: AwsIamProvider() + [DynamicSecretProviders.AwsIam]: AwsIamProvider(), + [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 14b79eeea..60cab6f80 100644 --- a/backend/src/ee/services/dynamic-secret/providers/models.ts +++ b/backend/src/ee/services/dynamic-secret/providers/models.ts @@ -7,6 +7,29 @@ export enum SqlProviders { MsSQL = "mssql" } +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(), + + creationStatement: z.string().trim(), + revocationStatement: z.string().trim(), + renewStatement: z.string().trim().optional(), + ca: z.string().optional() +}); + +export const DynamicSecretAwsElastiCacheSchema = 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() +}); + export const DynamicSecretSqlDBSchema = z.object({ client: z.nativeEnum(SqlProviders), host: z.string().trim().toLowerCase(), @@ -47,13 +70,17 @@ export const DynamicSecretAwsIamSchema = z.object({ export enum DynamicSecretProviders { SqlDatabase = "sql-database", Cassandra = "cassandra", - AwsIam = "aws-iam" + AwsIam = "aws-iam", + 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.AwsIam), inputs: DynamicSecretAwsIamSchema }), + 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 new file mode 100644 index 000000000..23eb454c5 --- /dev/null +++ b/backend/src/ee/services/dynamic-secret/providers/redis.ts @@ -0,0 +1,183 @@ +/* eslint-disable no-console */ +import handlebars from "handlebars"; +import { Redis } from "ioredis"; +import { customAlphabet } from "nanoid"; +import { z } from "zod"; + +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, TDynamicProviderFns } from "./models"; + +const generatePassword = () => { + const charset = "abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789-_.~!*$#"; + return customAlphabet(charset, 64)(); +}; + +const generateUsername = () => { + return alphaNumericNanoId(32); +}; + +const executeTransactions = async (connection: Redis, commands: string[]): Promise<(string | null)[] | null> => { + // Initiate a transaction + const pipeline = connection.multi(); + + // Add all commands to the pipeline + for (const command of commands) { + const args = command + .split(" ") + .map((arg) => arg.trim()) + .filter((arg) => arg.length > 0); + pipeline.call(args[0], ...args.slice(1)); + } + + // Execute the transaction + const results = await pipeline.exec(); + + if (!results) { + throw new BadRequestError({ message: "Redis transaction failed: No results returned" }); + } + + // Check for errors in the results + const errors = results.filter(([err]) => err !== null); + if (errors.length > 0) { + throw new BadRequestError({ message: "Redis transaction failed with errors" }); + } + + // eslint-disable-next-line @typescript-eslint/no-unused-vars + return results.map(([_, result]) => result as string | null); +}; + +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.DB_HOST || getDbConnectionHost(appCfg.DB_CONNECTION_URI); + + const providerInputs = await DynamicSecretRedisDBSchema.parseAsync(inputs); + if ( + isCloud && + // localhost + // internal ips + (providerInputs.host === "host.docker.internal" || + providerInputs.host.match(/^10\.\d+\.\d+\.\d+/) || + providerInputs.host.match(/^192\.168\.\d+\.\d+/)) + ) + throw new BadRequestError({ message: "Invalid db 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 { + 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) { + result = await connection.auth(providerInputs.username, providerInputs.password, () => {}); + } else { + result = await connection.auth(providerInputs.username, () => {}); + } + + if (result !== "OK") { + throw new BadRequestError({ message: `Invalid credentials, Redis returned ${result} status` }); + } + + return connection; + } catch (err) { + if (connection) await connection.quit(); + + throw err; + } + }; + + const validateConnection = async (inputs: unknown) => { + const providerInputs = await validateProviderInputs(inputs); + const connection = await getClient(providerInputs); + + const pingResponse = await connection + .ping() + .then(() => true) + .catch(() => false); + + return pingResponse; + }; + + const create = async (inputs: unknown, expireAt: number) => { + const providerInputs = await validateProviderInputs(inputs); + const connection = await getClient(providerInputs); + + const username = generateUsername(); + const password = generatePassword(); + const expiration = new Date(expireAt).toISOString(); + + const creationStatement = handlebars.compile(providerInputs.creationStatement, { noEscape: true })({ + username, + password, + expiration + }); + + const queries = creationStatement.toString().split(";").filter(Boolean); + + await executeTransactions(connection, queries); + + await connection.quit(); + return { entityId: username, data: { DB_USERNAME: username, DB_PASSWORD: password } }; + }; + + const revoke = async (inputs: unknown, entityId: string) => { + const providerInputs = await validateProviderInputs(inputs); + const connection = await getClient(providerInputs); + + const username = entityId; + + const revokeStatement = handlebars.compile(providerInputs.revocationStatement)({ username }); + const queries = revokeStatement.toString().split(";").filter(Boolean); + + await executeTransactions(connection, queries); + + await connection.quit(); + return { entityId: username }; + }; + + const renew = async (inputs: unknown, entityId: string, expireAt: number) => { + const providerInputs = await validateProviderInputs(inputs); + const connection = await getClient(providerInputs); + + const username = entityId; + const expiration = new Date(expireAt).toISOString(); + + const renewStatement = handlebars.compile(providerInputs.renewStatement)({ username, expiration }); + + if (renewStatement) { + const queries = renewStatement.toString().split(";").filter(Boolean); + await executeTransactions(connection, queries); + } + + await connection.quit(); + return { entityId: username }; + }; + + return { + validateProviderInputs, + validateConnection, + create, + revoke, + renew + }; +}; diff --git a/docs/documentation/platform/dynamic-secrets/aws-elasticache.mdx b/docs/documentation/platform/dynamic-secrets/aws-elasticache.mdx new file mode 100644 index 000000000..d02f47858 --- /dev/null +++ b/docs/documentation/platform/dynamic-secrets/aws-elasticache.mdx @@ -0,0 +1,144 @@ +--- +title: "AWS Elasticahe" +description: "Learn how to dynamically generate Redis Database user credentials." +--- + +The Infisical Redis dynamic secret allows you to generate Redis Database credentials on demand based on configured role. + +## Prerequisites + + + +2. Create an AWS IAM user with the following permissions: +```json +{ + "Version": "2012-10-17", + "Statement": [ + { + "Sid": "", + "Effect": "Allow", + "Action": [ + "elasticache:DescribeUsers", + "elasticache:ModifyUser", + "elasticache:CreateUser", + "elasticache:CreateUserGroup", + "elasticache:DeleteUser", + "elasticache:DescribeReplicationGroups", + "elasticache:DescribeUserGroups", + "elasticache:ModifyReplicationGroup", + "elasticache:ModifyUserGroup" + ], + "Resource": "arn:aws:elasticache:::user:*" + } + ] +} +``` + +3. Create an access key ID and secret access key for the user you created in the previous step. You will need these to configure the Infisical dynamic secret. + + + New leases may take up-to a couple of minutes before ElastiCache has the chance to complete their configuration. + It is recommended to use a retry strategy when establishing new Redis ElastiCache connections. + This may prevent errors when trying to use a password that isn't yet live on the targeted ElastiCache cluster. + + While a leasing is being created, you will be unable to create new leases for the same dynamic secret. + + + + Please ensure that your ElastiCache cluster has transit encryption enabled and set to required. This is required for the dynamic secret to work. + + + + + +## Set up Dynamic Secrets with Redis + + + + Open the Secret Overview dashboard and select the environment in which you would like to add a dynamic secret. + + + ![Add Dynamic Secret Button](../../../images/platform/dynamic-secrets/add-dynamic-secret-button-redis.png) + + + ![Dynamic Secret Modal](../../../images/platform/dynamic-secrets/dynamic-secret-modal-aws-elasti-cache) + + + + Name by which you want the secret to be referenced + + + + Default time-to-live for a generated secret (it is possible to modify this value when a secret is generate) + + + + Maximum time-to-live for a generated secret. + + + + The region that the ElastiCache cluster is located in. _(e.g. us-east-1)_ + + + + 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 secret access key of the AWS IAM user you created in the prerequisites. This will be used to provision and manage the dynamic secret leases. + + + + A CA may be required if your DB requires it for incoming connections. This is often the case when connecting to a managed service. + + + + + If you want to provide specific privileges for the generated dynamic credentials, you can modify the ElastiCache statement to your needs. This is useful if you want to only give access to a specific table(s). + + ![Modify ElastiCache Statements Modal](/images/platform/dynamic-secrets/modify-elasticache-statement.png) + + + After submitting the form, you will see a dynamic secret created in the dashboard. + + + If this step fails, you may have to add the CA certificate. + + + + + Once you've successfully configured the dynamic secret, you're ready to generate on-demand credentials. + To do this, simply click on the 'Generate' button which appears when hovering over the dynamic secret item. + Alternatively, you can initiate the creation of a new lease by selecting 'New Lease' from the dynamic secret lease list section. + + ![Dynamic Secret](/images/platform/dynamic-secrets/dynamic-secret-generate-redis.png) + ![Dynamic Secret](/images/platform/dynamic-secrets/dynamic-secret-lease-empty-redis.png) + + When generating these secrets, it's important to specify a Time-to-Live (TTL) duration. This will dictate how long the credentials are valid for. + + ![Provision Lease](/images/platform/dynamic-secrets/provision-lease-redis.png) + + + Ensure that the TTL for the lease fall within the maximum TTL defined when configuring the dynamic secret. + + + + Once you click the `Submit` button, a new secret lease will be generated and the credentials from it will be shown to you. + + ![Provision Lease](/images/platform/dynamic-secrets/lease-values-redis.png) + + + +## Audit or Revoke Leases +Once you have created one or more leases, you will be able to access them by clicking on the respective dynamic secret item on the dashboard. +This will allow you see the expiration time of the lease or delete a lease before it's set time to live. + +![Provision Lease](/images/platform/dynamic-secrets/lease-data-redis.png) + +## Renew Leases +To extend the life of the generated dynamic secret leases past its initial time to live, simply click on the **Renew** as illustrated below. +![Provision Lease](/images/platform/dynamic-secrets/dynamic-secret-lease-renew-redis.png) + + + Lease renewals cannot exceed the maximum TTL set when configuring the dynamic secret + \ No newline at end of file diff --git a/docs/documentation/platform/dynamic-secrets/aws-iam.mdx b/docs/documentation/platform/dynamic-secrets/aws-iam.mdx index 6ec5b48b9..7a3976e0f 100644 --- a/docs/documentation/platform/dynamic-secrets/aws-iam.mdx +++ b/docs/documentation/platform/dynamic-secrets/aws-iam.mdx @@ -1,6 +1,6 @@ --- title: "AWS IAM" -description: "How to dynamically generate AWS IAM Users." +description: "Learn how to dynamically generate AWS IAM Users." --- The Infisical AWS IAM dynamic secret allows you to generate AWS IAM Users on demand based on configured AWS policy. diff --git a/docs/documentation/platform/dynamic-secrets/cassandra.mdx b/docs/documentation/platform/dynamic-secrets/cassandra.mdx index 78e03e011..fd46c8288 100644 --- a/docs/documentation/platform/dynamic-secrets/cassandra.mdx +++ b/docs/documentation/platform/dynamic-secrets/cassandra.mdx @@ -1,6 +1,6 @@ --- title: "Cassandra" -description: "How to dynamically generate Cassandra database users." +description: "Learn how to dynamically generate Cassandra database user credentials" --- The Infisical Cassandra dynamic secret allows you to generate Cassandra database credentials on demand based on configured role. diff --git a/docs/documentation/platform/dynamic-secrets/mssql.mdx b/docs/documentation/platform/dynamic-secrets/mssql.mdx index 8dae71399..fb666adca 100644 --- a/docs/documentation/platform/dynamic-secrets/mssql.mdx +++ b/docs/documentation/platform/dynamic-secrets/mssql.mdx @@ -1,6 +1,6 @@ --- title: "MS SQL" -description: "How to dynamically generate MS SQL database users." +description: "Learn how to dynamically generate MS SQL database user credentials." --- The Infisical MS SQL dynamic secret allows you to generate Microsoft SQL server database credentials on demand based on configured role. diff --git a/docs/documentation/platform/dynamic-secrets/mysql.mdx b/docs/documentation/platform/dynamic-secrets/mysql.mdx index c64edab63..d85f4b7bb 100644 --- a/docs/documentation/platform/dynamic-secrets/mysql.mdx +++ b/docs/documentation/platform/dynamic-secrets/mysql.mdx @@ -1,6 +1,6 @@ --- title: "MySQL" -description: "Learn how to dynamically generate MySQL Database user passwords." +description: "Learn how to dynamically generate MySQL Database user credentials." --- The Infisical MySQL dynamic secret allows you to generate MySQL Database credentials on demand based on configured role. diff --git a/docs/documentation/platform/dynamic-secrets/oracle.mdx b/docs/documentation/platform/dynamic-secrets/oracle.mdx index 05b832c4f..a6fb68913 100644 --- a/docs/documentation/platform/dynamic-secrets/oracle.mdx +++ b/docs/documentation/platform/dynamic-secrets/oracle.mdx @@ -1,6 +1,6 @@ --- title: "Oracle" -description: "Learn how to dynamically generate Oracle Database user passwords." +description: "Learn how to dynamically generate Oracle Database user credentials." --- The Infisical Oracle dynamic secret allows you to generate Oracle Database credentials on demand based on configured role. diff --git a/docs/documentation/platform/dynamic-secrets/overview.mdx b/docs/documentation/platform/dynamic-secrets/overview.mdx index 81ab42656..24c7fae4e 100644 --- a/docs/documentation/platform/dynamic-secrets/overview.mdx +++ b/docs/documentation/platform/dynamic-secrets/overview.mdx @@ -32,4 +32,5 @@ Dynamic secrets are particularly useful in environments with stringent security 2. [MySQL](./mysql) 3. [Cassandra](./cassandra) 4. [Oracle](./oracle) +6. [Redis](./redis) 5. [AWS IAM](./aws-iam) diff --git a/docs/documentation/platform/dynamic-secrets/postgresql.mdx b/docs/documentation/platform/dynamic-secrets/postgresql.mdx index 13adfc750..ebc19b011 100644 --- a/docs/documentation/platform/dynamic-secrets/postgresql.mdx +++ b/docs/documentation/platform/dynamic-secrets/postgresql.mdx @@ -1,6 +1,6 @@ --- title: "PostgreSQL" -description: "How to dynamically generate PostgreSQL database users." +description: "Learn how to dynamically generate PostgreSQL database users." --- The Infisical PostgreSQL dynamic secret allows you to generate PostgreSQL database credentials on demand based on configured role. diff --git a/docs/documentation/platform/dynamic-secrets/redis.mdx b/docs/documentation/platform/dynamic-secrets/redis.mdx new file mode 100644 index 000000000..1270e6959 --- /dev/null +++ b/docs/documentation/platform/dynamic-secrets/redis.mdx @@ -0,0 +1,106 @@ +--- +title: "Redis" +description: "Learn how to dynamically generate Redis Database user credentials." +--- + +The Infisical Redis dynamic secret allows you to generate Redis Database credentials on demand based on configured role. + +## Prerequisite +Create a user with the required permission in your Redis instance. This user will be used to create new accounts on-demand. + + +## Set up Dynamic Secrets with Redis + + + + Open the Secret Overview dashboard and select the environment in which you would like to add a dynamic secret. + + + ![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) + + + + Name by which you want the secret to be referenced + + + + Default time-to-live for a generated secret (it is possible to modify this value when a secret is generate) + + + + Maximum time-to-live for a generated secret. + + + + 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. + + + + Redis username that will be used to create new users on-demand. This is often 'default' or 'admin'. + + + + Password that will be used to create dynamic secrets. This is required if your Redis instance is password protected. + + + + A CA may be required if your DB requires it for incoming connections. This is often the case when connecting to a managed service. + + + + + If you want to provide specific privileges for the generated dynamic credentials, you can modify the Redis statement to your needs. This is useful if you want to only give access to a specific table(s). + + ![Modify Redis Statements Modal](/images/platform/dynamic-secrets/modify-redis-statement.png) + + + After submitting the form, you will see a dynamic secret created in the dashboard. + + + If this step fails, you may have to add the CA certificate. + + + + + Once you've successfully configured the dynamic secret, you're ready to generate on-demand credentials. + To do this, simply click on the 'Generate' button which appears when hovering over the dynamic secret item. + Alternatively, you can initiate the creation of a new lease by selecting 'New Lease' from the dynamic secret lease list section. + + ![Dynamic Secret](/images/platform/dynamic-secrets/dynamic-secret-generate-redis.png) + ![Dynamic Secret](/images/platform/dynamic-secrets/dynamic-secret-lease-empty-redis.png) + + When generating these secrets, it's important to specify a Time-to-Live (TTL) duration. This will dictate how long the credentials are valid for. + + ![Provision Lease](/images/platform/dynamic-secrets/provision-lease-redis.png) + + + Ensure that the TTL for the lease fall within the maximum TTL defined when configuring the dynamic secret. + + + + Once you click the `Submit` button, a new secret lease will be generated and the credentials from it will be shown to you. + + ![Provision Lease](/images/platform/dynamic-secrets/lease-values-redis.png) + + + +## Audit or Revoke Leases +Once you have created one or more leases, you will be able to access them by clicking on the respective dynamic secret item on the dashboard. +This will allow you see the expiration time of the lease or delete a lease before it's set time to live. + +![Provision Lease](/images/platform/dynamic-secrets/lease-data-redis.png) + +## Renew Leases +To extend the life of the generated dynamic secret leases past its initial time to live, simply click on the **Renew** as illustrated below. +![Provision Lease](/images/platform/dynamic-secrets/dynamic-secret-lease-renew-redis.png) + + + Lease renewals cannot exceed the maximum TTL set when configuring the dynamic secret + \ No newline at end of file diff --git a/docs/images/platform/dynamic-secrets/add-dynamic-secret-button-redis.png b/docs/images/platform/dynamic-secrets/add-dynamic-secret-button-redis.png new file mode 100644 index 000000000..537f20e73 Binary files /dev/null and b/docs/images/platform/dynamic-secrets/add-dynamic-secret-button-redis.png differ diff --git a/docs/images/platform/dynamic-secrets/dynamic-secret-generate-redis.png b/docs/images/platform/dynamic-secrets/dynamic-secret-generate-redis.png new file mode 100644 index 000000000..db7f8be35 Binary files /dev/null and b/docs/images/platform/dynamic-secrets/dynamic-secret-generate-redis.png differ diff --git a/docs/images/platform/dynamic-secrets/dynamic-secret-lease-empty-redis.png b/docs/images/platform/dynamic-secrets/dynamic-secret-lease-empty-redis.png new file mode 100644 index 000000000..a7842b298 Binary files /dev/null and b/docs/images/platform/dynamic-secrets/dynamic-secret-lease-empty-redis.png differ diff --git a/docs/images/platform/dynamic-secrets/dynamic-secret-lease-renew-redis.png b/docs/images/platform/dynamic-secrets/dynamic-secret-lease-renew-redis.png new file mode 100644 index 000000000..a2fd14d41 Binary files /dev/null and b/docs/images/platform/dynamic-secrets/dynamic-secret-lease-renew-redis.png 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/dynamic-secret-modal-redis.png b/docs/images/platform/dynamic-secrets/dynamic-secret-modal-redis.png new file mode 100644 index 000000000..a4835e517 Binary files /dev/null and b/docs/images/platform/dynamic-secrets/dynamic-secret-modal-redis.png differ diff --git a/docs/images/platform/dynamic-secrets/lease-data-redis.png b/docs/images/platform/dynamic-secrets/lease-data-redis.png new file mode 100644 index 000000000..f3fd51637 Binary files /dev/null and b/docs/images/platform/dynamic-secrets/lease-data-redis.png differ diff --git a/docs/images/platform/dynamic-secrets/lease-values-redis.png b/docs/images/platform/dynamic-secrets/lease-values-redis.png new file mode 100644 index 000000000..95d4a4ffd Binary files /dev/null and b/docs/images/platform/dynamic-secrets/lease-values-redis.png differ diff --git a/docs/images/platform/dynamic-secrets/modify-elasticache-statement.png b/docs/images/platform/dynamic-secrets/modify-elasticache-statement.png new file mode 100644 index 000000000..c8cd662d0 Binary files /dev/null and b/docs/images/platform/dynamic-secrets/modify-elasticache-statement.png differ diff --git a/docs/images/platform/dynamic-secrets/modify-redis-statement.png b/docs/images/platform/dynamic-secrets/modify-redis-statement.png new file mode 100644 index 000000000..c9726f752 Binary files /dev/null and b/docs/images/platform/dynamic-secrets/modify-redis-statement.png differ diff --git a/docs/images/platform/dynamic-secrets/provision-lease-redis.png b/docs/images/platform/dynamic-secrets/provision-lease-redis.png new file mode 100644 index 000000000..f5237d058 Binary files /dev/null and b/docs/images/platform/dynamic-secrets/provision-lease-redis.png differ diff --git a/docs/mint.json b/docs/mint.json index 75368e3a8..34cb085a8 100644 --- a/docs/mint.json +++ b/docs/mint.json @@ -154,6 +154,8 @@ "documentation/platform/dynamic-secrets/mssql", "documentation/platform/dynamic-secrets/oracle", "documentation/platform/dynamic-secrets/cassandra", + "documentation/platform/dynamic-secrets/redis", + "documentation/platform/dynamic-secrets/aws-elasticache", "documentation/platform/dynamic-secrets/aws-iam" ] }, diff --git a/frontend/src/hooks/api/dynamicSecret/types.ts b/frontend/src/hooks/api/dynamicSecret/types.ts index b987e94cc..ff881e506 100644 --- a/frontend/src/hooks/api/dynamicSecret/types.ts +++ b/frontend/src/hooks/api/dynamicSecret/types.ts @@ -18,7 +18,9 @@ export type TDynamicSecret = { export enum DynamicSecretProviders { SqlDatabase = "sql-database", Cassandra = "cassandra", - AwsIam = "aws-iam" + AwsIam = "aws-iam", + Redis = "redis", + AwsElastiCache = "aws-elasticache" } export enum SqlProviders { @@ -30,47 +32,72 @@ export enum SqlProviders { export type TDynamicSecretProvider = | { - type: DynamicSecretProviders.SqlDatabase; - inputs: { - client: SqlProviders; - host: string; - port: number; - database: string; - username: string; - password: string; - creationStatement: string; - revocationStatement: string; - renewStatement?: string; - ca?: string | undefined; - }; - } + type: DynamicSecretProviders.SqlDatabase; + inputs: { + client: SqlProviders; + host: string; + port: number; + database: string; + username: string; + password: string; + creationStatement: string; + revocationStatement: string; + renewStatement?: string; + ca?: string | undefined; + }; + } | { - type: DynamicSecretProviders.Cassandra; - inputs: { - host: string; - port: number; - keyspace?: string; - localDataCenter: string; - username: string; - password: string; - creationStatement: string; - revocationStatement: string; - renewStatement?: string; - ca?: string | undefined; - }; - } + type: DynamicSecretProviders.Cassandra; + inputs: { + host: string; + port: number; + keyspace?: string; + localDataCenter: string; + username: string; + password: string; + creationStatement: string; + revocationStatement: string; + renewStatement?: string; + ca?: string | undefined; + }; + } | { - type: DynamicSecretProviders.AwsIam; - inputs: { - accessKey: string; - secretAccessKey: string; - region: string; - awsPath?: string; - policyDocument?: string; - userGroups?: string; - policyArns?: string; + type: DynamicSecretProviders.AwsIam; + inputs: { + accessKey: string; + secretAccessKey: string; + region: string; + awsPath?: string; + policyDocument?: string; + userGroups?: string; + policyArns?: string; + }; + } + | { + type: DynamicSecretProviders.Redis; + inputs: { + host: string; + port: number; + username: string; + password?: string; + creationStatement: string; + renewStatement?: string; + 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 = { projectSlug: string; 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 + + ( + +