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 { CassandraProvider } from "./cassandra";
import { DynamicSecretProviders } from "./models";
@@ -8,5 +9,6 @@ export const buildDynamicSecretProviders = () => ({
[DynamicSecretProviders.SqlDatabase]: SqlDatabaseProvider(),
[DynamicSecretProviders.Cassandra]: CassandraProvider(),
[DynamicSecretProviders.AwsIam]: AwsIamProvider(),
[DynamicSecretProviders.Redis]: RedisDatabaseProvider()
[DynamicSecretProviders.Redis]: RedisDatabaseProvider(),
[DynamicSecretProviders.AwsElastiCache]: AwsElastiCacheDatabaseProvider()
});
@@ -7,51 +7,28 @@ export enum SqlProviders {
MsSQL = "mssql"
}
export enum RedisProviders {
Redis = "redis",
Elasticache = "elasticache"
}
export const DynamicSecretRedisDBSchema = z.object({
host: z.string().trim().toLowerCase(),
port: z.number(),
username: z.string().trim(), // this is often "default".
password: z.string().trim().optional(),
export const DynamicSecretRedisDBSchema = z
.object({
client: z.nativeEnum(RedisProviders),
host: z.string().trim().toLowerCase(),
port: z.number(),
username: z.string().trim(), // this is often "default".
password: z.string().trim().optional(),
creationStatement: z.string().trim(),
revocationStatement: z.string().trim(),
renewStatement: z.string().trim().optional(),
ca: z.string().optional()
});
elastiCacheIamUsername: z.string().trim().optional(),
elastiCacheRegion: z.string().trim().optional(),
export const DynamicSecretAwsElastiCacheSchema = z.object({
clusterName: z.string().trim().min(1),
accessKeyId: z.string().trim().min(1),
secretAccessKey: z.string().trim().min(1),
creationStatement: z.string().trim(),
revocationStatement: z.string().trim(),
renewStatement: z.string().trim().optional(),
ca: z.string().optional()
})
.refine(
(data) => {
if (data.client === RedisProviders.Elasticache) {
return !!data.elastiCacheIamUsername;
}
return true;
},
{
message: "elastiCacheIamUsername is required when client is ElastiCache",
path: ["elastiCacheIamUsername"]
}
)
.refine(
(data) => {
if (data.client === RedisProviders.Elasticache) {
return !!data.elastiCacheRegion;
}
return true;
},
{
message: "elastiCacheRegion is required when client is ElastiCache",
path: ["elastiCacheRegion"]
}
);
region: z.string().trim(),
creationStatement: z.string().trim(),
revocationStatement: z.string().trim(),
ca: z.string().optional()
});
export const DynamicSecretSqlDBSchema = z.object({
client: z.nativeEnum(SqlProviders),
@@ -94,14 +71,16 @@ export enum DynamicSecretProviders {
SqlDatabase = "sql-database",
Cassandra = "cassandra",
AwsIam = "aws-iam",
Redis = "redis"
Redis = "redis",
AwsElastiCache = "aws-elasticache"
}
export const DynamicSecretProviderSchema = z.discriminatedUnion("type", [
z.object({ type: z.literal(DynamicSecretProviders.SqlDatabase), inputs: DynamicSecretSqlDBSchema }),
z.object({ type: z.literal(DynamicSecretProviders.Cassandra), inputs: DynamicSecretCassandraSchema }),
z.object({ type: z.literal(DynamicSecretProviders.AwsIam), inputs: DynamicSecretAwsIamSchema }),
z.object({ type: z.literal(DynamicSecretProviders.Redis), inputs: DynamicSecretRedisDBSchema })
z.object({ type: z.literal(DynamicSecretProviders.Redis), inputs: DynamicSecretRedisDBSchema }),
z.object({ type: z.literal(DynamicSecretProviders.AwsElastiCache), inputs: DynamicSecretAwsElastiCacheSchema })
]);
export type TDynamicProviderFns = {
@@ -4,25 +4,19 @@ import { Redis } from "ioredis";
import { customAlphabet } from "nanoid";
import { z } from "zod";
import { CreateElastiCacheUserSchema, ElastiCacheConnector, ElastiCacheUserManager } from "@app/lib/aws";
import { getConfig } from "@app/lib/config/env";
import { BadRequestError } from "@app/lib/errors";
import { getDbConnectionHost } from "@app/lib/knex";
import { alphaNumericNanoId } from "@app/lib/nanoid";
import { DynamicSecretRedisDBSchema, RedisProviders, TDynamicProviderFns } from "./models";
import { DynamicSecretRedisDBSchema, TDynamicProviderFns } from "./models";
const generatePassword = () => {
const charset = "abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789-_.~!*$#";
return customAlphabet(charset, 64)();
};
const generateUsername = (provider: RedisProviders) => {
if (provider === RedisProviders.Elasticache) {
const charset = "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789-";
return `inf-${customAlphabet(charset, 32)()}`; // Username must start with an ascii letter, so we prepend the username with "inf-"
}
const generateUsername = () => {
return alphaNumericNanoId(32);
};
@@ -60,22 +54,9 @@ export const RedisDatabaseProvider = (): TDynamicProviderFns => {
const validateProviderInputs = async (inputs: unknown) => {
const appCfg = getConfig();
const isCloud = Boolean(appCfg.LICENSE_SERVER_KEY); // quick and dirty way to check if its cloud or not
const dbHost = appCfg.REDIS_URL || getDbConnectionHost(appCfg.REDIS_URL);
const providerInputs = DynamicSecretRedisDBSchema.parse(inputs);
if (providerInputs.client === RedisProviders.Elasticache) {
JSON.parse(providerInputs.creationStatement);
JSON.parse(providerInputs.revocationStatement);
if (providerInputs.renewStatement) {
JSON.parse(providerInputs.renewStatement);
}
if (!providerInputs.elastiCacheRegion) {
throw new BadRequestError({ message: "elastiCacheRegion is required when client is ElastiCache" });
}
}
const dbHost = appCfg.DB_HOST || getDbConnectionHost(appCfg.DB_CONNECTION_URI);
const providerInputs = await DynamicSecretRedisDBSchema.parseAsync(inputs);
if (
isCloud &&
// localhost
@@ -85,62 +66,36 @@ export const RedisDatabaseProvider = (): TDynamicProviderFns => {
providerInputs.host.match(/^192\.168\.\d+\.\d+/))
)
throw new BadRequestError({ message: "Invalid db host" });
if (providerInputs.host === "localhost" || dbHost === providerInputs.host)
if (providerInputs.host === "localhost" || providerInputs.host === "127.0.0.1" || dbHost === providerInputs.host)
throw new BadRequestError({ message: "Invalid db host" });
return providerInputs;
};
const getClient = async (providerInputs: z.infer<typeof DynamicSecretRedisDBSchema>) => {
let connection: Redis | null = null;
try {
if (providerInputs.client === RedisProviders.Elasticache) {
const connectionUri = await ElastiCacheConnector(
{
host: providerInputs.host,
port: providerInputs.port,
userId: providerInputs.elastiCacheIamUsername!
},
{
accessKeyId: providerInputs.username,
secretAccessKey: providerInputs.password!
},
providerInputs.elastiCacheRegion!
).createConnectionUri();
connection = new Redis(connectionUri, {
...(providerInputs.ca && {
tls: {
rejectUnauthorized: false,
ca: providerInputs.ca
}
})
});
} else if (providerInputs.client === RedisProviders.Redis) {
connection = new Redis({
username: providerInputs.username,
host: providerInputs.host,
port: providerInputs.port,
password: providerInputs.password || undefined,
...(providerInputs.ca && {
tls: {
rejectUnauthorized: false,
ca: providerInputs.ca
}
})
});
}
if (connection === null) {
throw new BadRequestError({ message: "Failed to obtain a valid Redis client" });
}
connection = new Redis({
username: providerInputs.username,
host: providerInputs.host,
port: providerInputs.port,
password: providerInputs.password,
...(providerInputs.ca && {
tls: {
rejectUnauthorized: false,
ca: providerInputs.ca
}
})
});
let result: string;
if (providerInputs.password && providerInputs.client === RedisProviders.Redis) {
if (providerInputs.password) {
result = await connection.auth(providerInputs.username, providerInputs.password, () => {});
if (result !== "OK") {
throw new BadRequestError({ message: `Invalid credentials, Redis returned ${result} status` });
}
} else {
result = await connection.auth(providerInputs.username, () => {});
}
if (result !== "OK") {
throw new BadRequestError({ message: `Invalid credentials, Redis returned ${result} status` });
}
return connection;
@@ -164,54 +119,25 @@ export const RedisDatabaseProvider = (): TDynamicProviderFns => {
};
const create = async (inputs: unknown, expireAt: number) => {
console.log(inputs);
const providerInputs = await validateProviderInputs(inputs);
const connection = await getClient(providerInputs);
const leaseUsername = generateUsername(providerInputs.client);
const leasePassword = generatePassword();
const leaseExpiration = new Date(expireAt).toISOString();
const username = generateUsername();
const password = generatePassword();
const expiration = new Date(expireAt).toISOString();
if (providerInputs.client === RedisProviders.Redis) {
const creationStatement = handlebars.compile(providerInputs.creationStatement, { noEscape: true })({
username: leaseUsername,
password: leasePassword,
expiration: leaseExpiration
});
const queries = creationStatement.toString().split(";").filter(Boolean);
const creationStatement = handlebars.compile(providerInputs.creationStatement, { noEscape: true })({
username,
password,
expiration
});
await executeTransactions(connection, queries);
const queries = creationStatement.toString().split(";").filter(Boolean);
await connection.quit();
return { entityId: leaseUsername, data: { DB_USERNAME: leaseUsername, DB_PASSWORD: leasePassword } };
}
if (providerInputs.client === RedisProviders.Elasticache) {
const parsedCreationData = CreateElastiCacheUserSchema.parse(JSON.parse(providerInputs.creationStatement));
await executeTransactions(connection, queries);
await ElastiCacheUserManager(
{
accessKeyId: providerInputs.username,
secretAccessKey: providerInputs.password!
},
providerInputs.elastiCacheRegion!
).createUser({
AccessString: parsedCreationData.AccessString,
Engine: parsedCreationData.Engine,
UserId: leaseUsername,
UserName: leaseUsername,
Passwords: [leasePassword]
});
return {
entityId: leaseUsername,
data: {
DB_USERNAME: leaseUsername,
DB_PASSWORD: leasePassword
}
};
}
throw new BadRequestError({ message: "Invalid client type" });
await connection.quit();
return { entityId: username, data: { DB_USERNAME: username, DB_PASSWORD: password } };
};
const revoke = async (inputs: unknown, entityId: string) => {
@@ -220,19 +146,6 @@ export const RedisDatabaseProvider = (): TDynamicProviderFns => {
const username = entityId;
if (providerInputs.client === RedisProviders.Elasticache) {
await ElastiCacheUserManager(
{
accessKeyId: providerInputs.username,
secretAccessKey: providerInputs.password!
},
providerInputs.elastiCacheRegion!
).deleteUser({ UserId: username });
await connection.quit();
return { entityId: username };
}
const revokeStatement = handlebars.compile(providerInputs.revocationStatement)({ username });
const queries = revokeStatement.toString().split(";").filter(Boolean);