diff --git a/backend/package-lock.json b/backend/package-lock.json index b3eef6bd1..088e3b73e 100644 --- a/backend/package-lock.json +++ b/backend/package-lock.json @@ -13040,12 +13040,12 @@ } }, "node_modules/micromatch": { - "version": "4.0.5", - "resolved": "https://registry.npmjs.org/micromatch/-/micromatch-4.0.5.tgz", - "integrity": "sha512-DMy+ERcEW2q8Z2Po+WNXuw3c5YaUSFjAO5GsJqfEl7UjvtIuFKO6ZrKvcItdy98dwFI2N1tg3zNIdKaQT+aNdA==", + "version": "4.0.8", + "resolved": "https://registry.npmjs.org/micromatch/-/micromatch-4.0.8.tgz", + "integrity": "sha512-PXwfBhYu0hBCPw8Dn0E+WDYb7af3dSLVWKi3HGv84IdF4TyFoC0ysxFd0Goxw7nSv4T/PzEJQxsYsEiFCKo2BA==", "dev": true, "dependencies": { - "braces": "^3.0.2", + "braces": "^3.0.3", "picomatch": "^2.3.1" }, "engines": { diff --git a/backend/src/ee/services/dynamic-secret/providers/index.ts b/backend/src/ee/services/dynamic-secret/providers/index.ts index 1ee020625..0eb613df2 100644 --- a/backend/src/ee/services/dynamic-secret/providers/index.ts +++ b/backend/src/ee/services/dynamic-secret/providers/index.ts @@ -2,6 +2,7 @@ import { AwsElastiCacheDatabaseProvider } from "./aws-elasticache"; import { AwsIamProvider } from "./aws-iam"; import { CassandraProvider } from "./cassandra"; import { DynamicSecretProviders } from "./models"; +import { MongoAtlasProvider } from "./mongo-atlas"; import { RedisDatabaseProvider } from "./redis"; import { SqlDatabaseProvider } from "./sql-database"; @@ -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); + } + }; +}; diff --git a/docs/documentation/platform/dynamic-secrets/mongo-atlas.mdx b/docs/documentation/platform/dynamic-secrets/mongo-atlas.mdx new file mode 100644 index 000000000..f9352f2e5 --- /dev/null +++ b/docs/documentation/platform/dynamic-secrets/mongo-atlas.mdx @@ -0,0 +1,114 @@ +--- +title: "Mongo Atlas" +description: "Learn how to dynamically generate Mongo Atlas Database user credentials." +--- + +The Infisical Mongo Atlas dynamic secret allows you to generate Mongo Atlas Database credentials on demand based on configured role. + +## Prerequisite +Create a project scopped API Key with the required permission in your Mongo Atlas following the [official doc](https://www.mongodb.com/docs/atlas/configure-api-access/#grant-programmatic-access-to-a-project). + + + The API Key must have permission to manage users in the project. + + +## Set up Dynamic Secrets with Mongo Atlas + + + + 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.png) + + + ![Dynamic Secret Modal](../../../images/platform/dynamic-secrets/dynamic-secret-atlas-modal.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 public key of your generated Atlas API Key. This acts as a username. + + + + The private key of your generated Atlas API Key. This acts as a password. + + + + Unique 24-hexadecimal digit string that identifies your project. This is same as project id + + + + List that provides the pairings of one role with one applicable database. + - **Database Name**: Database to which the user is granted access privileges. + - **Collection**: Collection on which this role applies. + - **Role Name**: 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. + - Enum: `atlasAdmin` `backup` `clusterMonitor` `dbAdmin` `dbAdminAnyDatabase` `enableSharding` `read` `readAnyDatabase` `readWrite` `readWriteAnyDatabase` ``. + + + ![Dynamic Secret Setup Modal](../../../images/platform/dynamic-secrets/dynamic-secret-modal-atlas.png) + + + + List that contains clusters, MongoDB Atlas Data Lakes, and MongoDB Atlas Streams Instances that this database user can access. If omitted, MongoDB Cloud grants the database user access to all the clusters, MongoDB Atlas Data Lakes, and MongoDB Atlas Streams Instances in the project. + + ![Modify Scope Modal](../../../images/platform/dynamic-secrets/advanced-option-atlas.png) + - **Label**: Human-readable label that identifies the cluster or MongoDB Atlas Data Lake that this database user can access. + - **Type**: Category of resource that this database user can access. + + + 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 certficate. + + + ![Dynamic Secret](../../../images/platform/dynamic-secrets/dynamic-secret.png) + + + 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.png) + ![Dynamic Secret](/images/platform/dynamic-secrets/dynamic-secret-lease-empty.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.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 for it will be shown to you. + + ![Provision Lease](/images/platform/dynamic-secrets/lease-values.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.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.png) + + + Lease renewals cannot exceed the maximum TTL set when configuring the dynamic secret + diff --git a/docs/images/platform/dynamic-secrets/advanced-option-atlas.png b/docs/images/platform/dynamic-secrets/advanced-option-atlas.png new file mode 100644 index 000000000..50c9f89bd Binary files /dev/null and b/docs/images/platform/dynamic-secrets/advanced-option-atlas.png differ diff --git a/docs/images/platform/dynamic-secrets/dynamic-secret-atlas-modal.png b/docs/images/platform/dynamic-secrets/dynamic-secret-atlas-modal.png new file mode 100644 index 000000000..f18ee3547 Binary files /dev/null and b/docs/images/platform/dynamic-secrets/dynamic-secret-atlas-modal.png differ diff --git a/docs/images/platform/dynamic-secrets/dynamic-secret-modal-atlas.png b/docs/images/platform/dynamic-secrets/dynamic-secret-modal-atlas.png new file mode 100644 index 000000000..e3989b00f Binary files /dev/null and b/docs/images/platform/dynamic-secrets/dynamic-secret-modal-atlas.png differ diff --git a/docs/mint.json b/docs/mint.json index 34cb085a8..e7db8ce6c 100644 --- a/docs/mint.json +++ b/docs/mint.json @@ -156,7 +156,8 @@ "documentation/platform/dynamic-secrets/cassandra", "documentation/platform/dynamic-secrets/redis", "documentation/platform/dynamic-secrets/aws-elasticache", - "documentation/platform/dynamic-secrets/aws-iam" + "documentation/platform/dynamic-secrets/aws-iam", + "documentation/platform/dynamic-secrets/mongo-atlas" ] }, { diff --git a/frontend/package-lock.json b/frontend/package-lock.json index 74f00b74d..3148f5da2 100644 --- a/frontend/package-lock.json +++ b/frontend/package-lock.json @@ -4,6 +4,7 @@ "requires": true, "packages": { "": { + "name": "frontend", "dependencies": { "@casl/ability": "^6.5.0", "@casl/react": "^3.1.0", @@ -84,6 +85,7 @@ "react-grid-layout": "^1.3.4", "react-hook-form": "^7.43.0", "react-i18next": "^12.2.2", + "react-icons": "^5.3.0", "react-mailchimp-subscribe": "^2.1.3", "react-markdown": "^8.0.3", "react-redux": "^8.0.2", @@ -8609,26 +8611,6 @@ "integrity": "sha512-AjwI4MvWx3HAOaZqYsjKWyEObT9lcVV0Y0V8nXo6cXzN8ZiMxVhf6F3d/UNvXVGKrEzL/Dluc5p+y9GkzlTWig==", "dev": true }, - "node_modules/@types/eslint": { - "version": "8.56.2", - "resolved": "https://registry.npmjs.org/@types/eslint/-/eslint-8.56.2.tgz", - "integrity": "sha512-uQDwm1wFHmbBbCZCqAlq6Do9LYwByNZHWzXppSnay9SuwJ+VRbjkbLABer54kcPnMSlG6Fdiy2yaFXm/z9Z5gw==", - "dev": true, - "dependencies": { - "@types/estree": "*", - "@types/json-schema": "*" - } - }, - "node_modules/@types/eslint-scope": { - "version": "3.7.7", - "resolved": "https://registry.npmjs.org/@types/eslint-scope/-/eslint-scope-3.7.7.tgz", - "integrity": "sha512-MzMFlSLBqNF2gcHWO0G1vP/YQyfvrxZ0bF+u7mzUdZ1/xK4A4sru+nraZz5i3iEIk1l1uyicaDVTB4QbbEkAYg==", - "dev": true, - "dependencies": { - "@types/eslint": "*", - "@types/estree": "*" - } - }, "node_modules/@types/estree": { "version": "0.0.51", "resolved": "https://registry.npmjs.org/@types/estree/-/estree-0.0.51.tgz", @@ -9298,9 +9280,9 @@ "dev": true }, "node_modules/@webassemblyjs/ast": { - "version": "1.11.6", - "resolved": "https://registry.npmjs.org/@webassemblyjs/ast/-/ast-1.11.6.tgz", - "integrity": "sha512-IN1xI7PwOvLPgjcf180gC1bqn3q/QaOCwYUahIOhbYUu8KA/3tw2RT/T0Gidi1l7Hhj5D/INhJxiICObqpMu4Q==", + "version": "1.12.1", + "resolved": "https://registry.npmjs.org/@webassemblyjs/ast/-/ast-1.12.1.tgz", + "integrity": "sha512-EKfMUOPRRUTy5UII4qJDGPpqfwjOmZ5jeGFwid9mnoqIFK+e0vqoi1qH56JpmZSzEL53jKnNzScdmftJyG5xWg==", "dev": true, "dependencies": { "@webassemblyjs/helper-numbers": "1.11.6", @@ -9320,9 +9302,9 @@ "dev": true }, "node_modules/@webassemblyjs/helper-buffer": { - "version": "1.11.6", - "resolved": "https://registry.npmjs.org/@webassemblyjs/helper-buffer/-/helper-buffer-1.11.6.tgz", - "integrity": "sha512-z3nFzdcp1mb8nEOFFk8DrYLpHvhKC3grJD2ardfKOzmbmJvEf/tPIqCY+sNcwZIY8ZD7IkB2l7/pqhUhqm7hLA==", + "version": "1.12.1", + "resolved": "https://registry.npmjs.org/@webassemblyjs/helper-buffer/-/helper-buffer-1.12.1.tgz", + "integrity": "sha512-nzJwQw99DNDKr9BVCOZcLuJJUlqkJh+kVzVl6Fmq/tI5ZtEyWT1KZMyOXltXLZJmDtvLCDgwsyrkohEtopTXCw==", "dev": true }, "node_modules/@webassemblyjs/helper-numbers": { @@ -9343,15 +9325,15 @@ "dev": true }, "node_modules/@webassemblyjs/helper-wasm-section": { - "version": "1.11.6", - "resolved": "https://registry.npmjs.org/@webassemblyjs/helper-wasm-section/-/helper-wasm-section-1.11.6.tgz", - "integrity": "sha512-LPpZbSOwTpEC2cgn4hTydySy1Ke+XEu+ETXuoyvuyezHO3Kjdu90KK95Sh9xTbmjrCsUwvWwCOQQNta37VrS9g==", + "version": "1.12.1", + "resolved": "https://registry.npmjs.org/@webassemblyjs/helper-wasm-section/-/helper-wasm-section-1.12.1.tgz", + "integrity": "sha512-Jif4vfB6FJlUlSbgEMHUyk1j234GTNG9dBJ4XJdOySoj518Xj0oGsNi59cUQF4RRMS9ouBUxDDdyBVfPTypa5g==", "dev": true, "dependencies": { - "@webassemblyjs/ast": "1.11.6", - "@webassemblyjs/helper-buffer": "1.11.6", + "@webassemblyjs/ast": "1.12.1", + "@webassemblyjs/helper-buffer": "1.12.1", "@webassemblyjs/helper-wasm-bytecode": "1.11.6", - "@webassemblyjs/wasm-gen": "1.11.6" + "@webassemblyjs/wasm-gen": "1.12.1" } }, "node_modules/@webassemblyjs/ieee754": { @@ -9379,28 +9361,28 @@ "dev": true }, "node_modules/@webassemblyjs/wasm-edit": { - "version": "1.11.6", - "resolved": "https://registry.npmjs.org/@webassemblyjs/wasm-edit/-/wasm-edit-1.11.6.tgz", - "integrity": "sha512-Ybn2I6fnfIGuCR+Faaz7YcvtBKxvoLV3Lebn1tM4o/IAJzmi9AWYIPWpyBfU8cC+JxAO57bk4+zdsTjJR+VTOw==", + "version": "1.12.1", + "resolved": "https://registry.npmjs.org/@webassemblyjs/wasm-edit/-/wasm-edit-1.12.1.tgz", + "integrity": "sha512-1DuwbVvADvS5mGnXbE+c9NfA8QRcZ6iKquqjjmR10k6o+zzsRVesil54DKexiowcFCPdr/Q0qaMgB01+SQ1u6g==", "dev": true, "dependencies": { - "@webassemblyjs/ast": "1.11.6", - "@webassemblyjs/helper-buffer": "1.11.6", + "@webassemblyjs/ast": "1.12.1", + "@webassemblyjs/helper-buffer": "1.12.1", "@webassemblyjs/helper-wasm-bytecode": "1.11.6", - "@webassemblyjs/helper-wasm-section": "1.11.6", - "@webassemblyjs/wasm-gen": "1.11.6", - "@webassemblyjs/wasm-opt": "1.11.6", - "@webassemblyjs/wasm-parser": "1.11.6", - "@webassemblyjs/wast-printer": "1.11.6" + "@webassemblyjs/helper-wasm-section": "1.12.1", + "@webassemblyjs/wasm-gen": "1.12.1", + "@webassemblyjs/wasm-opt": "1.12.1", + "@webassemblyjs/wasm-parser": "1.12.1", + "@webassemblyjs/wast-printer": "1.12.1" } }, "node_modules/@webassemblyjs/wasm-gen": { - "version": "1.11.6", - "resolved": "https://registry.npmjs.org/@webassemblyjs/wasm-gen/-/wasm-gen-1.11.6.tgz", - "integrity": "sha512-3XOqkZP/y6B4F0PBAXvI1/bky7GryoogUtfwExeP/v7Nzwo1QLcq5oQmpKlftZLbT+ERUOAZVQjuNVak6UXjPA==", + "version": "1.12.1", + "resolved": "https://registry.npmjs.org/@webassemblyjs/wasm-gen/-/wasm-gen-1.12.1.tgz", + "integrity": "sha512-TDq4Ojh9fcohAw6OIMXqiIcTq5KUXTGRkVxbSo1hQnSy6lAM5GSdfwWeSxpAo0YzgsgF182E/U0mDNhuA0tW7w==", "dev": true, "dependencies": { - "@webassemblyjs/ast": "1.11.6", + "@webassemblyjs/ast": "1.12.1", "@webassemblyjs/helper-wasm-bytecode": "1.11.6", "@webassemblyjs/ieee754": "1.11.6", "@webassemblyjs/leb128": "1.11.6", @@ -9408,24 +9390,24 @@ } }, "node_modules/@webassemblyjs/wasm-opt": { - "version": "1.11.6", - "resolved": "https://registry.npmjs.org/@webassemblyjs/wasm-opt/-/wasm-opt-1.11.6.tgz", - "integrity": "sha512-cOrKuLRE7PCe6AsOVl7WasYf3wbSo4CeOk6PkrjS7g57MFfVUF9u6ysQBBODX0LdgSvQqRiGz3CXvIDKcPNy4g==", + "version": "1.12.1", + "resolved": "https://registry.npmjs.org/@webassemblyjs/wasm-opt/-/wasm-opt-1.12.1.tgz", + "integrity": "sha512-Jg99j/2gG2iaz3hijw857AVYekZe2SAskcqlWIZXjji5WStnOpVoat3gQfT/Q5tb2djnCjBtMocY/Su1GfxPBg==", "dev": true, "dependencies": { - "@webassemblyjs/ast": "1.11.6", - "@webassemblyjs/helper-buffer": "1.11.6", - "@webassemblyjs/wasm-gen": "1.11.6", - "@webassemblyjs/wasm-parser": "1.11.6" + "@webassemblyjs/ast": "1.12.1", + "@webassemblyjs/helper-buffer": "1.12.1", + "@webassemblyjs/wasm-gen": "1.12.1", + "@webassemblyjs/wasm-parser": "1.12.1" } }, "node_modules/@webassemblyjs/wasm-parser": { - "version": "1.11.6", - "resolved": "https://registry.npmjs.org/@webassemblyjs/wasm-parser/-/wasm-parser-1.11.6.tgz", - "integrity": "sha512-6ZwPeGzMJM3Dqp3hCsLgESxBGtT/OeCvCZ4TA1JUPYgmhAx38tTPR9JaKy0S5H3evQpO/h2uWs2j6Yc/fjkpTQ==", + "version": "1.12.1", + "resolved": "https://registry.npmjs.org/@webassemblyjs/wasm-parser/-/wasm-parser-1.12.1.tgz", + "integrity": "sha512-xikIi7c2FHXysxXe3COrVUPSheuBtpcfhbpFj4gmu7KRLYOzANztwUU0IbsqvMqzuNK2+glRGWCEqZo1WCLyAQ==", "dev": true, "dependencies": { - "@webassemblyjs/ast": "1.11.6", + "@webassemblyjs/ast": "1.12.1", "@webassemblyjs/helper-api-error": "1.11.6", "@webassemblyjs/helper-wasm-bytecode": "1.11.6", "@webassemblyjs/ieee754": "1.11.6", @@ -9434,12 +9416,12 @@ } }, "node_modules/@webassemblyjs/wast-printer": { - "version": "1.11.6", - "resolved": "https://registry.npmjs.org/@webassemblyjs/wast-printer/-/wast-printer-1.11.6.tgz", - "integrity": "sha512-JM7AhRcE+yW2GWYaKeHL5vt4xqee5N2WcezptmgyhNS+ScggqcT1OtXykhAb13Sn5Yas0j2uv9tHgrjwvzAP4A==", + "version": "1.12.1", + "resolved": "https://registry.npmjs.org/@webassemblyjs/wast-printer/-/wast-printer-1.12.1.tgz", + "integrity": "sha512-+X4WAlOisVWQMikjbcvY2e0rwPsKQ9F688lksZhBcPycBBuii3O7m8FACbDMWDojpAqvjIncrG8J0XHKyQfVeA==", "dev": true, "dependencies": { - "@webassemblyjs/ast": "1.11.6", + "@webassemblyjs/ast": "1.12.1", "@xtuc/long": "4.2.2" } }, @@ -12933,9 +12915,9 @@ "integrity": "sha512-JGmudTwg7yxMYvR/gWbalqqQiyu7WTFv2Xu3vw4cJHXPFxNgAk0oy8UHaer8nLF4lZJa+rNoj6GsrKIVJTV6Tw==" }, "node_modules/elliptic": { - "version": "6.5.4", - "resolved": "https://registry.npmjs.org/elliptic/-/elliptic-6.5.4.tgz", - "integrity": "sha512-iLhC6ULemrljPZb+QutR5TQGB+pdW6KGD5RSegS+8sorOZT+rdQFbsQFJgvN3eRqNALqJer4oQ16YvJHlU8hzQ==", + "version": "6.5.7", + "resolved": "https://registry.npmjs.org/elliptic/-/elliptic-6.5.7.tgz", + "integrity": "sha512-ESVCtTwiA+XhY3wyh24QqRGBoP3rEdDUl3EDUUo9tft074fi19IrdpH7hLCMMP3CIj7jb3W96rn8lt/BqIlt5Q==", "dev": true, "dependencies": { "bn.js": "^4.11.9", @@ -12998,9 +12980,9 @@ } }, "node_modules/enhanced-resolve": { - "version": "5.15.0", - "resolved": "https://registry.npmjs.org/enhanced-resolve/-/enhanced-resolve-5.15.0.tgz", - "integrity": "sha512-LXYT42KJ7lpIKECr2mAXIaMldcNCh/7E0KBKOu4KSfkHmP+mZmSs+8V5gBAqisWBy0OO4W5Oyys0GO1Y8KtdKg==", + "version": "5.17.1", + "resolved": "https://registry.npmjs.org/enhanced-resolve/-/enhanced-resolve-5.17.1.tgz", + "integrity": "sha512-LMHl3dXhTcfv8gM4kEzIUeTQ+7fpdA0l2tUf34BddXPkz2A5xJ5L/Pchd5BL6rdccM9QGvu0sWZzK1Z1t4wwyg==", "dev": true, "dependencies": { "graceful-fs": "^4.2.4", @@ -15867,11 +15849,11 @@ } }, "node_modules/infisical-node/node_modules/axios": { - "version": "1.6.5", - "resolved": "https://registry.npmjs.org/axios/-/axios-1.6.5.tgz", - "integrity": "sha512-Ii012v05KEVuUoFWmMW/UQv9aRIc3ZwkWDcM+h5Il8izZCtRVpDUfwpoFf7eOtajT3QiGR4yDUx7lPqHJULgbg==", + "version": "1.7.5", + "resolved": "https://registry.npmjs.org/axios/-/axios-1.7.5.tgz", + "integrity": "sha512-fZu86yCo+svH3uqJ/yTdQ0QHpQu5oL+/QE+QPSv6BZSkDAoky9vytxp7u5qk83OJFS3kEBcesWni9WTZAv3tSw==", "dependencies": { - "follow-redirects": "^1.15.4", + "follow-redirects": "^1.15.6", "form-data": "^4.0.0", "proxy-from-env": "^1.1.0" } @@ -18133,12 +18115,12 @@ ] }, "node_modules/micromatch": { - "version": "4.0.5", - "resolved": "https://registry.npmjs.org/micromatch/-/micromatch-4.0.5.tgz", - "integrity": "sha512-DMy+ERcEW2q8Z2Po+WNXuw3c5YaUSFjAO5GsJqfEl7UjvtIuFKO6ZrKvcItdy98dwFI2N1tg3zNIdKaQT+aNdA==", + "version": "4.0.8", + "resolved": "https://registry.npmjs.org/micromatch/-/micromatch-4.0.8.tgz", + "integrity": "sha512-PXwfBhYu0hBCPw8Dn0E+WDYb7af3dSLVWKi3HGv84IdF4TyFoC0ysxFd0Goxw7nSv4T/PzEJQxsYsEiFCKo2BA==", "dev": true, "dependencies": { - "braces": "^3.0.2", + "braces": "^3.0.3", "picomatch": "^2.3.1" }, "engines": { @@ -20968,6 +20950,14 @@ } } }, + "node_modules/react-icons": { + "version": "5.3.0", + "resolved": "https://registry.npmjs.org/react-icons/-/react-icons-5.3.0.tgz", + "integrity": "sha512-DnUk8aFbTyQPSkCfF8dbX6kQjXA9DktMeJqfjrg6cK9vwQVMxmcA3BfP4QoiztVmEHtwlTgLFsPuH2NskKT6eg==", + "peerDependencies": { + "react": "*" + } + }, "node_modules/react-is": { "version": "18.2.0", "resolved": "https://registry.npmjs.org/react-is/-/react-is-18.2.0.tgz", @@ -24479,9 +24469,9 @@ } }, "node_modules/watchpack": { - "version": "2.4.0", - "resolved": "https://registry.npmjs.org/watchpack/-/watchpack-2.4.0.tgz", - "integrity": "sha512-Lcvm7MGST/4fup+ifyKi2hjyIAwcdI4HRgtvTpIUxBRhB+RFtUh8XtDOxUfctVCnhVi+QQj49i91OyvzkJl6cg==", + "version": "2.4.2", + "resolved": "https://registry.npmjs.org/watchpack/-/watchpack-2.4.2.tgz", + "integrity": "sha512-TnbFSbcOCcDgjZ4piURLCbJ3nJhznVh9kw6F6iokjiFPl8ONxe9A6nMDVXDiNbrSfLILs6vB07F7wLBrwPYzJw==", "dev": true, "dependencies": { "glob-to-regexp": "^0.4.1", @@ -24506,34 +24496,33 @@ "integrity": "sha512-2JAn3z8AR6rjK8Sm8orRC0h/bcl/DqL7tRPdGZ4I1CjdF+EaMLmYxBHyXuKL849eucPFhvBoxMsflfOb8kxaeQ==" }, "node_modules/webpack": { - "version": "5.89.0", - "resolved": "https://registry.npmjs.org/webpack/-/webpack-5.89.0.tgz", - "integrity": "sha512-qyfIC10pOr70V+jkmud8tMfajraGCZMBWJtrmuBymQKCrLTRejBI8STDp1MCyZu/QTdZSeacCQYpYNQVOzX5kw==", + "version": "5.94.0", + "resolved": "https://registry.npmjs.org/webpack/-/webpack-5.94.0.tgz", + "integrity": "sha512-KcsGn50VT+06JH/iunZJedYGUJS5FGjow8wb9c0v5n1Om8O1g4L6LjtfxwlXIATopoQu+vOXXa7gYisWxCoPyg==", "dev": true, "dependencies": { - "@types/eslint-scope": "^3.7.3", - "@types/estree": "^1.0.0", - "@webassemblyjs/ast": "^1.11.5", - "@webassemblyjs/wasm-edit": "^1.11.5", - "@webassemblyjs/wasm-parser": "^1.11.5", + "@types/estree": "^1.0.5", + "@webassemblyjs/ast": "^1.12.1", + "@webassemblyjs/wasm-edit": "^1.12.1", + "@webassemblyjs/wasm-parser": "^1.12.1", "acorn": "^8.7.1", - "acorn-import-assertions": "^1.9.0", - "browserslist": "^4.14.5", + "acorn-import-attributes": "^1.9.5", + "browserslist": "^4.21.10", "chrome-trace-event": "^1.0.2", - "enhanced-resolve": "^5.15.0", + "enhanced-resolve": "^5.17.1", "es-module-lexer": "^1.2.1", "eslint-scope": "5.1.1", "events": "^3.2.0", "glob-to-regexp": "^0.4.1", - "graceful-fs": "^4.2.9", + "graceful-fs": "^4.2.11", "json-parse-even-better-errors": "^2.3.1", "loader-runner": "^4.2.0", "mime-types": "^2.1.27", "neo-async": "^2.6.2", "schema-utils": "^3.2.0", "tapable": "^2.1.1", - "terser-webpack-plugin": "^5.3.7", - "watchpack": "^2.4.0", + "terser-webpack-plugin": "^5.3.10", + "watchpack": "^2.4.1", "webpack-sources": "^3.2.3" }, "bin": { @@ -24666,9 +24655,9 @@ "dev": true }, "node_modules/webpack/node_modules/acorn": { - "version": "8.11.3", - "resolved": "https://registry.npmjs.org/acorn/-/acorn-8.11.3.tgz", - "integrity": "sha512-Y9rRfJG5jcKOE0CLisYbojUjIrIEE7AGMzA/Sm4BslANhbS+cDMpgBdcPT91oJ7OuJ9hYJBx59RjbhxVnrF8Xg==", + "version": "8.12.1", + "resolved": "https://registry.npmjs.org/acorn/-/acorn-8.12.1.tgz", + "integrity": "sha512-tcpGyI9zbizT9JbV6oYE477V6mTlXvvi0T0G3SNIYE2apm/G5huBa1+K89VGeovbg+jycCrfhl3ADxErOuO6Jg==", "dev": true, "bin": { "acorn": "bin/acorn" @@ -24677,10 +24666,10 @@ "node": ">=0.4.0" } }, - "node_modules/webpack/node_modules/acorn-import-assertions": { - "version": "1.9.0", - "resolved": "https://registry.npmjs.org/acorn-import-assertions/-/acorn-import-assertions-1.9.0.tgz", - "integrity": "sha512-cmMwop9x+8KFhxvKrKfPYmN6/pKTYYHBqLa0DfvVZcKMJWNyWLnaqND7dx/qn66R7ewM1UX5XMaDVP5wlVTaVA==", + "node_modules/webpack/node_modules/acorn-import-attributes": { + "version": "1.9.5", + "resolved": "https://registry.npmjs.org/acorn-import-attributes/-/acorn-import-attributes-1.9.5.tgz", + "integrity": "sha512-n02Vykv5uA3eHGM/Z2dQrcD56kL8TyDb2p1+0P83PClMnC/nc+anbQRhIOWnSq4Ke/KvDPrY3C9hDtC/A3eHnQ==", "dev": true, "peerDependencies": { "acorn": "^8" diff --git a/frontend/package.json b/frontend/package.json index 994485252..a538e7cf1 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -92,6 +92,7 @@ "react-grid-layout": "^1.3.4", "react-hook-form": "^7.43.0", "react-i18next": "^12.2.2", + "react-icons": "^5.3.0", "react-mailchimp-subscribe": "^2.1.3", "react-markdown": "^8.0.3", "react-redux": "^8.0.2", diff --git a/frontend/src/components/v2/FormControl/FormControl.tsx b/frontend/src/components/v2/FormControl/FormControl.tsx index 9711e3e68..8594cfd73 100644 --- a/frontend/src/components/v2/FormControl/FormControl.tsx +++ b/frontend/src/components/v2/FormControl/FormControl.tsx @@ -9,36 +9,42 @@ import { Tooltip } from "../Tooltip"; export type FormLabelProps = { id?: string; isRequired?: boolean; - isOptional?:boolean; + isOptional?: boolean; label?: ReactNode; icon?: ReactNode; className?: string; tooltipText?: string; + tooltipClassName?: string; }; -export const FormLabel = ({ id, label, isRequired, icon, className,isOptional, tooltipText }: FormLabelProps) => ( +export const FormLabel = ({ + id, + label, + isRequired, + icon, + className, + isOptional, + tooltipText, + tooltipClassName +}: FormLabelProps) => ( {label} {isRequired && *} - {isOptional && - Optional} + {isOptional && - Optional} {icon && !tooltipText && ( {icon} )} {tooltipText && ( - - + + )} diff --git a/frontend/src/hooks/api/dynamicSecret/types.ts b/frontend/src/hooks/api/dynamicSecret/types.ts index ff881e506..7076ea929 100644 --- a/frontend/src/hooks/api/dynamicSecret/types.ts +++ b/frontend/src/hooks/api/dynamicSecret/types.ts @@ -20,7 +20,8 @@ export enum DynamicSecretProviders { Cassandra = "cassandra", AwsIam = "aws-iam", Redis = "redis", - AwsElastiCache = "aws-elasticache" + AwsElastiCache = "aws-elasticache", + MongoAtlas = "mongo-db-atlas" } export enum SqlProviders { @@ -96,6 +97,23 @@ export type TDynamicSecretProvider = creationStatement: string; revocationStatement: string; ca?: string | undefined; + } + } +|{ + type: DynamicSecretProviders.MongoAtlas; + inputs: { + adminPublicKey: string; + adminPrivateKey: string; + groupId: string; + roles: { + databaseName: string; + roleName: string; + collectionName?: string; + }[]; + scopes?: { + name: string; + type: string; + }[]; }; }; diff --git a/frontend/src/views/SecretMainPage/components/ActionBar/CreateDynamicSecretForm/CreateDynamicSecretForm.tsx b/frontend/src/views/SecretMainPage/components/ActionBar/CreateDynamicSecretForm/CreateDynamicSecretForm.tsx index b28a07116..52b3170a2 100644 --- a/frontend/src/views/SecretMainPage/components/ActionBar/CreateDynamicSecretForm/CreateDynamicSecretForm.tsx +++ b/frontend/src/views/SecretMainPage/components/ActionBar/CreateDynamicSecretForm/CreateDynamicSecretForm.tsx @@ -1,4 +1,5 @@ import { useState } from "react"; +import { SiApachecassandra,SiMongodb } from "react-icons/si"; import { faAws } from "@fortawesome/free-brands-svg-icons"; import { faDatabase } from "@fortawesome/free-solid-svg-icons"; import { FontAwesomeIcon } from "@fortawesome/react-fontawesome"; @@ -10,6 +11,7 @@ import { DynamicSecretProviders } from "@app/hooks/api/dynamicSecret/types"; import { AwsElastiCacheInputForm } from "./AwsElastiCacheInputForm"; import { AwsIamInputForm } from "./AwsIamInputForm"; import { CassandraInputForm } from "./CassandraInputForm"; +import { MongoAtlasInputForm } from "./MongoAtlasInputForm"; import { RedisInputForm } from "./RedisInputForm"; import { SqlDatabaseInputForm } from "./SqlDatabaseInputForm"; @@ -28,29 +30,34 @@ enum WizardSteps { const DYNAMIC_SECRET_LIST = [ { - icon: faDatabase, + icon: , provider: DynamicSecretProviders.SqlDatabase, title: "SQL\nDatabase" }, { - icon: faDatabase, + icon: , provider: DynamicSecretProviders.Cassandra, title: "Cassandra" }, { - icon: faDatabase, + icon: , provider: DynamicSecretProviders.Redis, title: "Redis" }, { - icon: faAws, + icon: , provider: DynamicSecretProviders.AwsElastiCache, title: "AWS ElastiCache" }, { - icon: faAws, + icon: , provider: DynamicSecretProviders.AwsIam, title: "AWS IAM" + }, + { + icon: , + provider: DynamicSecretProviders.MongoAtlas, + title: "Mongo Atlas" } ]; @@ -105,7 +112,7 @@ export const CreateDynamicSecretForm = ({ } }} > - + {icon}
{title}
))} @@ -202,6 +209,24 @@ export const CreateDynamicSecretForm = ({ /> )} + {wizardStep === WizardSteps.ProviderInputs && + selectedProvider === DynamicSecretProviders.MongoAtlas && ( + + + + )} diff --git a/frontend/src/views/SecretMainPage/components/ActionBar/CreateDynamicSecretForm/MongoAtlasInputForm.tsx b/frontend/src/views/SecretMainPage/components/ActionBar/CreateDynamicSecretForm/MongoAtlasInputForm.tsx new file mode 100644 index 000000000..14f529fe0 --- /dev/null +++ b/frontend/src/views/SecretMainPage/components/ActionBar/CreateDynamicSecretForm/MongoAtlasInputForm.tsx @@ -0,0 +1,455 @@ +import { Controller, useFieldArray, useForm } from "react-hook-form"; +import { faPlus, faTrash } from "@fortawesome/free-solid-svg-icons"; +import { FontAwesomeIcon } from "@fortawesome/react-fontawesome"; +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, + FormLabel, + IconButton, + Input, + Select, + SelectItem +} 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({ + adminPublicKey: z.string().trim().min(1), + adminPrivateKey: z.string().trim().min(1), + groupId: z.string().trim().min(1), + roles: z + .object({ + collectionName: z.string().optional(), + databaseName: z.string().min(1), + roleName: z.string().min(1) + }) + .array() + .min(1), + scopes: z + .object({ + name: z.string().min(1), + type: z.string().min(1) + }) + .array() + }), + 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; +}; + +const ATLAS_SCOPE_TYPES = [ + { + label: "Cluster", + value: "CLUSTER" + }, + { + label: "Data Lake", + value: "DATA_LAKE" + }, + { + label: "Stream", + value: "STREAM" + } +]; + +export const MongoAtlasInputForm = ({ + onCompleted, + onCancel, + environment, + secretPath, + projectSlug +}: Props) => { + const { + control, + getValues, + setValue, + formState: { isSubmitting }, + handleSubmit + } = useForm({ + resolver: zodResolver(formSchema), + defaultValues: { + provider: { + roles: [{ databaseName: "", roleName: "" }] + } + } + }); + + const roleFields = useFieldArray({ + control, + name: "provider.roles" + }); + + const scopeFields = useFieldArray({ + control, + name: "provider.scopes" + }); + + const createDynamicSecret = useCreateDynamicSecret(); + + 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.MongoAtlas, 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 +
+
+
+ ( + + + + )} + /> + ( + + + + )} + /> +
+ ( + + + + )} + /> + +
+ {roleFields.fields.map(({ id: roleFieldId }, i) => ( +
+
+ {i === 0 && Database Name} + ( + + + + )} + /> +
+
+ {i === 0 && ( + + )} + ( + + + + )} + /> +
+
+ {i === 0 && ( + + )} + ( + + + + )} + /> +
+ { + const roles = getValues("provider.roles"); + if (roles && roles?.length > 1) { + roleFields.remove(i); + } else { + setValue("provider.roles", [{ databaseName: "", roleName: "" }]); + } + }} + > + + +
+ ))} +
+ +
+
+ + + Advanced + + +
+ {scopeFields.fields.map(({ id: scopeFieldId }, i) => ( +
+
+ {i === 0 && ( + + )} + ( + + + + )} + /> +
+
+ {i === 0 && Type} + ( + + + + )} + /> +
+ { + scopeFields.remove(i); + }} + > + + +
+ ))} +
+ +
+
+
+
+
+
+
+
+
+ + +
+
+
+ ); +}; diff --git a/frontend/src/views/SecretMainPage/components/DynamicSecretListView/CreateDynamicSecretLease.tsx b/frontend/src/views/SecretMainPage/components/DynamicSecretListView/CreateDynamicSecretLease.tsx index 3cb709d59..800cf7747 100644 --- a/frontend/src/views/SecretMainPage/components/DynamicSecretListView/CreateDynamicSecretLease.tsx +++ b/frontend/src/views/SecretMainPage/components/DynamicSecretListView/CreateDynamicSecretLease.tsx @@ -57,7 +57,8 @@ const OutputDisplay = ({ const renderOutputForm = (provider: DynamicSecretProviders, data: unknown) => { if ( provider === DynamicSecretProviders.SqlDatabase || - provider === DynamicSecretProviders.Cassandra + provider === DynamicSecretProviders.Cassandra || + provider === DynamicSecretProviders.MongoAtlas ) { const { DB_PASSWORD, DB_USERNAME } = data as { DB_USERNAME: string; DB_PASSWORD: string }; return ( diff --git a/frontend/src/views/SecretMainPage/components/DynamicSecretListView/EditDynamicSecretForm/EditDynamicSecretForm.tsx b/frontend/src/views/SecretMainPage/components/DynamicSecretListView/EditDynamicSecretForm/EditDynamicSecretForm.tsx index 3309126ee..344d8183d 100644 --- a/frontend/src/views/SecretMainPage/components/DynamicSecretListView/EditDynamicSecretForm/EditDynamicSecretForm.tsx +++ b/frontend/src/views/SecretMainPage/components/DynamicSecretListView/EditDynamicSecretForm/EditDynamicSecretForm.tsx @@ -7,6 +7,7 @@ import { DynamicSecretProviders } from "@app/hooks/api/dynamicSecret/types"; import { EditDynamicSecretAwsElastiCacheProviderForm } from "./EditDynamicSecretAwsElastiCacheProviderForm"; import { EditDynamicSecretAwsIamForm } from "./EditDynamicSecretAwsIamForm"; import { EditDynamicSecretCassandraForm } from "./EditDynamicSecretCassandraForm"; +import { EditDynamicSecretMongoAtlasForm } from "./EditDynamicSecretMongoAtlasForm"; import { EditDynamicSecretRedisProviderForm } from "./EditDynamicSecretRedisProviderForm"; import { EditDynamicSecretSqlProviderForm } from "./EditDynamicSecretSqlProviderForm"; @@ -128,6 +129,23 @@ export const EditDynamicSecretForm = ({ /> )} + {dynamicSecretDetails?.type === DynamicSecretProviders.MongoAtlas && ( + + + + )} ); }; diff --git a/frontend/src/views/SecretMainPage/components/DynamicSecretListView/EditDynamicSecretForm/EditDynamicSecretMongoAtlasForm.tsx b/frontend/src/views/SecretMainPage/components/DynamicSecretListView/EditDynamicSecretForm/EditDynamicSecretMongoAtlasForm.tsx new file mode 100644 index 000000000..d5daf34a9 --- /dev/null +++ b/frontend/src/views/SecretMainPage/components/DynamicSecretListView/EditDynamicSecretForm/EditDynamicSecretMongoAtlasForm.tsx @@ -0,0 +1,466 @@ +import { Controller, useFieldArray, useForm } from "react-hook-form"; +import { faPlus, faTrash } from "@fortawesome/free-solid-svg-icons"; +import { FontAwesomeIcon } from "@fortawesome/react-fontawesome"; +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, + FormLabel, + IconButton, + Input, + Select, + SelectItem +} from "@app/components/v2"; +import { useUpdateDynamicSecret } from "@app/hooks/api"; +import { TDynamicSecret } from "@app/hooks/api/dynamicSecret/types"; + +const formSchema = z.object({ + inputs: z + .object({ + adminPublicKey: z.string().trim().min(1), + adminPrivateKey: z.string().trim().min(1), + groupId: z.string().trim().min(1), + roles: z + .object({ + collectionName: z.string().optional(), + databaseName: z.string().min(1), + roleName: z.string().min(1) + }) + .array() + .min(1), + scopes: z + .object({ + name: z.string().min(1), + type: z.string().min(1) + }) + .array() + }) + .partial(), + 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" }); + }) + .nullable(), + newName: z + .string() + .refine((val) => val.toLowerCase() === val, "Must be lowercase") + .optional() +}); +type TForm = z.infer; + +type Props = { + onClose: () => void; + dynamicSecret: TDynamicSecret & { inputs: unknown }; + secretPath: string; + environment: string; + projectSlug: string; +}; + +const ATLAS_SCOPE_TYPES = [ + { + label: "Cluster", + value: "CLUSTER" + }, + { + label: "Data Lake", + value: "DATA_LAKE" + }, + { + label: "Stream", + value: "STREAM" + } +]; + +export const EditDynamicSecretMongoAtlasForm = ({ + onClose, + dynamicSecret, + environment, + secretPath, + projectSlug +}: Props) => { + const { + control, + getValues, + setValue, + formState: { isSubmitting }, + handleSubmit + } = useForm({ + resolver: zodResolver(formSchema), + values: { + defaultTTL: dynamicSecret.defaultTTL, + maxTTL: dynamicSecret.maxTTL, + newName: dynamicSecret.name, + inputs: { + ...(dynamicSecret.inputs as TForm["inputs"]) + } + } + }); + + const roleFields = useFieldArray({ + control, + name: "inputs.roles" + }); + + const scopeFields = useFieldArray({ + control, + name: "inputs.scopes" + }); + + const updateDynamicSecret = useUpdateDynamicSecret(); + + const handleUpdateDynamicSecret = async ({ inputs, maxTTL, defaultTTL, newName }: TForm) => { + // wait till previous request is finished + if (updateDynamicSecret.isLoading) return; + try { + await updateDynamicSecret.mutateAsync({ + name: dynamicSecret.name, + path: secretPath, + projectSlug, + environmentSlug: environment, + data: { + maxTTL: maxTTL || undefined, + defaultTTL, + inputs, + newName: newName === dynamicSecret.name ? undefined : newName + } + }); + onClose(); + createNotification({ + type: "success", + text: "Successfully updated dynamic secret" + }); + } catch (err) { + createNotification({ + type: "error", + text: "Failed to update dynamic secret" + }); + } + }; + + return ( +
+
+
+
+ ( + + + + )} + /> +
+
+ ( + } + isError={Boolean(error?.message)} + errorText={error?.message} + > + + + )} + /> +
+
+ ( + } + isError={Boolean(error?.message)} + errorText={error?.message} + > + + + )} + /> +
+
+
+
+ Configuration +
+
+
+ ( + + + + )} + /> + ( + + + + )} + /> +
+ ( + + + + )} + /> + +
+ {roleFields.fields.map(({ id: roleFieldId }, i) => ( +
+
+ {i === 0 && Database Name} + ( + + + + )} + /> +
+
+ {i === 0 && ( + + )} + ( + + + + )} + /> +
+
+ {i === 0 && ( + + )} + ( + + + + )} + /> +
+ { + const roles = getValues("inputs.roles"); + if (roles && roles?.length > 1) { + roleFields.remove(i); + } else { + setValue("inputs.roles", [{ databaseName: "", roleName: "" }]); + } + }} + > + + +
+ ))} +
+ +
+
+ + + Advanced + + +
+ {scopeFields.fields.map(({ id: scopeFieldId }, i) => ( +
+
+ {i === 0 && ( + + )} + ( + + + + )} + /> +
+
+ {i === 0 && Type} + ( + + + + )} + /> +
+ { + scopeFields.remove(i); + }} + > + + +
+ ))} +
+ +
+
+
+
+
+
+
+
+ + +
+
+
+ ); +};