From 80a4f838a1846f594d6f66471236b2c2c2892cef Mon Sep 17 00:00:00 2001 From: = Date: Thu, 29 Aug 2024 13:17:20 +0530 Subject: [PATCH] feat: completed mongo atlas dynamic secret backend logic --- .../dynamic-secret/providers/index.ts | 4 +- .../dynamic-secret/providers/models.ts | 43 +++++- .../dynamic-secret/providers/mongo-atlas.ts | 146 ++++++++++++++++++ backend/src/lib/axios/digest-auth.ts | 57 +++++++ 4 files changed, 247 insertions(+), 3 deletions(-) create mode 100644 backend/src/ee/services/dynamic-secret/providers/mongo-atlas.ts create mode 100644 backend/src/lib/axios/digest-auth.ts diff --git a/backend/src/ee/services/dynamic-secret/providers/index.ts b/backend/src/ee/services/dynamic-secret/providers/index.ts index 1ee020625..e64b26d80 100644 --- a/backend/src/ee/services/dynamic-secret/providers/index.ts +++ b/backend/src/ee/services/dynamic-secret/providers/index.ts @@ -3,6 +3,7 @@ import { AwsIamProvider } from "./aws-iam"; import { CassandraProvider } from "./cassandra"; import { DynamicSecretProviders } from "./models"; import { RedisDatabaseProvider } from "./redis"; +import { MongoAtlasProvider } from "./mongo-atlas"; import { SqlDatabaseProvider } from "./sql-database"; export const buildDynamicSecretProviders = () => ({ @@ -10,5 +11,6 @@ export const buildDynamicSecretProviders = () => ({ [DynamicSecretProviders.Cassandra]: CassandraProvider(), [DynamicSecretProviders.AwsIam]: AwsIamProvider(), [DynamicSecretProviders.Redis]: RedisDatabaseProvider(), - [DynamicSecretProviders.AwsElastiCache]: AwsElastiCacheDatabaseProvider() + [DynamicSecretProviders.AwsElastiCache]: AwsElastiCacheDatabaseProvider(), + [DynamicSecretProviders.MongoAtlas]: MongoAtlasProvider() }); diff --git a/backend/src/ee/services/dynamic-secret/providers/models.ts b/backend/src/ee/services/dynamic-secret/providers/models.ts index 60cab6f80..06bc6a9ed 100644 --- a/backend/src/ee/services/dynamic-secret/providers/models.ts +++ b/backend/src/ee/services/dynamic-secret/providers/models.ts @@ -67,12 +67,50 @@ export const DynamicSecretAwsIamSchema = z.object({ policyArns: z.string().trim().optional() }); +export const DynamicSecretMongoAtlasSchema = z.object({ + adminPublicKey: z.string().trim().min(1).describe("Admin user public api key"), + adminPrivateKey: z.string().trim().min(1).describe("Admin user private api key"), + groupId: z + .string() + .trim() + .min(1) + .describe("Unique 24-hexadecimal digit string that identifies your project. This is same as project id"), + roles: z + .object({ + collectionName: z.string().optional().describe("Collection on which this role applies."), + databaseName: z.string().min(1).describe("Database to which the user is granted access privileges."), + roleName: z + .string() + .min(1) + .describe( + ' Enum: "atlasAdmin" "backup" "clusterMonitor" "dbAdmin" "dbAdminAnyDatabase" "enableSharding" "read" "readAnyDatabase" "readWrite" "readWriteAnyDatabase" "".Human-readable label that identifies a group of privileges assigned to a database user. This value can either be a built-in role or a custom role.' + ) + }) + .array() + .min(1), + scopes: z + .object({ + name: z + .string() + .min(1) + .describe( + "Human-readable label that identifies the cluster or MongoDB Atlas Data Lake that this database user can access." + ), + type: z + .string() + .min(1) + .describe("Category of resource that this database user can access. Enum: CLUSTER, DATA_LAKE, STREAM") + }) + .array() +}); + export enum DynamicSecretProviders { SqlDatabase = "sql-database", Cassandra = "cassandra", AwsIam = "aws-iam", Redis = "redis", - AwsElastiCache = "aws-elasticache" + AwsElastiCache = "aws-elasticache", + MongoAtlas = "mongo-db-atlas" } export const DynamicSecretProviderSchema = z.discriminatedUnion("type", [ @@ -80,7 +118,8 @@ export const DynamicSecretProviderSchema = z.discriminatedUnion("type", [ 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.AwsElastiCache), inputs: DynamicSecretAwsElastiCacheSchema }) + z.object({ type: z.literal(DynamicSecretProviders.AwsElastiCache), inputs: DynamicSecretAwsElastiCacheSchema }), + z.object({ type: z.literal(DynamicSecretProviders.MongoAtlas), inputs: DynamicSecretMongoAtlasSchema }) ]); export type TDynamicProviderFns = { diff --git a/backend/src/ee/services/dynamic-secret/providers/mongo-atlas.ts b/backend/src/ee/services/dynamic-secret/providers/mongo-atlas.ts new file mode 100644 index 000000000..69f54ce77 --- /dev/null +++ b/backend/src/ee/services/dynamic-secret/providers/mongo-atlas.ts @@ -0,0 +1,146 @@ +import axios, { AxiosError } from "axios"; +import { customAlphabet } from "nanoid"; +import { z } from "zod"; + +import { createDigestAuthRequestInterceptor } from "@app/lib/axios/digest-auth"; +import { alphaNumericNanoId } from "@app/lib/nanoid"; + +import { DynamicSecretMongoAtlasSchema, TDynamicProviderFns } from "./models"; + +const generatePassword = (size = 48) => { + const charset = "abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789-_.~!*$#"; + return customAlphabet(charset, 48)(size); +}; + +const generateUsername = () => { + return alphaNumericNanoId(32); +}; + +export const MongoAtlasProvider = (): TDynamicProviderFns => { + const validateProviderInputs = async (inputs: unknown) => { + const providerInputs = await DynamicSecretMongoAtlasSchema.parseAsync(inputs); + return providerInputs; + }; + + const getClient = async (providerInputs: z.infer) => { + const client = axios.create({ + baseURL: "https://cloud.mongodb.com/api/atlas", + headers: { + Accept: "application/vnd.atlas.2023-02-01+json", + "Content-Type": "application/json" + } + }); + const digestAuth = createDigestAuthRequestInterceptor( + client, + providerInputs.adminPublicKey, + providerInputs.adminPrivateKey + ); + return digestAuth; + }; + + const validateConnection = async (inputs: unknown) => { + const providerInputs = await validateProviderInputs(inputs); + const client = await getClient(providerInputs); + + const isConnected = await client({ + method: "GET", + url: `v2/groups/${providerInputs.groupId}/databaseUsers`, + params: { itemsPerPage: 1 } + }) + .then(() => true) + .catch((error) => { + if ((error as AxiosError).response) { + throw new Error(JSON.stringify((error as AxiosError).response?.data)); + } + throw error; + }); + 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 expiration = new Date(expireAt).toISOString(); + await client({ + method: "POST", + url: `/v2/groups/${providerInputs.groupId}/databaseUsers`, + data: { + roles: providerInputs.roles, + scopes: providerInputs.scopes, + deleteAfterDate: expiration, + username, + password, + databaseName: "admin", + groupId: providerInputs.groupId + } + }).catch((error) => { + if ((error as AxiosError).response) { + throw new Error(JSON.stringify((error as AxiosError).response?.data)); + } + throw error; + }); + 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 isExisting = await client({ + method: "GET", + url: `/v2/groups/${providerInputs.groupId}/databaseUsers/admin/${username}` + }).catch((err) => { + if ((err as AxiosError).response?.status === 404) return false; + throw err; + }); + if (isExisting) { + await client({ + method: "DELETE", + url: `/v2/groups/${providerInputs.groupId}/databaseUsers/admin/${username}` + }).catch((error) => { + if ((error as AxiosError).response) { + throw new Error(JSON.stringify((error as AxiosError).response?.data)); + } + throw error; + }); + } + + 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(); + + await client({ + method: "PATCH", + url: `/v2/groups/${providerInputs.groupId}/databaseUsers/admin/${username}`, + data: { + deleteAfterDate: expiration, + databaseName: "admin", + groupId: providerInputs.groupId + } + }).catch((error) => { + if ((error as AxiosError).response) { + throw new Error(JSON.stringify((error as AxiosError).response?.data)); + } + throw error; + }); + return { entityId: username }; + }; + + return { + validateProviderInputs, + validateConnection, + create, + revoke, + renew + }; +}; diff --git a/backend/src/lib/axios/digest-auth.ts b/backend/src/lib/axios/digest-auth.ts new file mode 100644 index 000000000..ee9dbd79b --- /dev/null +++ b/backend/src/lib/axios/digest-auth.ts @@ -0,0 +1,57 @@ +import crypto from "node:crypto"; + +import { AxiosError, AxiosInstance, AxiosRequestConfig } from "axios"; + +export const createDigestAuthRequestInterceptor = ( + axiosInstance: AxiosInstance, + username: string, + password: string +) => { + let nc = 0; + + return async (opts: AxiosRequestConfig) => { + try { + return await axiosInstance.request(opts); + } catch (err) { + const error = err as AxiosError; + const authHeader = (error?.response?.headers?.["www-authenticate"] as string) || ""; + + if (error?.response?.status !== 401 || !authHeader?.includes("nonce")) { + return Promise.reject(error.message); + } + + if (!error.config) { + return Promise.reject(error); + } + + const authDetails = authHeader.split(",").map((el) => el.split("=")); + nc += 1; + const nonceCount = nc.toString(16).padStart(8, "0"); + const cnonce = crypto.randomBytes(24).toString("hex"); + const realm = authDetails.find((el) => el[0].toLowerCase().indexOf("realm") > -1)?.[1].replace(/"/g, ""); + const nonce = authDetails.find((el) => el[0].toLowerCase().indexOf("nonce") > -1)?.[1].replace(/"/g, ""); + const ha1 = crypto.createHash("md5").update(`${username}:${realm}:${password}`).digest("hex"); + const path = opts.url; + + const ha2 = crypto + .createHash("md5") + .update(`${opts.method ?? "GET"}:${path}`) + .digest("hex"); + + const response = crypto + .createHash("md5") + .update(`${ha1}:${nonce}:${nonceCount}:${cnonce}:auth:${ha2}`) + .digest("hex"); + const authorization = `Digest username="${username}",realm="${realm}",nonce="${nonce}",uri="${path}",qop="auth",algorithm="MD5",response="${response}",nc="${nonceCount}",cnonce="${cnonce}"`; + + if (opts.headers) { + // eslint-disable-next-line + opts.headers.authorization = authorization; + } else { + // eslint-disable-next-line + opts.headers = { authorization }; + } + return axiosInstance.request(opts); + } + }; +};