diff --git a/backend/src/ee/services/dynamic-secret/providers/couchbase.ts b/backend/src/ee/services/dynamic-secret/providers/couchbase.ts new file mode 100644 index 000000000..c59f1c2b3 --- /dev/null +++ b/backend/src/ee/services/dynamic-secret/providers/couchbase.ts @@ -0,0 +1,289 @@ +import crypto from "node:crypto"; + +import axios from "axios"; +import RE2 from "re2"; + +import { BadRequestError } from "@app/lib/errors"; +import { sanitizeString } from "@app/lib/fn"; +import { alphaNumericNanoId } from "@app/lib/nanoid"; +import { blockLocalAndPrivateIpAddresses } from "@app/lib/validator/validate-url"; + +import { DynamicSecretCouchbaseSchema, PasswordRequirements, TDynamicProviderFns } from "./models"; +import { compileUsernameTemplate } from "./templateUtils"; + +type TCreateCouchbaseUser = { + name: string; + password: string; + access: { + privileges: string[]; + resources: { + buckets: { + name: string; + scopes?: { + name: string; + collections?: string[]; + }[]; + }[]; + }; + }[]; +}; + +type CouchbaseUserResponse = { + id: string; + uuid?: string; +}; + +const sanitizeCouchbaseUsername = (username: string): string => { + // Couchbase username restrictions: + // - Cannot contain: ) ( > < , ; : " \ / ] [ ? = } { + // - Cannot begin with @ character + + const forbiddenCharsPattern = new RE2('[\\)\\(><,;:"\\\\\\[\\]\\?=\\}\\{]', "g"); + let sanitized = forbiddenCharsPattern.replace(username, "-"); + + const leadingAtPattern = new RE2("^@+"); + sanitized = leadingAtPattern.replace(sanitized, ""); + + if (!sanitized || sanitized.length === 0) { + return alphaNumericNanoId(12); + } + + return sanitized; +}; + +/** + * Normalizes bucket configuration to handle wildcard (*) access consistently. + * + * Key behaviors: + * - If "*" appears anywhere (string or array), grants access to ALL buckets, scopes, and collections + * + * @param buckets - Either a string or array of bucket configurations + * @returns Normalized bucket resources for Couchbase API + */ +const normalizeBucketConfiguration = ( + buckets: + | string + | Array<{ + name: string; + scopes?: Array<{ + name: string; + collections?: string[]; + }>; + }> +) => { + if (typeof buckets === "string") { + // Simple string format - either "*" or comma-separated bucket names + const bucketNames = buckets + .split(",") + .map((bucket) => bucket.trim()) + .filter((bucket) => bucket.length > 0); + + // If "*" is present anywhere, grant access to all buckets, scopes, and collections + if (bucketNames.includes("*") || buckets === "*") { + return [{ name: "*" }]; + } + return bucketNames.map((bucketName) => ({ name: bucketName })); + } + + // Array of bucket objects with scopes and collections + // Check if any bucket is "*" - if so, grant access to all buckets, scopes, and collections + const hasWildcardBucket = buckets.some((bucket) => bucket.name === "*"); + + if (hasWildcardBucket) { + return [{ name: "*" }]; + } + + return buckets.map((bucket) => ({ + name: bucket.name, + scopes: bucket.scopes?.map((scope) => ({ + name: scope.name, + collections: scope.collections || [] + })) + })); +}; + +const generateUsername = (usernameTemplate?: string | null, identity?: { name: string }) => { + const randomUsername = alphaNumericNanoId(12); + if (!usernameTemplate) return sanitizeCouchbaseUsername(randomUsername); + + const compiledUsername = compileUsernameTemplate({ + usernameTemplate, + randomUsername, + identity + }); + + return sanitizeCouchbaseUsername(compiledUsername); +}; + +const generatePassword = (requirements?: PasswordRequirements): string => { + const { + length = 12, + required = { lowercase: 1, uppercase: 1, digits: 1, symbols: 1 }, + allowedSymbols = "!@#$%^()_+-=[]{}:,?/~`" + } = requirements || {}; + + const lowercase = "abcdefghijklmnopqrstuvwxyz"; + const uppercase = "ABCDEFGHIJKLMNOPQRSTUVWXYZ"; + const digits = "0123456789"; + const symbols = allowedSymbols; + + let password = ""; + let remaining = length; + + // Add required characters + for (let i = 0; i < required.lowercase; i += 1) { + password += lowercase[crypto.randomInt(lowercase.length)]; + remaining -= 1; + } + for (let i = 0; i < required.uppercase; i += 1) { + password += uppercase[crypto.randomInt(uppercase.length)]; + remaining -= 1; + } + for (let i = 0; i < required.digits; i += 1) { + password += digits[crypto.randomInt(digits.length)]; + remaining -= 1; + } + for (let i = 0; i < required.symbols; i += 1) { + password += symbols[crypto.randomInt(symbols.length)]; + remaining -= 1; + } + + // Fill remaining with random characters from all sets + const allChars = lowercase + uppercase + digits + symbols; + for (let i = 0; i < remaining; i += 1) { + password += allChars[crypto.randomInt(allChars.length)]; + } + + // Shuffle the password + return password + .split("") + .sort(() => crypto.randomInt(3) - 1) + .join(""); +}; + +const couchbaseApiRequest = async ( + method: string, + url: string, + apiKey: string, + data?: unknown +): Promise => { + await blockLocalAndPrivateIpAddresses(url); + + try { + const response = await axios({ + method: method.toLowerCase() as "get" | "post" | "put" | "delete", + url, + headers: { + Authorization: `Bearer ${apiKey}`, + "Content-Type": "application/json" + }, + data: data || undefined, + timeout: 30000 + }); + + return response.data as CouchbaseUserResponse; + } catch (err) { + const sanitizedErrorMessage = sanitizeString({ + unsanitizedString: (err as Error)?.message, + tokens: [apiKey] + }); + throw new BadRequestError({ + message: `Failed to connect with provider: ${sanitizedErrorMessage}` + }); + } +}; + +export const CouchbaseProvider = (): TDynamicProviderFns => { + const validateProviderInputs = async (inputs: object) => { + const providerInputs = DynamicSecretCouchbaseSchema.parse(inputs); + + await blockLocalAndPrivateIpAddresses(providerInputs.url); + + return providerInputs; + }; + + const validateConnection = async (inputs: unknown): Promise => { + try { + const providerInputs = await validateProviderInputs(inputs as object); + + // Test connection by trying to get organization info + const url = `${providerInputs.url}/v4/organizations/${providerInputs.orgId}`; + await couchbaseApiRequest("GET", url, providerInputs.auth.apiKey); + + return true; + } catch (error) { + throw new BadRequestError({ + message: `Failed to connect to Couchbase: ${error instanceof Error ? error.message : "Unknown error"}` + }); + } + }; + + const create = async ({ + inputs, + usernameTemplate, + identity + }: { + inputs: unknown; + usernameTemplate?: string | null; + identity?: { name: string }; + }) => { + const providerInputs = await validateProviderInputs(inputs as object); + + const username = generateUsername(usernameTemplate, identity); + + const password = generatePassword(providerInputs.passwordRequirements); + + const createUserUrl = `${providerInputs.url}/v4/organizations/${providerInputs.orgId}/projects/${providerInputs.projectId}/clusters/${providerInputs.clusterId}/users`; + + const bucketResources = normalizeBucketConfiguration(providerInputs.buckets); + + const userData: TCreateCouchbaseUser = { + name: username, + password, + access: [ + { + privileges: providerInputs.roles, + resources: { + buckets: bucketResources + } + } + ] + }; + + const response = await couchbaseApiRequest("POST", createUserUrl, providerInputs.auth.apiKey, userData); + + const userUuid = response?.id || response?.uuid || username; + + return { + entityId: userUuid, + data: { + username, + password + } + }; + }; + + const revoke = async (inputs: unknown, entityId: string) => { + const providerInputs = await validateProviderInputs(inputs as object); + + const deleteUserUrl = `${providerInputs.url}/v4/organizations/${providerInputs.orgId}/projects/${providerInputs.projectId}/clusters/${providerInputs.clusterId}/users/${encodeURIComponent(entityId)}`; + + await couchbaseApiRequest("DELETE", deleteUserUrl, providerInputs.auth.apiKey); + + return { entityId }; + }; + + const renew = async (_inputs: unknown, entityId: string) => { + // Couchbase Cloud API doesn't support renewing user credentials + // The user remains valid until explicitly deleted + return { entityId }; + }; + + 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 7fd65f98d..184b9fc89 100644 --- a/backend/src/ee/services/dynamic-secret/providers/index.ts +++ b/backend/src/ee/services/dynamic-secret/providers/index.ts @@ -5,6 +5,7 @@ import { AwsElastiCacheDatabaseProvider } from "./aws-elasticache"; import { AwsIamProvider } from "./aws-iam"; import { AzureEntraIDProvider } from "./azure-entra-id"; import { CassandraProvider } from "./cassandra"; +import { CouchbaseProvider } from "./couchbase"; import { ElasticSearchProvider } from "./elastic-search"; import { GcpIamProvider } from "./gcp-iam"; import { GithubProvider } from "./github"; @@ -46,5 +47,6 @@ export const buildDynamicSecretProviders = ({ [DynamicSecretProviders.Kubernetes]: KubernetesProvider({ gatewayService }), [DynamicSecretProviders.Vertica]: VerticaProvider({ gatewayService }), [DynamicSecretProviders.GcpIam]: GcpIamProvider(), - [DynamicSecretProviders.Github]: GithubProvider() + [DynamicSecretProviders.Github]: GithubProvider(), + [DynamicSecretProviders.Couchbase]: CouchbaseProvider() }); diff --git a/backend/src/ee/services/dynamic-secret/providers/models.ts b/backend/src/ee/services/dynamic-secret/providers/models.ts index 528ea414a..ae1bcfc25 100644 --- a/backend/src/ee/services/dynamic-secret/providers/models.ts +++ b/backend/src/ee/services/dynamic-secret/providers/models.ts @@ -505,6 +505,91 @@ export const DynamicSecretGithubSchema = z.object({ .describe("The private key generated for your GitHub App.") }); +export const DynamicSecretCouchbaseSchema = z.object({ + url: z.string().url().trim().min(1).describe("Couchbase Cloud API URL"), + orgId: z.string().trim().min(1).describe("Organization ID"), + projectId: z.string().trim().min(1).describe("Project ID"), + clusterId: z.string().trim().min(1).describe("Cluster ID"), + roles: z.array(z.string().trim().min(1)).min(1).describe("Roles to assign to the user"), + buckets: z + .union([ + z + .string() + .trim() + .min(1) + .default("*") + .refine((val) => { + if (val.includes(",")) { + const buckets = val + .split(",") + .map((b) => b.trim()) + .filter((b) => b.length > 0); + if (buckets.includes("*") && buckets.length > 1) { + return false; + } + } + return true; + }, "Cannot combine '*' with other bucket names"), + z + .array( + z.object({ + name: z.string().trim().min(1).describe("Bucket name"), + scopes: z + .array( + z.object({ + name: z.string().trim().min(1).describe("Scope name"), + collections: z.array(z.string().trim().min(1)).optional().describe("Collection names") + }) + ) + .optional() + .describe("Scopes within the bucket") + }) + ) + .refine((buckets) => { + const hasWildcard = buckets.some((bucket) => bucket.name === "*"); + if (hasWildcard && buckets.length > 1) { + return false; + } + return true; + }, "Cannot combine '*' bucket with other buckets") + ]) + .default("*") + .describe( + "Bucket configuration: '*' for all buckets, scopes, and collections or array of bucket objects with specific scopes and collections" + ), + passwordRequirements: z + .object({ + length: z.number().min(8, "Password must be at least 8 characters").max(128), + required: z + .object({ + lowercase: z.number().min(1, "At least 1 lowercase character required"), + uppercase: z.number().min(1, "At least 1 uppercase character required"), + digits: z.number().min(1, "At least 1 digit required"), + symbols: z.number().min(1, "At least 1 special character required") + }) + .refine((data) => { + const total = Object.values(data).reduce((sum, count) => sum + count, 0); + return total <= 128; + }, "Sum of required characters cannot exceed 128"), + allowedSymbols: z + .string() + .refine((symbols) => { + const forbiddenChars = ["<", ">", ";", ".", "*", "&", "|", "£"]; + return !forbiddenChars.some((char) => symbols?.includes(char)); + }, "Cannot contain: < > ; . * & | £") + .optional() + }) + .refine((data) => { + const total = Object.values(data.required).reduce((sum, count) => sum + count, 0); + return total <= data.length; + }, "Sum of required characters cannot exceed the total length") + .optional() + .describe("Password generation requirements for Couchbase"), + auth: z.object({ + apiKey: z.string().trim().min(1).describe("Couchbase Cloud API Key") + }) +}); + export enum DynamicSecretProviders { SqlDatabase = "sql-database", Cassandra = "cassandra", @@ -524,7 +609,8 @@ export enum DynamicSecretProviders { Kubernetes = "kubernetes", Vertica = "vertica", GcpIam = "gcp-iam", - Github = "github" + Github = "github", + Couchbase = "couchbase" } export const DynamicSecretProviderSchema = z.discriminatedUnion("type", [ @@ -546,7 +632,8 @@ export const DynamicSecretProviderSchema = z.discriminatedUnion("type", [ z.object({ type: z.literal(DynamicSecretProviders.Kubernetes), inputs: DynamicSecretKubernetesSchema }), z.object({ type: z.literal(DynamicSecretProviders.Vertica), inputs: DynamicSecretVerticaSchema }), z.object({ type: z.literal(DynamicSecretProviders.GcpIam), inputs: DynamicSecretGcpIamSchema }), - z.object({ type: z.literal(DynamicSecretProviders.Github), inputs: DynamicSecretGithubSchema }) + z.object({ type: z.literal(DynamicSecretProviders.Github), inputs: DynamicSecretGithubSchema }), + z.object({ type: z.literal(DynamicSecretProviders.Couchbase), inputs: DynamicSecretCouchbaseSchema }) ]); export type TDynamicProviderFns = { diff --git a/docs/docs.json b/docs/docs.json index 922e80d32..894004b27 100644 --- a/docs/docs.json +++ b/docs/docs.json @@ -439,6 +439,7 @@ "documentation/platform/dynamic-secrets/aws-iam", "documentation/platform/dynamic-secrets/azure-entra-id", "documentation/platform/dynamic-secrets/cassandra", + "documentation/platform/dynamic-secrets/couchbase", "documentation/platform/dynamic-secrets/elastic-search", "documentation/platform/dynamic-secrets/gcp-iam", "documentation/platform/dynamic-secrets/github", diff --git a/docs/documentation/platform/dynamic-secrets/couchbase.mdx b/docs/documentation/platform/dynamic-secrets/couchbase.mdx new file mode 100644 index 000000000..6a803c1f8 --- /dev/null +++ b/docs/documentation/platform/dynamic-secrets/couchbase.mdx @@ -0,0 +1,259 @@ +--- +title: "Couchbase" +description: "Learn how to dynamically generate Couchbase Database user credentials." +--- + +The Infisical Couchbase dynamic secret allows you to generate Couchbase Cloud Database user credentials on demand based on configured roles and bucket access permissions. + +## Prerequisite + +Create an API Key in your Couchbase Cloud following the [official documentation](https://docs.couchbase.com/cloud/get-started/create-account.html#create-api-key). + +The API Key must have permission to manage database users in your Couchbase Cloud organization and project. + +## Set up Dynamic Secrets with Couchbase + + + + 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/couchbase/dynamic-secret-couchbase-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 after a secret is generated) + + + + Maximum time-to-live for a generated secret + + + + The Couchbase Cloud API URL + + + + Your Couchbase Cloud organization ID + + + + Your Couchbase Cloud project ID + + + + Your Couchbase Cloud cluster ID where users will be created + + + + Database credential roles to assign to the generated user. Available options: + - **read**: Read access to bucket data (alias for data_reader) + - **write**: Read and write access to bucket data (alias for data_writer) + + + + Specify bucket access configuration: + - Use `*` for access to all buckets + - Use comma-separated bucket names (e.g., `bucket1,bucket2,bucket3`) for specific buckets + - Use Advanced Bucket Configuration for granular scope and collection access + + + + Your Couchbase Cloud API Key for authentication + + + ![Dynamic Secret Setup Modal](../../../images/platform/dynamic-secrets/couchbase/dynamic-secret-modal-couchbase.png) + + + + + ![Advanced Configuration Modal](../../../images/platform/dynamic-secrets/couchbase/advanced-option-couchbase.png) + + + Enable advanced bucket configuration to specify granular access to buckets, scopes, and collections + + + When Advanced Bucket Configuration is enabled, you can configure: + + + List of buckets with optional scope and collection specifications: + - **Bucket Name**: Name of the bucket (e.g., travel-sample) + - **Scopes**: Optional array of scopes within the bucket + - **Scope Name**: Name of the scope (e.g., inventory, _default) + - **Collections**: Optional array of collection names within the scope + + + + Specifies a template for generating usernames. This field allows customization of how usernames are automatically created. + + Allowed template variables are: + - `{{randomUsername}}`: Random username string + - `{{unixTimestamp}}`: Current Unix timestamp + - `{{identity.name}}`: Name of the identity that is generating the secret + - `{{random N}}`: Random string of N characters + + Allowed template functions are: + - `truncate`: Truncates a string to a specified length + - `replace`: Replaces a substring with another value + + Examples: + ``` + {{randomUsername}} // infisical-3POnzeFyK9gW2nioK0q2gMjr6CZqsRiX + {{unixTimestamp}} // 17490641580 + {{identity.name}} // testuser + {{random 5}} // x9k2m + {{truncate identity.name 4}} // test + {{replace identity.name 'user' 'replace'}} // testreplace + ``` + + + + Optional password generation requirements for Couchbase users: + + + Length of the generated password + + + + Minimum required character counts: + - **Lowercase Count**: Minimum lowercase letters (default: 1) + - **Uppercase Count**: Minimum uppercase letters (default: 1) + - **Digit Count**: Minimum digits (default: 1) + - **Symbol Count**: Minimum special characters (default: 1) + + + + Special characters allowed in passwords. Cannot contain: `< > ; . * & | £` + + + + Couchbase password requirements: minimum 8 characters, maximum 128 characters, at least 1 uppercase, 1 lowercase, 1 digit, and 1 special character. Cannot contain: `< > ; . * & | £` + + + + + + + After submitting the form, you will see a dynamic secret created in the dashboard. + + + If this step fails, you may need to verify your Couchbase Cloud API key permissions and organization/project/cluster IDs. + + + ![Dynamic Secret](../../../images/platform/dynamic-secrets/couchbase/dynamic-secret-couchbase.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 falls 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) + + + + +## Advanced Bucket Configuration Examples + +The advanced bucket configuration allows you to specify granular access control: + +### Example 1: Specific Bucket Access +```json +[ + { + "name": "travel-sample" + } +] +``` + +### Example 2: Bucket with Specific Scopes +```json +[ + { + "name": "travel-sample", + "scopes": [ + { + "name": "inventory" + }, + { + "name": "_default" + } + ] + } +] +``` + +### Example 3: Bucket with Scopes and Collections +```json +[ + { + "name": "travel-sample", + "scopes": [ + { + "name": "inventory", + "collections": ["airport", "airline"] + }, + { + "name": "_default", + "collections": ["users"] + } + ] + } +] +``` + +## 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 to see the expiration time of the lease or delete a lease before its 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** button 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 + + +## Couchbase Roles and Permissions + +The Couchbase dynamic secret integration supports the following database credential roles: + +- **read**: Provides read-only access to bucket data +- **write**: Provides read and write access to bucket data + + +These roles are specifically for database credentials and are different from Couchbase's administrative roles. They provide data-level access to buckets, scopes, and collections based on your configuration. + + +## Troubleshooting + +### Common Issues + +1. **Invalid API Key**: Ensure your Couchbase Cloud API key has the necessary permissions to manage database users +2. **Invalid Organization/Project/Cluster IDs**: Verify that the provided IDs exist and are accessible with your API key +3. **Role Permission Errors**: Make sure you're using only the supported database credential roles (read, write) +4. **Bucket Access Issues**: Ensure the specified buckets exist in your cluster and are accessible \ No newline at end of file diff --git a/docs/images/platform/dynamic-secrets/couchbase/advanced-option-couchbase.png b/docs/images/platform/dynamic-secrets/couchbase/advanced-option-couchbase.png new file mode 100644 index 000000000..9167f1a48 Binary files /dev/null and b/docs/images/platform/dynamic-secrets/couchbase/advanced-option-couchbase.png differ diff --git a/docs/images/platform/dynamic-secrets/couchbase/dynamic-secret-couchbase-modal.png b/docs/images/platform/dynamic-secrets/couchbase/dynamic-secret-couchbase-modal.png new file mode 100644 index 000000000..b2ad14bd7 Binary files /dev/null and b/docs/images/platform/dynamic-secrets/couchbase/dynamic-secret-couchbase-modal.png differ diff --git a/docs/images/platform/dynamic-secrets/couchbase/dynamic-secret-couchbase.png b/docs/images/platform/dynamic-secrets/couchbase/dynamic-secret-couchbase.png new file mode 100644 index 000000000..6a1b5ce91 Binary files /dev/null and b/docs/images/platform/dynamic-secrets/couchbase/dynamic-secret-couchbase.png differ diff --git a/docs/images/platform/dynamic-secrets/couchbase/dynamic-secret-modal-couchbase.png b/docs/images/platform/dynamic-secrets/couchbase/dynamic-secret-modal-couchbase.png new file mode 100644 index 000000000..c1fad1a22 Binary files /dev/null and b/docs/images/platform/dynamic-secrets/couchbase/dynamic-secret-modal-couchbase.png differ diff --git a/frontend/src/hooks/api/dynamicSecret/mutation.ts b/frontend/src/hooks/api/dynamicSecret/mutation.ts index e347e4493..2d04334e6 100644 --- a/frontend/src/hooks/api/dynamicSecret/mutation.ts +++ b/frontend/src/hooks/api/dynamicSecret/mutation.ts @@ -43,12 +43,15 @@ export const useUpdateDynamicSecret = () => { ); return data.dynamicSecret; }, - onSuccess: (_, { path, environmentSlug, projectSlug }) => { + onSuccess: (_, { path, environmentSlug, projectSlug, name }) => { // TODO: optimize but currently don't pass projectId queryClient.invalidateQueries({ queryKey: dashboardKeys.all() }); queryClient.invalidateQueries({ queryKey: dynamicSecretKeys.list({ path, projectSlug, environmentSlug }) }); + queryClient.invalidateQueries({ + queryKey: dynamicSecretKeys.details({ path, projectSlug, environmentSlug, name }) + }); } }); }; diff --git a/frontend/src/hooks/api/dynamicSecret/types.ts b/frontend/src/hooks/api/dynamicSecret/types.ts index 84b618153..289bc1d04 100644 --- a/frontend/src/hooks/api/dynamicSecret/types.ts +++ b/frontend/src/hooks/api/dynamicSecret/types.ts @@ -37,7 +37,8 @@ export enum DynamicSecretProviders { Kubernetes = "kubernetes", Vertica = "vertica", GcpIam = "gcp-iam", - Github = "github" + Github = "github", + Couchbase = "couchbase" } export enum KubernetesDynamicSecretCredentialType { @@ -353,6 +354,38 @@ export type TDynamicSecretProvider = installationId: number; privateKey: string; }; + } + | { + type: DynamicSecretProviders.Couchbase; + inputs: { + url: string; + orgId: string; + projectId: string; + clusterId: string; + roles: string[]; + buckets: + | string + | Array<{ + name: string; + scopes?: Array<{ + name: string; + collections?: string[]; + }>; + }>; + passwordRequirements?: { + length: number; + required: { + lowercase: number; + uppercase: number; + digits: number; + symbols: number; + }; + allowedSymbols?: string; + }; + auth: { + apiKey: string; + }; + }; }; export type TCreateDynamicSecretDTO = { diff --git a/frontend/src/pages/secret-manager/SecretDashboardPage/components/ActionBar/CreateDynamicSecretForm/CouchbaseInputForm.tsx b/frontend/src/pages/secret-manager/SecretDashboardPage/components/ActionBar/CreateDynamicSecretForm/CouchbaseInputForm.tsx new file mode 100644 index 000000000..fd2eecaa6 --- /dev/null +++ b/frontend/src/pages/secret-manager/SecretDashboardPage/components/ActionBar/CreateDynamicSecretForm/CouchbaseInputForm.tsx @@ -0,0 +1,948 @@ +/* eslint-disable jsx-a11y/label-has-associated-control */ +import { Controller, 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, + FilterableSelect, + FormControl, + IconButton, + Input, + SecretInput, + Switch, + Tooltip +} from "@app/components/v2"; +import { useCreateDynamicSecret } from "@app/hooks/api"; +import { DynamicSecretProviders } from "@app/hooks/api/dynamicSecret/types"; +import { WorkspaceEnv } from "@app/hooks/api/types"; +import { slugSchema } from "@app/lib/schemas"; + +// Component for managing scopes and collections within a bucket +const BucketScopesConfiguration = ({ + control, + bucketIndex, + bucketsValue, + setValue, + addScope, + removeScope, + addCollection, + removeCollection +}: { + control: any; + bucketIndex: number; + bucketsValue: any; + setValue: any; + addScope: (bucketIndex: number) => void; + removeScope: (bucketIndex: number, scopeIndex: number) => void; + addCollection: (bucketIndex: number, scopeIndex: number) => void; + removeCollection: (bucketIndex: number, scopeIndex: number, collectionIndex: number) => void; +}) => { + const bucket = Array.isArray(bucketsValue) ? bucketsValue[bucketIndex] : null; + const scopeFields = bucket?.scopes || []; + + return ( +
+
+ + +
+ + {scopeFields.map((_scope: any, scopeIndex: number) => ( +
+
+
Scope {scopeIndex + 1}
+ removeScope(bucketIndex, scopeIndex)} + > + + +
+ + ( + + + + )} + /> + +
+
+ + +
+ + {scopeFields[scopeIndex]?.collections?.map( + (collection: string, collectionIndex: number) => ( +
+ + { + const currentBuckets = Array.isArray(bucketsValue) ? [...bucketsValue] : []; + if (currentBuckets[bucketIndex]?.scopes?.[scopeIndex]?.collections) { + currentBuckets[bucketIndex].scopes[scopeIndex].collections[ + collectionIndex + ] = e.target.value; + setValue("provider.buckets", currentBuckets); + } + }} + placeholder="e.g., airport, airline" + className="text-sm" + /> + + removeCollection(bucketIndex, scopeIndex, collectionIndex)} + > + + +
+ ) + )} + + {(!scopeFields[scopeIndex]?.collections || + scopeFields[scopeIndex].collections.length === 0) && ( +
+ No collections specified (access to all collections in scope) +
+ )} +
+
+ ))} + + {scopeFields.length === 0 && ( +
+

+ No scopes configured (access to all scopes in bucket) +

+ +
+ )} +
+ ); +}; + +const couchbaseRoles = [ + { value: "read", label: "Read", description: "Read-only access to bucket data" }, + { + value: "write", + label: "Write", + description: "Full write access to bucket data" + } +]; + +const passwordRequirementsSchema = z + .object({ + length: z.number().min(8, "Password must be at least 8 characters").max(128), + required: z + .object({ + lowercase: z.number().min(1, "At least 1 lowercase character required"), + uppercase: z.number().min(1, "At least 1 uppercase character required"), + digits: z.number().min(1, "At least 1 digit required"), + symbols: z.number().min(1, "At least 1 special character required") + }) + .refine((data) => { + const total = Object.values(data).reduce((sum, count) => sum + count, 0); + return total <= 128; + }, "Sum of required characters cannot exceed 128"), + allowedSymbols: z + .string() + .refine((symbols) => { + const forbiddenChars = ["<", ">", ";", ".", "*", "&", "|", "£"]; + return !forbiddenChars.some((char) => symbols?.includes(char)); + }, "Cannot contain: < > ; . * & | £") + .optional() + }) + .refine((data) => { + const total = Object.values(data.required).reduce((sum, count) => sum + count, 0); + return total <= data.length; + }, "Sum of required characters cannot exceed the total length"); + +const bucketSchema = z.object({ + name: z.string().trim().min(1, "Bucket name is required"), + scopes: z + .array( + z.object({ + name: z.string().trim().min(1, "Scope name is required"), + collections: z.array(z.string().trim().min(1)).optional() + }) + ) + .optional() +}); + +const formSchema = z.object({ + provider: z.object({ + url: z.string().url().trim().min(1), + orgId: z.string().trim().min(1), + projectId: z.string().trim().min(1), + clusterId: z.string().trim().min(1), + roles: z.array(z.string()).min(1, "At least one role must be selected"), + buckets: z.union([z.string().trim().min(1), z.array(bucketSchema)]), + useAdvancedBuckets: z.boolean().default(false), + passwordRequirements: passwordRequirementsSchema.optional(), + auth: z.object({ + apiKey: z.string().trim().min(1) + }) + }), + 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" }); + 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" }); + if (valMs > 24 * 60 * 60 * 1000) + ctx.addIssue({ code: z.ZodIssueCode.custom, message: "TTL must be less than a day" }); + }), + name: slugSchema(), + environment: z.object({ name: z.string(), slug: z.string() }), + usernameTemplate: z.string().nullable().optional() +}); +type TForm = z.infer; + +type Props = { + onCompleted: () => void; + onCancel: () => void; + secretPath: string; + projectSlug: string; + environments: WorkspaceEnv[]; + isSingleEnvironmentMode?: boolean; +}; + +export const CouchbaseInputForm = ({ + onCompleted, + onCancel, + environments, + secretPath, + projectSlug, + isSingleEnvironmentMode +}: Props) => { + const { + control, + formState: { isSubmitting }, + handleSubmit, + setValue, + getValues, + watch + } = useForm({ + resolver: zodResolver(formSchema), + defaultValues: { + provider: { + url: "https://cloudapi.cloud.couchbase.com", + roles: ["read"], + buckets: "*", + useAdvancedBuckets: false, + passwordRequirements: { + length: 12, + required: { + lowercase: 1, + uppercase: 1, + digits: 1, + symbols: 1 + }, + allowedSymbols: "!@#$%^()_+-=[]{}:,?/~`" + }, + auth: { + apiKey: "" + } + }, + environment: isSingleEnvironmentMode ? environments[0] : undefined, + usernameTemplate: "{{randomUsername}}" + } + }); + + const createDynamicSecret = useCreateDynamicSecret(); + + const isAdvancedMode = watch("provider.useAdvancedBuckets"); + const bucketsValue = watch("provider.buckets"); + + const addBucket = () => { + const currentBuckets = Array.isArray(bucketsValue) ? bucketsValue : []; + setValue("provider.buckets", [...currentBuckets, { name: "", scopes: [] }]); + }; + + const removeBucket = (index: number) => { + const currentBuckets = Array.isArray(bucketsValue) ? bucketsValue : []; + const newBuckets = currentBuckets.filter((_, i) => i !== index); + setValue("provider.buckets", newBuckets); + }; + + const addScope = (bucketIndex: number) => { + const currentBuckets = Array.isArray(bucketsValue) ? [...bucketsValue] : []; + if (currentBuckets[bucketIndex]) { + const currentScopes = currentBuckets[bucketIndex].scopes || []; + currentBuckets[bucketIndex] = { + ...currentBuckets[bucketIndex], + scopes: [...currentScopes, { name: "", collections: [] }] + }; + setValue("provider.buckets", currentBuckets); + } + }; + + const removeScope = (bucketIndex: number, scopeIndex: number) => { + const currentBuckets = Array.isArray(bucketsValue) ? [...bucketsValue] : []; + if (currentBuckets[bucketIndex]?.scopes) { + currentBuckets[bucketIndex].scopes = currentBuckets[bucketIndex].scopes.filter( + (_, i) => i !== scopeIndex + ); + setValue("provider.buckets", currentBuckets); + } + }; + + const addCollection = (bucketIndex: number, scopeIndex: number) => { + const currentBuckets = Array.isArray(bucketsValue) ? [...bucketsValue] : []; + if (currentBuckets[bucketIndex]?.scopes?.[scopeIndex]) { + const currentCollections = currentBuckets[bucketIndex].scopes[scopeIndex].collections || []; + currentBuckets[bucketIndex].scopes[scopeIndex].collections = [...currentCollections, ""]; + setValue("provider.buckets", currentBuckets); + } + }; + + const removeCollection = (bucketIndex: number, scopeIndex: number, collectionIndex: number) => { + const currentBuckets = Array.isArray(bucketsValue) ? [...bucketsValue] : []; + if (currentBuckets[bucketIndex]?.scopes?.[scopeIndex]?.collections) { + currentBuckets[bucketIndex].scopes[scopeIndex].collections = currentBuckets[ + bucketIndex + ].scopes[scopeIndex].collections.filter((_, i) => i !== collectionIndex); + setValue("provider.buckets", currentBuckets); + } + }; + + const handleCreateDynamicSecret = async ({ + name, + maxTTL, + provider, + defaultTTL, + environment, + usernameTemplate + }: TForm) => { + if (createDynamicSecret.isPending) return; + const isDefaultUsernameTemplate = usernameTemplate === "{{randomUsername}}"; + + const transformedProvider = { + ...provider, + buckets: provider.useAdvancedBuckets ? provider.buckets : (provider.buckets as string) + }; + + const { useAdvancedBuckets, ...finalProvider } = transformedProvider; + + try { + await createDynamicSecret.mutateAsync({ + provider: { type: DynamicSecretProviders.Couchbase, inputs: finalProvider }, + maxTTL, + name, + path: secretPath, + defaultTTL, + projectSlug, + environmentSlug: environment.slug, + usernameTemplate: + !usernameTemplate || isDefaultUsernameTemplate ? undefined : usernameTemplate + }); + onCompleted(); + } catch { + 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 +
+
+
+ ( + + + + )} + /> +
+
+ ( + + + + )} + /> +
+ ( + + + + )} + /> + ( + + + + )} + /> +
+ ( + + value?.includes(role.value))} + onChange={(selectedRoles) => { + if (Array.isArray(selectedRoles)) { + onChange(selectedRoles.map((role: any) => role.value)); + } else { + onChange([]); + } + }} + options={couchbaseRoles} + placeholder="Select roles..." + getOptionLabel={(option) => option.label} + getOptionValue={(option) => option.value} + /> + + )} + /> + + ( + + { + onChange(checked); + const bucketsController = getValues("provider.buckets"); + if (checked && typeof bucketsController === "string") { + setValue("provider.buckets", []); + } else if (!checked && Array.isArray(bucketsController)) { + setValue("provider.buckets", "*"); + } + }} + /> + + )} + /> + + {!watch("provider.useAdvancedBuckets") && ( + ( + + field.onChange(e.target.value)} + placeholder="* (all buckets, scopes & collections) or bucket1,bucket2,bucket3" + /> + + )} + /> + )} + + {isAdvancedMode && Array.isArray(bucketsValue) && ( +
+
+
+
+ Advanced Bucket Configuration +
+
+ Configure specific buckets with their scopes and collections. Leave scopes + empty for access to all scopes in a bucket. +
+
+ +
+ +
+ {Array.isArray(bucketsValue) && + (bucketsValue as any[]).map((_, bucketIndex) => ( +
+
+

+ Bucket {bucketIndex + 1} +

+ removeBucket(bucketIndex)} + > + + +
+ + ( + + + + )} + /> + + +
+ ))} + + {(!Array.isArray(bucketsValue) || bucketsValue.length === 0) && ( +
+

No buckets configured

+ +
+ )} +
+
+ )} + ( + + onChange(e.target.value)} + /> + + )} + /> +
+ + ( + + + + )} + /> + + + +
+ Password Configuration (optional) + +
+ ? +
+
+
+
+ +
+ Set constraints on the generated Couchbase user password (8-128 characters) +
+ + Forbidden characters: < > ; . * & | £ + +
+
+
+ ( + + field.onChange(Number(e.target.value))} + /> + + )} + /> +
+ +
+

Minimum Required Character Counts

+
+ {(() => { + const total = Object.values( + watch("provider.passwordRequirements.required") || {} + ).reduce((sum, count) => sum + Number(count || 0), 0); + const length = watch("provider.passwordRequirements.length") || 0; + const isError = total > length; + return ( + + Total required characters: {total}{" "} + {isError ? `(exceeds length of ${length})` : ""} + + ); + })()} +
+
+ ( + + field.onChange(Number(e.target.value))} + /> + + )} + /> + ( + + field.onChange(Number(e.target.value))} + /> + + )} + /> + ( + + field.onChange(Number(e.target.value))} + /> + + )} + /> + ( + + field.onChange(Number(e.target.value))} + /> + + )} + /> +
+
+ +
+

Allowed Symbols

+ ( + + + + )} + /> +
+
+
+
+
+ {!isSingleEnvironmentMode && ( + ( + + option.name} + getOptionValue={(option) => option.slug} + menuPlacement="top" + /> + + )} + /> + )} +
+
+
+
+ + +
+
+
+ ); +}; diff --git a/frontend/src/pages/secret-manager/SecretDashboardPage/components/ActionBar/CreateDynamicSecretForm/CreateDynamicSecretForm.tsx b/frontend/src/pages/secret-manager/SecretDashboardPage/components/ActionBar/CreateDynamicSecretForm/CreateDynamicSecretForm.tsx index 68f99a267..abe14d82d 100644 --- a/frontend/src/pages/secret-manager/SecretDashboardPage/components/ActionBar/CreateDynamicSecretForm/CreateDynamicSecretForm.tsx +++ b/frontend/src/pages/secret-manager/SecretDashboardPage/components/ActionBar/CreateDynamicSecretForm/CreateDynamicSecretForm.tsx @@ -2,6 +2,7 @@ import { useState } from "react"; import { DiRedis } from "react-icons/di"; import { SiApachecassandra, + SiCouchbase, SiElasticsearch, SiFiles, SiKubernetes, @@ -29,6 +30,7 @@ import { AwsElastiCacheInputForm } from "./AwsElastiCacheInputForm"; import { AwsIamInputForm } from "./AwsIamInputForm"; import { AzureEntraIdInputForm } from "./AzureEntraIdInputForm"; import { CassandraInputForm } from "./CassandraInputForm"; +import { CouchbaseInputForm } from "./CouchbaseInputForm"; import { ElasticSearchInputForm } from "./ElasticSearchInputForm"; import { GcpIamInputForm } from "./GcpIamInputForm"; import { GithubInputForm } from "./GithubInputForm"; @@ -154,6 +156,11 @@ const DYNAMIC_SECRET_LIST = [ icon: , provider: DynamicSecretProviders.Github, title: "GitHub" + }, + { + icon: , + provider: DynamicSecretProviders.Couchbase, + title: "Couchbase" } ]; @@ -608,6 +615,25 @@ export const CreateDynamicSecretForm = ({ /> )} + {wizardStep === WizardSteps.ProviderInputs && + selectedProvider === DynamicSecretProviders.Couchbase && ( + + + + )} diff --git a/frontend/src/pages/secret-manager/SecretDashboardPage/components/DynamicSecretListView/CreateDynamicSecretLease.tsx b/frontend/src/pages/secret-manager/SecretDashboardPage/components/DynamicSecretListView/CreateDynamicSecretLease.tsx index 133abe58e..da9051369 100644 --- a/frontend/src/pages/secret-manager/SecretDashboardPage/components/DynamicSecretListView/CreateDynamicSecretLease.tsx +++ b/frontend/src/pages/secret-manager/SecretDashboardPage/components/DynamicSecretListView/CreateDynamicSecretLease.tsx @@ -384,6 +384,24 @@ const renderOutputForm = ( ); } + if (provider === DynamicSecretProviders.Couchbase) { + const { username, password } = data as { + username: string; + password: string; + }; + + return ( +
+ + +
+ ); + } + return null; }; diff --git a/frontend/src/pages/secret-manager/SecretDashboardPage/components/DynamicSecretListView/EditDynamicSecretForm/EditDynamicSecretCouchbaseForm.tsx b/frontend/src/pages/secret-manager/SecretDashboardPage/components/DynamicSecretListView/EditDynamicSecretForm/EditDynamicSecretCouchbaseForm.tsx new file mode 100644 index 000000000..dbe0560bc --- /dev/null +++ b/frontend/src/pages/secret-manager/SecretDashboardPage/components/DynamicSecretListView/EditDynamicSecretForm/EditDynamicSecretCouchbaseForm.tsx @@ -0,0 +1,929 @@ +/* eslint-disable jsx-a11y/label-has-associated-control */ +import { Controller, 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, + FilterableSelect, + FormControl, + IconButton, + Input, + SecretInput, + Switch, + Tooltip +} from "@app/components/v2"; +import { useUpdateDynamicSecret } from "@app/hooks/api"; +import { TDynamicSecret } from "@app/hooks/api/dynamicSecret/types"; +import { slugSchema } from "@app/lib/schemas"; + +import { MetadataForm } from "../MetadataForm"; + +const BucketScopesConfiguration = ({ + control, + bucketIndex, + bucketsValue, + setValue, + addScope, + removeScope, + addCollection, + removeCollection +}: { + control: any; + bucketIndex: number; + bucketsValue: any; + setValue: any; + addScope: (bucketIndex: number) => void; + removeScope: (bucketIndex: number, scopeIndex: number) => void; + addCollection: (bucketIndex: number, scopeIndex: number) => void; + removeCollection: (bucketIndex: number, scopeIndex: number, collectionIndex: number) => void; +}) => { + const bucket = Array.isArray(bucketsValue) ? bucketsValue[bucketIndex] : null; + const scopeFields = bucket?.scopes || []; + + return ( +
+
+ + +
+ + {scopeFields.map((_scope: any, scopeIndex: number) => ( +
+
+
Scope {scopeIndex + 1}
+ removeScope(bucketIndex, scopeIndex)} + > + + +
+ + ( + + + + )} + /> + +
+
+ + +
+ + {scopeFields[scopeIndex]?.collections?.map( + (collection: string, collectionIndex: number) => ( +
+ + { + const currentBuckets = Array.isArray(bucketsValue) ? [...bucketsValue] : []; + if (currentBuckets[bucketIndex]?.scopes?.[scopeIndex]?.collections) { + currentBuckets[bucketIndex].scopes[scopeIndex].collections[ + collectionIndex + ] = e.target.value; + setValue("inputs.buckets", currentBuckets); + } + }} + placeholder="e.g., airport, airline" + className="text-sm" + /> + + removeCollection(bucketIndex, scopeIndex, collectionIndex)} + > + + +
+ ) + )} + + {(!scopeFields[scopeIndex]?.collections || + scopeFields[scopeIndex].collections.length === 0) && ( +
+ No collections specified (access to all collections in scope) +
+ )} +
+
+ ))} + + {scopeFields.length === 0 && ( +
+

+ No scopes configured (access to all scopes in bucket) +

+ +
+ )} +
+ ); +}; + +const couchbaseRoles = [ + { value: "read", label: "Read", description: "Read-only access to bucket data" }, + { + value: "write", + label: "Write", + description: "Full write access to bucket data" + } +]; + +const passwordRequirementsSchema = z + .object({ + length: z.number().min(8, "Password must be at least 8 characters").max(128), + required: z + .object({ + lowercase: z.number().min(1, "At least 1 lowercase character required"), + uppercase: z.number().min(1, "At least 1 uppercase character required"), + digits: z.number().min(1, "At least 1 digit required"), + symbols: z.number().min(1, "At least 1 special character required") + }) + .refine((data) => { + const total = Object.values(data).reduce((sum, count) => sum + count, 0); + return total <= 128; + }, "Sum of required characters cannot exceed 128"), + allowedSymbols: z + .string() + .refine((symbols) => { + const forbiddenChars = ["<", ">", ";", ".", "*", "&", "|", "�"]; + return !forbiddenChars.some((char) => symbols?.includes(char)); + }, "Cannot contain: < > ; . * &") + .optional() + }) + .refine((data) => { + const total = Object.values(data.required).reduce((sum, count) => sum + count, 0); + return total <= data.length; + }, "Sum of required characters cannot exceed the total length"); + +const bucketSchema = z.object({ + name: z.string().trim().min(1, "Bucket name is required"), + scopes: z + .array( + z.object({ + name: z.string().trim().min(1, "Scope name is required"), + collections: z.array(z.string().trim().min(1)).optional() + }) + ) + .optional() +}); + +const formSchema = z.object({ + inputs: z + .object({ + url: z.string().url().min(1), + orgId: z.string().min(1), + projectId: z.string().min(1), + clusterId: z.string().min(1), + roles: z.array(z.string()).min(1), + buckets: z.union([z.string().trim().min(1), z.array(bucketSchema)]), + useAdvancedBuckets: z.boolean().default(false), + passwordRequirements: passwordRequirementsSchema.optional(), + auth: z.object({ + apiKey: z.string().min(1) + }) + }) + .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" }); + 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" }); + if (valMs > 24 * 60 * 60 * 1000) + ctx.addIssue({ code: z.ZodIssueCode.custom, message: "TTL must be less than a day" }); + }) + .nullable(), + newName: slugSchema().optional(), + metadata: z + .object({ + key: z.string().trim().min(1), + value: z.string().trim().default("") + }) + .array() + .optional(), + usernameTemplate: z.string().trim().nullable().optional() +}); + +type TForm = z.infer; + +type Props = { + onClose: () => void; + dynamicSecret: TDynamicSecret & { inputs: unknown }; + secretPath: string; + projectSlug: string; + environment: string; +}; + +export const EditDynamicSecretCouchbaseForm = ({ + onClose, + dynamicSecret, + secretPath, + environment, + projectSlug +}: Props) => { + const { + control, + formState: { isSubmitting }, + handleSubmit, + setValue, + watch + } = useForm({ + resolver: zodResolver(formSchema), + defaultValues: { + defaultTTL: dynamicSecret.defaultTTL, + maxTTL: dynamicSecret.maxTTL || undefined, + newName: dynamicSecret.name, + metadata: dynamicSecret.metadata, + usernameTemplate: dynamicSecret.usernameTemplate, + inputs: { + ...(dynamicSecret.inputs as any), + useAdvancedBuckets: Array.isArray((dynamicSecret.inputs as any)?.buckets) + } + } + }); + + const updateDynamicSecret = useUpdateDynamicSecret(); + + const isAdvancedMode = watch("inputs.useAdvancedBuckets"); + const bucketsValue = watch("inputs.buckets"); + + const addBucket = () => { + const currentBuckets = Array.isArray(bucketsValue) ? bucketsValue : []; + setValue("inputs.buckets", [...currentBuckets, { name: "", scopes: [] }]); + }; + + const removeBucket = (index: number) => { + const currentBuckets = Array.isArray(bucketsValue) ? bucketsValue : []; + const newBuckets = currentBuckets.filter((_, i) => i !== index); + setValue("inputs.buckets", newBuckets); + }; + + const addScope = (bucketIndex: number) => { + const currentBuckets = Array.isArray(bucketsValue) ? [...bucketsValue] : []; + if (currentBuckets[bucketIndex]) { + const currentScopes = currentBuckets[bucketIndex].scopes || []; + currentBuckets[bucketIndex] = { + ...currentBuckets[bucketIndex], + scopes: [...currentScopes, { name: "", collections: [] }] + }; + setValue("inputs.buckets", currentBuckets); + } + }; + + const removeScope = (bucketIndex: number, scopeIndex: number) => { + const currentBuckets = Array.isArray(bucketsValue) ? [...bucketsValue] : []; + if (currentBuckets[bucketIndex]?.scopes) { + currentBuckets[bucketIndex].scopes = currentBuckets[bucketIndex].scopes.filter( + (_, i) => i !== scopeIndex + ); + setValue("inputs.buckets", currentBuckets); + } + }; + + const addCollection = (bucketIndex: number, scopeIndex: number) => { + const currentBuckets = Array.isArray(bucketsValue) ? [...bucketsValue] : []; + if (currentBuckets[bucketIndex]?.scopes?.[scopeIndex]) { + const currentCollections = currentBuckets[bucketIndex].scopes[scopeIndex].collections || []; + currentBuckets[bucketIndex].scopes[scopeIndex].collections = [...currentCollections, ""]; + setValue("inputs.buckets", currentBuckets); + } + }; + + const removeCollection = (bucketIndex: number, scopeIndex: number, collectionIndex: number) => { + const currentBuckets = Array.isArray(bucketsValue) ? [...bucketsValue] : []; + if (currentBuckets[bucketIndex]?.scopes?.[scopeIndex]?.collections) { + currentBuckets[bucketIndex].scopes[scopeIndex].collections = currentBuckets[ + bucketIndex + ].scopes[scopeIndex].collections.filter((_, i) => i !== collectionIndex); + setValue("inputs.buckets", currentBuckets); + } + }; + + const handleUpdateDynamicSecret = async ({ + inputs, + newName, + defaultTTL, + maxTTL, + metadata, + usernameTemplate + }: TForm) => { + if (updateDynamicSecret.isPending) return; + + const transformedInputs = inputs + ? { + ...inputs, + buckets: inputs.useAdvancedBuckets ? inputs.buckets : (inputs.buckets as string) + } + : inputs; + + const finalInputs = transformedInputs + ? (() => { + const { useAdvancedBuckets, ...rest } = transformedInputs; + return rest; + })() + : transformedInputs; + + try { + await updateDynamicSecret.mutateAsync({ + name: dynamicSecret.name, + path: secretPath, + projectSlug, + environmentSlug: environment, + data: { + defaultTTL, + maxTTL: maxTTL || undefined, + newName: newName === dynamicSecret.name ? undefined : newName, + metadata, + usernameTemplate: + !usernameTemplate || usernameTemplate === "{{randomUsername}}" + ? undefined + : usernameTemplate, + inputs: finalInputs + } + }); + + createNotification({ + type: "success", + text: "Successfully updated dynamic secret" + }); + + onClose(); + } catch (err) { + createNotification({ + type: "error", + text: `Failed to update dynamic secret: ${err}` + }); + } + }; + + return ( +
+
+
+
+
+ ( + + + + )} + /> +
+
+ ( + } + isError={Boolean(error?.message)} + errorText={error?.message} + > + + + )} + /> +
+
+ ( + } + isError={Boolean(error?.message)} + errorText={error?.message} + > + + + )} + /> +
+
+ +
+
+ Configuration +
+
+
+ ( + + + + )} + /> +
+ ( + + + + )} + /> +
+ ( + + + + )} + /> + ( + + + + )} + /> +
+ ( + + value?.includes(role.value))} + onChange={(selectedRoles) => { + if (Array.isArray(selectedRoles)) { + onChange(selectedRoles.map((role: any) => role.value)); + } else { + onChange([]); + } + }} + options={couchbaseRoles} + placeholder="Select roles..." + getOptionLabel={(option) => option.label} + getOptionValue={(option) => option.value} + /> + + )} + /> + ( + + { + onChange(checked); + const bucketsController = watch("inputs.buckets"); + if (checked && typeof bucketsController === "string") { + setValue("inputs.buckets", []); + } else if (!checked && Array.isArray(bucketsController)) { + setValue("inputs.buckets", "*"); + } + }} + /> + + )} + /> + + {!watch("inputs.useAdvancedBuckets") && ( + ( + + field.onChange(e.target.value)} + placeholder="* (all buckets, scopes & collections) or bucket1,bucket2,bucket3" + /> + + )} + /> + )} + + {isAdvancedMode && Array.isArray(bucketsValue) && ( +
+
+
+
+ Advanced Bucket Configuration +
+
+ Configure specific buckets with their scopes and collections. Leave scopes + empty for access to all scopes in a bucket. +
+
+ +
+ +
+ {Array.isArray(bucketsValue) && + (bucketsValue as any[]).map((_, bucketIndex) => ( +
+
+

+ Bucket {bucketIndex + 1} +

+ removeBucket(bucketIndex)} + > + + +
+ + ( + + + + )} + /> + + +
+ ))} + + {(!Array.isArray(bucketsValue) || bucketsValue.length === 0) && ( +
+

No buckets configured

+ +
+ )} +
+
+ )} + ( + + onChange(e.target.value)} + /> + + )} + /> + ( + + + + )} + /> + + + +
+ Password Configuration (optional) + +
+ ? +
+
+
+
+ +
+ Set constraints on the generated Couchbase user password (8-128 characters) +
+ + Forbidden characters: < > ; . * & | � + +
+
+
+ ( + + field.onChange(Number(e.target.value))} + /> + + )} + /> +
+ +
+

Minimum Required Character Counts

+
+ {(() => { + const total = Object.values( + watch("inputs.passwordRequirements.required") || {} + ).reduce((sum, count) => sum + Number(count || 0), 0); + const length = watch("inputs.passwordRequirements.length") || 0; + const isError = total > length; + return ( + + Total required characters: {total}{" "} + {isError ? `(exceeds length of ${length})` : ""} + + ); + })()} +
+
+ ( + + field.onChange(Number(e.target.value))} + /> + + )} + /> + ( + + field.onChange(Number(e.target.value))} + /> + + )} + /> + ( + + field.onChange(Number(e.target.value))} + /> + + )} + /> + ( + + field.onChange(Number(e.target.value))} + /> + + )} + /> +
+
+ +
+

Allowed Symbols

+ ( + + + + )} + /> +
+
+
+
+
+
+
+
+
+ + +
+
+
+ ); +}; diff --git a/frontend/src/pages/secret-manager/SecretDashboardPage/components/DynamicSecretListView/EditDynamicSecretForm/EditDynamicSecretForm.tsx b/frontend/src/pages/secret-manager/SecretDashboardPage/components/DynamicSecretListView/EditDynamicSecretForm/EditDynamicSecretForm.tsx index 5c4d31dd0..c057d1070 100644 --- a/frontend/src/pages/secret-manager/SecretDashboardPage/components/DynamicSecretListView/EditDynamicSecretForm/EditDynamicSecretForm.tsx +++ b/frontend/src/pages/secret-manager/SecretDashboardPage/components/DynamicSecretListView/EditDynamicSecretForm/EditDynamicSecretForm.tsx @@ -8,6 +8,7 @@ import { EditDynamicSecretAwsElastiCacheProviderForm } from "./EditDynamicSecret import { EditDynamicSecretAwsIamForm } from "./EditDynamicSecretAwsIamForm"; import { EditDynamicSecretAzureEntraIdForm } from "./EditDynamicSecretAzureEntraIdForm"; import { EditDynamicSecretCassandraForm } from "./EditDynamicSecretCassandraForm"; +import { EditDynamicSecretCouchbaseForm } from "./EditDynamicSecretCouchbaseForm"; import { EditDynamicSecretElasticSearchForm } from "./EditDynamicSecretElasticSearchForm"; import { EditDynamicSecretGcpIamForm } from "./EditDynamicSecretGcpIamForm"; import { EditDynamicSecretGithubForm } from "./EditDynamicSecretGithubForm"; @@ -384,6 +385,23 @@ export const EditDynamicSecretForm = ({ /> )} + {dynamicSecretDetails?.type === DynamicSecretProviders.Couchbase && ( + + + + )} ); };