feat(server): added dynamic secret cassandra

This commit is contained in:
Akhil Mohan
2024-04-22 16:14:55 +05:30
parent ea6e739b46
commit a0f0593e2d
6 changed files with 211 additions and 36 deletions

View File

@@ -35,6 +35,7 @@
"axios-retry": "^4.0.0", "axios-retry": "^4.0.0",
"bcrypt": "^5.1.1", "bcrypt": "^5.1.1",
"bullmq": "^5.3.3", "bullmq": "^5.3.3",
"cassandra-driver": "^4.7.2",
"dotenv": "^16.4.1", "dotenv": "^16.4.1",
"fastify": "^4.26.0", "fastify": "^4.26.0",
"fastify-plugin": "^4.5.1", "fastify-plugin": "^4.5.1",
@@ -4565,6 +4566,15 @@
"@types/lodash": "*" "@types/lodash": "*"
} }
}, },
"node_modules/@types/long": {
"version": "5.0.0",
"resolved": "https://registry.npmjs.org/@types/long/-/long-5.0.0.tgz",
"integrity": "sha512-eQs9RsucA/LNjnMoJvWG/nXa7Pot/RbBzilF/QRIU/xRl+0ApxrSUFsV5lmf01SvSlqMzJ7Zwxe440wmz2SJGA==",
"deprecated": "This is a stub types definition. long provides its own type definitions, so you do not need this installed.",
"dependencies": {
"long": "*"
}
},
"node_modules/@types/mime": { "node_modules/@types/mime": {
"version": "1.3.5", "version": "1.3.5",
"resolved": "https://registry.npmjs.org/@types/mime/-/mime-1.3.5.tgz", "resolved": "https://registry.npmjs.org/@types/mime/-/mime-1.3.5.tgz",
@@ -5313,6 +5323,14 @@
"node": ">=0.4.0" "node": ">=0.4.0"
} }
}, },
"node_modules/adm-zip": {
"version": "0.5.12",
"resolved": "https://registry.npmjs.org/adm-zip/-/adm-zip-0.5.12.tgz",
"integrity": "sha512-6TVU49mK6KZb4qG6xWaaM4C7sA/sgUMLy/JYMOzkcp3BvVLpW0fXDFQiIzAuxFCt/2+xD7fNIiPFAoLZPhVNLQ==",
"engines": {
"node": ">=6.0"
}
},
"node_modules/agent-base": { "node_modules/agent-base": {
"version": "6.0.2", "version": "6.0.2",
"resolved": "https://registry.npmjs.org/agent-base/-/agent-base-6.0.2.tgz", "resolved": "https://registry.npmjs.org/agent-base/-/agent-base-6.0.2.tgz",
@@ -6190,6 +6208,20 @@
"node": ">=6" "node": ">=6"
} }
}, },
"node_modules/cassandra-driver": {
"version": "4.7.2",
"resolved": "https://registry.npmjs.org/cassandra-driver/-/cassandra-driver-4.7.2.tgz",
"integrity": "sha512-gwl1DeYvL8Wy3i1GDMzFtpUg5G473fU7EnHFZj7BUtdLB7loAfgZgB3zBhROc9fbaDSUDs6YwOPPojS5E1kbSA==",
"dependencies": {
"@types/long": "~5.0.0",
"@types/node": ">=8",
"adm-zip": "~0.5.10",
"long": "~5.2.3"
},
"engines": {
"node": ">=16"
}
},
"node_modules/chai": { "node_modules/chai": {
"version": "4.4.1", "version": "4.4.1",
"resolved": "https://registry.npmjs.org/chai/-/chai-4.4.1.tgz", "resolved": "https://registry.npmjs.org/chai/-/chai-4.4.1.tgz",

View File

@@ -96,6 +96,7 @@
"axios-retry": "^4.0.0", "axios-retry": "^4.0.0",
"bcrypt": "^5.1.1", "bcrypt": "^5.1.1",
"bullmq": "^5.3.3", "bullmq": "^5.3.3",
"cassandra-driver": "^4.7.2",
"dotenv": "^16.4.1", "dotenv": "^16.4.1",
"fastify": "^4.26.0", "fastify": "^4.26.0",
"fastify-plugin": "^4.5.1", "fastify-plugin": "^4.5.1",

View File

@@ -0,0 +1,125 @@
import cassandra from "cassandra-driver";
import handlebars from "handlebars";
import { customAlphabet } from "nanoid";
import { z } from "zod";
import { BadRequestError } from "@app/lib/errors";
import { alphaNumericNanoId } from "@app/lib/nanoid";
import { DynamicSecretCassandraSchema, TDynamicProviderFns } from "./models";
const generatePassword = (size = 48) => {
const charset = "abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789-_.~!*$#";
return customAlphabet(charset, 48)(size);
};
const generateUsername = () => {
return alphaNumericNanoId(32);
};
export const CassandraProvider = (): TDynamicProviderFns => {
const validateProviderInputs = async (inputs: unknown) => {
const providerInputs = await DynamicSecretCassandraSchema.parseAsync(inputs);
if (providerInputs.host === "localhost" || providerInputs.host === "127.0.0.1") {
throw new BadRequestError({ message: "Invalid db host" });
}
return providerInputs;
};
const getClient = async (providerInputs: z.infer<typeof DynamicSecretCassandraSchema>) => {
const sslOptions = providerInputs.ca ? { rejectUnauthorized: false, ca: providerInputs.ca } : undefined;
const client = new cassandra.Client({
sslOptions,
protocolOptions: {
port: providerInputs.port
},
credentials: {
username: providerInputs.username,
password: providerInputs.password
},
keyspace: providerInputs.keyspace,
localDataCenter: providerInputs?.localDataCenter,
contactPoints: providerInputs.host.split(",").filter(Boolean)
});
return client;
};
const validateConnection = async (inputs: unknown) => {
const providerInputs = await validateProviderInputs(inputs);
const client = await getClient(providerInputs);
const isConnected = await client.execute("SELECT * FROM system_schema.keyspaces").then(() => true);
await client.shutdown();
return isConnected;
};
const create = async (inputs: unknown, expireAt: number) => {
const providerInputs = await validateProviderInputs(inputs);
const client = await getClient(providerInputs);
const username = generateUsername();
const password = generatePassword();
const { keyspace } = providerInputs;
const expiration = new Date(expireAt).toISOString();
const creationStatement = handlebars.compile(providerInputs.creationStatement, { noEscape: true })({
username,
password,
expiration,
keyspace
});
const queries = creationStatement.toString().split(";").filter(Boolean);
for (const query of queries) {
// eslint-disable-next-line
await client.execute(query);
}
await client.shutdown();
return { entityId: username, data: { DB_USERNAME: username, DB_PASSWORD: password } };
};
const revoke = async (inputs: unknown, entityId: string) => {
const providerInputs = await validateProviderInputs(inputs);
const client = await getClient(providerInputs);
const username = entityId;
const { keyspace } = providerInputs;
const revokeStatement = handlebars.compile(providerInputs.revocationStatement)({ username, keyspace });
const queries = revokeStatement.toString().split(";").filter(Boolean);
for (const query of queries) {
// eslint-disable-next-line
await client.execute(query);
}
await client.shutdown();
return { entityId: username };
};
const renew = async (inputs: unknown, entityId: string, expireAt: number) => {
const providerInputs = await validateProviderInputs(inputs);
const client = await getClient(providerInputs);
const username = entityId;
const expiration = new Date(expireAt).toISOString();
const { keyspace } = providerInputs;
const renewStatement = handlebars.compile(providerInputs.revocationStatement)({ username, keyspace, expiration });
const queries = renewStatement.toString().split(";").filter(Boolean);
for (const query of queries) {
// eslint-disable-next-line
await client.execute(query);
}
await client.shutdown();
return { entityId: username };
};
return {
validateProviderInputs,
validateConnection,
create,
revoke,
renew
};
};

View File

@@ -1,6 +1,8 @@
import { CassandraProvider } from "./cassandra";
import { DynamicSecretProviders } from "./models"; import { DynamicSecretProviders } from "./models";
import { SqlDatabaseProvider } from "./sql-database"; import { SqlDatabaseProvider } from "./sql-database";
export const buildDynamicSecretProviders = () => ({ export const buildDynamicSecretProviders = () => ({
[DynamicSecretProviders.SqlDatabase]: SqlDatabaseProvider() [DynamicSecretProviders.SqlDatabase]: SqlDatabaseProvider(),
[DynamicSecretProviders.Cassandra]: CassandraProvider()
}); });

View File

@@ -19,12 +19,27 @@ export const DynamicSecretSqlDBSchema = z.object({
ca: z.string().optional() ca: z.string().optional()
}); });
export const DynamicSecretCassandraSchema = z.object({
host: z.string().toLowerCase(),
port: z.number(),
localDataCenter: z.string().min(1),
keyspace: z.string().optional(),
username: z.string(),
password: z.string(),
creationStatement: z.string(),
revocationStatement: z.string(),
renewStatement: z.string().optional(),
ca: z.string().optional()
});
export enum DynamicSecretProviders { export enum DynamicSecretProviders {
SqlDatabase = "sql-database" SqlDatabase = "sql-database",
Cassandra = "cassandra"
} }
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 })
]); ]);
export type TDynamicProviderFns = { export type TDynamicProviderFns = {

View File

@@ -30,19 +30,24 @@ const generateUsername = (provider: SqlProviders) => {
export const SqlDatabaseProvider = (): TDynamicProviderFns => { export const SqlDatabaseProvider = (): 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 dbHost = appCfg.DB_HOST || getDbConnectionHost(appCfg.DB_CONNECTION_URI); const dbHost = appCfg.DB_HOST || getDbConnectionHost(appCfg.DB_CONNECTION_URI);
const providerInputs = await DynamicSecretSqlDBSchema.parseAsync(inputs); const providerInputs = await DynamicSecretSqlDBSchema.parseAsync(inputs);
if ( if (
isCloud &&
// localhost // 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 === "localhost" ||
providerInputs.host === "127.0.0.1" || providerInputs.host === "127.0.0.1" ||
// database infisical uses // database infisical uses
dbHost === providerInputs.host || dbHost === providerInputs.host
// 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" }); throw new BadRequestError({ message: "Invalid db host" });
return providerInputs; return providerInputs;
@@ -93,15 +98,13 @@ export const SqlDatabaseProvider = (): TDynamicProviderFns => {
database database
}); });
await db.transaction(async (tx) => const queries = creationStatement.toString().split(";").filter(Boolean);
Promise.all( await db.transaction(async (tx) => {
creationStatement for (const query of queries) {
.toString() // eslint-disable-next-line
.split(";") await tx.raw(query);
.filter(Boolean) }
.map((query) => tx.raw(query)) });
)
);
await db.destroy(); await db.destroy();
return { entityId: username, data: { DB_USERNAME: username, DB_PASSWORD: password } }; return { entityId: username, data: { DB_USERNAME: username, DB_PASSWORD: password } };
}; };
@@ -114,15 +117,13 @@ export const SqlDatabaseProvider = (): TDynamicProviderFns => {
const { database } = providerInputs; const { database } = providerInputs;
const revokeStatement = handlebars.compile(providerInputs.revocationStatement)({ username, database }); const revokeStatement = handlebars.compile(providerInputs.revocationStatement)({ username, database });
await db.transaction(async (tx) => const queries = revokeStatement.toString().split(";").filter(Boolean);
Promise.all( await db.transaction(async (tx) => {
revokeStatement for (const query of queries) {
.toString() // eslint-disable-next-line
.split(";") await tx.raw(query);
.filter(Boolean) }
.map((query) => tx.raw(query)) });
)
);
await db.destroy(); await db.destroy();
return { entityId: username }; return { entityId: username };
@@ -137,16 +138,15 @@ export const SqlDatabaseProvider = (): TDynamicProviderFns => {
const { database } = providerInputs; const { database } = providerInputs;
const renewStatement = handlebars.compile(providerInputs.renewStatement)({ username, expiration, database }); const renewStatement = handlebars.compile(providerInputs.renewStatement)({ username, expiration, database });
if (renewStatement) if (renewStatement) {
await db.transaction(async (tx) => const queries = renewStatement.toString().split(";").filter(Boolean);
Promise.all( await db.transaction(async (tx) => {
renewStatement for (const query of queries) {
.toString() // eslint-disable-next-line
.split(";") await tx.raw(query);
.filter(Boolean) }
.map((query) => tx.raw(query)) });
) }
);
await db.destroy(); await db.destroy();
return { entityId: username }; return { entityId: username };