From a0f0593e2d4e9ac66d2fbb8d162c0590b7a6278d Mon Sep 17 00:00:00 2001 From: Akhil Mohan Date: Mon, 22 Apr 2024 16:14:55 +0530 Subject: [PATCH] feat(server): added dynamic secret cassandra --- backend/package-lock.json | 32 +++++ backend/package.json | 1 + .../dynamic-secret/providers/cassandra.ts | 125 ++++++++++++++++++ .../dynamic-secret/providers/index.ts | 4 +- .../dynamic-secret/providers/models.ts | 19 ++- .../dynamic-secret/providers/sql-database.ts | 66 ++++----- 6 files changed, 211 insertions(+), 36 deletions(-) create mode 100644 backend/src/ee/services/dynamic-secret/providers/cassandra.ts diff --git a/backend/package-lock.json b/backend/package-lock.json index 1ef6c19e2..98b15e5f5 100644 --- a/backend/package-lock.json +++ b/backend/package-lock.json @@ -35,6 +35,7 @@ "axios-retry": "^4.0.0", "bcrypt": "^5.1.1", "bullmq": "^5.3.3", + "cassandra-driver": "^4.7.2", "dotenv": "^16.4.1", "fastify": "^4.26.0", "fastify-plugin": "^4.5.1", @@ -4565,6 +4566,15 @@ "@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": { "version": "1.3.5", "resolved": "https://registry.npmjs.org/@types/mime/-/mime-1.3.5.tgz", @@ -5313,6 +5323,14 @@ "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": { "version": "6.0.2", "resolved": "https://registry.npmjs.org/agent-base/-/agent-base-6.0.2.tgz", @@ -6190,6 +6208,20 @@ "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": { "version": "4.4.1", "resolved": "https://registry.npmjs.org/chai/-/chai-4.4.1.tgz", diff --git a/backend/package.json b/backend/package.json index f53ec9329..0f7b5a590 100644 --- a/backend/package.json +++ b/backend/package.json @@ -96,6 +96,7 @@ "axios-retry": "^4.0.0", "bcrypt": "^5.1.1", "bullmq": "^5.3.3", + "cassandra-driver": "^4.7.2", "dotenv": "^16.4.1", "fastify": "^4.26.0", "fastify-plugin": "^4.5.1", diff --git a/backend/src/ee/services/dynamic-secret/providers/cassandra.ts b/backend/src/ee/services/dynamic-secret/providers/cassandra.ts new file mode 100644 index 000000000..aea0b9c99 --- /dev/null +++ b/backend/src/ee/services/dynamic-secret/providers/cassandra.ts @@ -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) => { + 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 + }; +}; diff --git a/backend/src/ee/services/dynamic-secret/providers/index.ts b/backend/src/ee/services/dynamic-secret/providers/index.ts index d66e60802..34c049553 100644 --- a/backend/src/ee/services/dynamic-secret/providers/index.ts +++ b/backend/src/ee/services/dynamic-secret/providers/index.ts @@ -1,6 +1,8 @@ +import { CassandraProvider } from "./cassandra"; import { DynamicSecretProviders } from "./models"; import { SqlDatabaseProvider } from "./sql-database"; export const buildDynamicSecretProviders = () => ({ - [DynamicSecretProviders.SqlDatabase]: SqlDatabaseProvider() + [DynamicSecretProviders.SqlDatabase]: SqlDatabaseProvider(), + [DynamicSecretProviders.Cassandra]: CassandraProvider() }); diff --git a/backend/src/ee/services/dynamic-secret/providers/models.ts b/backend/src/ee/services/dynamic-secret/providers/models.ts index d3510d583..edb60d4b2 100644 --- a/backend/src/ee/services/dynamic-secret/providers/models.ts +++ b/backend/src/ee/services/dynamic-secret/providers/models.ts @@ -19,12 +19,27 @@ export const DynamicSecretSqlDBSchema = z.object({ 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 { - SqlDatabase = "sql-database" + SqlDatabase = "sql-database", + Cassandra = "cassandra" } 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 = { diff --git a/backend/src/ee/services/dynamic-secret/providers/sql-database.ts b/backend/src/ee/services/dynamic-secret/providers/sql-database.ts index 4c1d5438b..6745f573b 100644 --- a/backend/src/ee/services/dynamic-secret/providers/sql-database.ts +++ b/backend/src/ee/services/dynamic-secret/providers/sql-database.ts @@ -30,19 +30,24 @@ const generateUsername = (provider: SqlProviders) => { export const SqlDatabaseProvider = (): 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 DynamicSecretSqlDBSchema.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" || // database infisical uses - dbHost === providerInputs.host || - // internal ips - providerInputs.host === "host.docker.internal" || - providerInputs.host.match(/^10\.\d+\.\d+\.\d+/) || - providerInputs.host.match(/^192\.168\.\d+\.\d+/) + dbHost === providerInputs.host ) throw new BadRequestError({ message: "Invalid db host" }); return providerInputs; @@ -93,15 +98,13 @@ export const SqlDatabaseProvider = (): TDynamicProviderFns => { database }); - await db.transaction(async (tx) => - Promise.all( - creationStatement - .toString() - .split(";") - .filter(Boolean) - .map((query) => tx.raw(query)) - ) - ); + const queries = creationStatement.toString().split(";").filter(Boolean); + await db.transaction(async (tx) => { + for (const query of queries) { + // eslint-disable-next-line + await tx.raw(query); + } + }); await db.destroy(); return { entityId: username, data: { DB_USERNAME: username, DB_PASSWORD: password } }; }; @@ -114,15 +117,13 @@ export const SqlDatabaseProvider = (): TDynamicProviderFns => { const { database } = providerInputs; const revokeStatement = handlebars.compile(providerInputs.revocationStatement)({ username, database }); - await db.transaction(async (tx) => - Promise.all( - revokeStatement - .toString() - .split(";") - .filter(Boolean) - .map((query) => tx.raw(query)) - ) - ); + const queries = revokeStatement.toString().split(";").filter(Boolean); + await db.transaction(async (tx) => { + for (const query of queries) { + // eslint-disable-next-line + await tx.raw(query); + } + }); await db.destroy(); return { entityId: username }; @@ -137,16 +138,15 @@ export const SqlDatabaseProvider = (): TDynamicProviderFns => { const { database } = providerInputs; const renewStatement = handlebars.compile(providerInputs.renewStatement)({ username, expiration, database }); - if (renewStatement) - await db.transaction(async (tx) => - Promise.all( - renewStatement - .toString() - .split(";") - .filter(Boolean) - .map((query) => tx.raw(query)) - ) - ); + if (renewStatement) { + const queries = renewStatement.toString().split(";").filter(Boolean); + await db.transaction(async (tx) => { + for (const query of queries) { + // eslint-disable-next-line + await tx.raw(query); + } + }); + } await db.destroy(); return { entityId: username };