diff --git a/backend/src/ee/services/dynamic-secret/providers/couchbase.ts b/backend/src/ee/services/dynamic-secret/providers/couchbase.ts index 2244d8483..520ce5289 100644 --- a/backend/src/ee/services/dynamic-secret/providers/couchbase.ts +++ b/backend/src/ee/services/dynamic-secret/providers/couchbase.ts @@ -4,6 +4,7 @@ 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"; @@ -27,34 +28,39 @@ type TCreateCouchbaseUser = { }[]; }; +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"); + + 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; }; 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); }; @@ -74,26 +80,26 @@ const generatePassword = (requirements?: PasswordRequirements): string => { let remaining = length; // Add required characters - for (let i = 0; i < required.lowercase; i++) { + for (let i = 0; i < required.lowercase; i += 1) { password += lowercase[crypto.randomInt(lowercase.length)]; - remaining--; + remaining -= 1; } - for (let i = 0; i < required.uppercase; i++) { + for (let i = 0; i < required.uppercase; i += 1) { password += uppercase[crypto.randomInt(uppercase.length)]; - remaining--; + remaining -= 1; } - for (let i = 0; i < required.digits; i++) { + for (let i = 0; i < required.digits; i += 1) { password += digits[crypto.randomInt(digits.length)]; - remaining--; + remaining -= 1; } - for (let i = 0; i < required.symbols; i++) { + for (let i = 0; i < required.symbols; i += 1) { password += symbols[crypto.randomInt(symbols.length)]; - remaining--; + remaining -= 1; } // Fill remaining with random characters from all sets const allChars = lowercase + uppercase + digits + symbols; - for (let i = 0; i < remaining; i++) { + for (let i = 0; i < remaining; i += 1) { password += allChars[crypto.randomInt(allChars.length)]; } @@ -109,99 +115,92 @@ const couchbaseApiRequest = async ( url: string, apiKey: string, data?: unknown -): Promise => { +): Promise => { await blockLocalAndPrivateIpAddresses(url); try { const response = await axios({ - method: method.toLowerCase() as any, + method: method.toLowerCase() as "get" | "post" | "put" | "delete", url, headers: { - "Authorization": `Bearer ${apiKey}`, + Authorization: `Bearer ${apiKey}`, "Content-Type": "application/json" }, data: data || undefined, timeout: 30000 }); - return response.data; - } catch (error: any) { - if (error.response) { - // Server responded with error status - const errorData = error.response.data; - const errorMessage = typeof errorData === 'string' ? errorData : JSON.stringify(errorData); - throw new BadRequestError({ - message: `Couchbase API error: ${error.response.status} ${error.response.statusText} - ${errorMessage}` - }); - } else if (error.request) { - // Request made but no response received - throw new BadRequestError({ - message: `Couchbase API request failed: ${error.message}` - }); - } else { - // Error in request configuration - throw new BadRequestError({ - message: `Couchbase API request error: ${error.message}` - }); - } + 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"}` + 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 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`; - + let bucketResources; - + if (typeof providerInputs.buckets === "string") { // Simple string format - either "*" or comma-separated bucket names - const bucketNames = providerInputs.buckets === "*" - ? ["*"] - : providerInputs.buckets.split(",").map(bucket => bucket.trim()).filter(bucket => bucket.length > 0); - bucketResources = bucketNames.map(bucketName => ({ name: bucketName })); + const bucketNames = + providerInputs.buckets === "*" + ? ["*"] + : providerInputs.buckets + .split(",") + .map((bucket) => bucket.trim()) + .filter((bucket) => bucket.length > 0); + bucketResources = bucketNames.map((bucketName) => ({ name: bucketName })); } else { // Array of bucket objects with scopes and collections - bucketResources = providerInputs.buckets.map(bucket => ({ + bucketResources = providerInputs.buckets.map((bucket) => ({ name: bucket.name, - scopes: bucket.scopes?.map(scope => ({ + scopes: bucket.scopes?.map((scope) => ({ name: scope.name, collections: scope.collections || [] })) @@ -211,18 +210,20 @@ export const CouchbaseProvider = (): TDynamicProviderFns => { const userData: TCreateCouchbaseUser = { name: username, password, - access: [{ - privileges: providerInputs.roles, - resources: { - buckets: bucketResources + access: [ + { + privileges: providerInputs.roles, + resources: { + buckets: bucketResources + } } - }] + ] }; - - const response = await couchbaseApiRequest("POST", createUserUrl, providerInputs.auth.apiKey, userData) as any; - + + const response = await couchbaseApiRequest("POST", createUserUrl, providerInputs.auth.apiKey, userData); + const userUuid = response?.id || response?.uuid || username; - + return { entityId: userUuid, data: { @@ -234,11 +235,11 @@ export const CouchbaseProvider = (): TDynamicProviderFns => { 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 }; }; @@ -255,4 +256,4 @@ export const CouchbaseProvider = (): TDynamicProviderFns => { revoke, renew }; -}; \ No newline at end of file +}; diff --git a/backend/src/ee/services/dynamic-secret/providers/models.ts b/backend/src/ee/services/dynamic-secret/providers/models.ts index 483abc344..43726b15a 100644 --- a/backend/src/ee/services/dynamic-secret/providers/models.ts +++ b/backend/src/ee/services/dynamic-secret/providers/models.ts @@ -511,16 +511,26 @@ export const DynamicSecretCouchbaseSchema = z.object({ 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("*"), - 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") - })) - ]).default("*").describe("Bucket configuration: '*' for all buckets or array of bucket objects with scopes and collections"), + buckets: z + .union([ + z.string().trim().min(1).default("*"), + 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") + }) + ) + ]) + .default("*") + .describe("Bucket configuration: '*' for all buckets or array of bucket objects with scopes and collections"), passwordRequirements: z .object({ length: z.number().min(8, "Password must be at least 8 characters").max(128), @@ -535,10 +545,13 @@ export const DynamicSecretCouchbaseSchema = z.object({ 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() + 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); diff --git a/docs/documentation/platform/dynamic-secrets/couchbase.mdx b/docs/documentation/platform/dynamic-secrets/couchbase.mdx index 4976b6ae4..35e4cea57 100644 --- a/docs/documentation/platform/dynamic-secrets/couchbase.mdx +++ b/docs/documentation/platform/dynamic-secrets/couchbase.mdx @@ -110,7 +110,7 @@ Create an API Key in your Couchbase Cloud following the [official documentation] {{randomUsername}} // infisical-3POnzeFyK9gW2nioK0q2gMjr6CZqsRiX {{unixTimestamp}} // 17490641580 {{identity.name}} // testuser - {{random-5}} // x9k2m + {{random 5}} // x9k2m {{truncate identity.name 4}} // test {{replace identity.name 'user' 'replace'}} // testreplace ``` @@ -157,12 +157,12 @@ Create an API Key in your Couchbase Cloud following the [official documentation] 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) + ![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) + ![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. @@ -170,7 +170,7 @@ Create an API Key in your Couchbase Cloud following the [official documentation] 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) + ![Provision Lease](../../../images/platform/dynamic-secrets/lease-values.png) @@ -229,12 +229,12 @@ The advanced bucket configuration allows you to specify granular access control: 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) +![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) +![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/frontend/src/pages/secret-manager/SecretDashboardPage/components/ActionBar/CreateDynamicSecretForm/CouchbaseInputForm.tsx b/frontend/src/pages/secret-manager/SecretDashboardPage/components/ActionBar/CreateDynamicSecretForm/CouchbaseInputForm.tsx index 067f1a878..67ac6ab3b 100644 --- a/frontend/src/pages/secret-manager/SecretDashboardPage/components/ActionBar/CreateDynamicSecretForm/CouchbaseInputForm.tsx +++ b/frontend/src/pages/secret-manager/SecretDashboardPage/components/ActionBar/CreateDynamicSecretForm/CouchbaseInputForm.tsx @@ -1,7 +1,8 @@ +/* eslint-disable jsx-a11y/label-has-associated-control */ import { Controller, useForm } from "react-hook-form"; -import { zodResolver } from "@hookform/resolvers/zod"; 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"; @@ -27,8 +28,8 @@ 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, +const BucketScopesConfiguration = ({ + control, bucketIndex, bucketsValue, setValue, @@ -36,8 +37,8 @@ const BucketScopesConfiguration = ({ removeScope, addCollection, removeCollection -}: { - control: any; +}: { + control: any; bucketIndex: number; bucketsValue: any; setValue: any; @@ -49,7 +50,6 @@ const BucketScopesConfiguration = ({ const bucket = Array.isArray(bucketsValue) ? bucketsValue[bucketIndex] : null; const scopeFields = bucket?.scopes || []; - return (
@@ -66,11 +66,12 @@ const BucketScopesConfiguration = ({
{scopeFields.map((_scope: any, scopeIndex: number) => ( -
+
-
- Scope {scopeIndex + 1} -
+
Scope {scopeIndex + 1}
- + ( - + )} /> -
+
- - {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) && ( -
+ + {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)

- ( - - - - )} - /> -
( )} /> +
+ ( + + + + )} + /> + ( + + + + )} + /> +
( + name="provider.roles" + defaultValue={["data_reader"]} + render={({ field: { value, onChange }, fieldState: { error } }) => ( - - - )} - /> -
- ( - - 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 = control._formValues.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) or bucket1,bucket2,bucket3" + 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} /> )} /> - )} - {isAdvancedMode && Array.isArray(bucketsValue) && ( -
-
-
-
- Advanced Bucket Configuration -
-
- Configure specific buckets with their scopes and collections -
-
- -
- -
- {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)} - /> + { + 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) or bucket1,bucket2,bucket3" + /> + )} + /> + )} + + {isAdvancedMode && Array.isArray(bucketsValue) && ( +
+
+
+
+ Advanced Bucket Configuration +
+
+ Configure specific buckets with their scopes and collections +
+
+ +
+ +
+ {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)} + /> + + )} />
@@ -731,7 +752,7 @@ export const CouchbaseInputForm = ({
Password Configuration (optional) -
+
?
@@ -750,7 +771,7 @@ export const CouchbaseInputForm = ({ ( )} - {wizardStep === WizardSteps.ProviderInputs && + {wizardStep === WizardSteps.ProviderInputs && selectedProvider === DynamicSecretProviders.Couchbase && (
@@ -66,11 +66,12 @@ const BucketScopesConfiguration = ({
{scopeFields.map((_scope: any, scopeIndex: number) => ( -
+
-
- Scope {scopeIndex + 1} -
+
Scope {scopeIndex + 1}
- + ( - + )} /> -
+
- - {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) && ( -
+ + {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)

- {Array.isArray(bucketsValue) && (bucketsValue as any[]).map((_, bucketIndex) => ( -
-
-

- Bucket {bucketIndex + 1} -

- removeBucket(bucketIndex)} - > - - -
- - ( - ( +
+
+

+ Bucket {bucketIndex + 1} +

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

- No buckets configured -

+
+

No buckets configured