Address PR comments

This commit is contained in:
Carlos Monastyrski
2025-08-13 20:13:34 -07:00
parent b479406ba0
commit 91a76f50ca
7 changed files with 138 additions and 89 deletions
@@ -51,6 +51,57 @@ const sanitizeCouchbaseUsername = (username: string): string => {
return sanitized; 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 generateUsername = (usernameTemplate?: string | null, identity?: { name: string }) => {
const randomUsername = alphaNumericNanoId(12); const randomUsername = alphaNumericNanoId(12);
if (!usernameTemplate) return sanitizeCouchbaseUsername(randomUsername); if (!usernameTemplate) return sanitizeCouchbaseUsername(randomUsername);
@@ -184,28 +235,7 @@ export const CouchbaseProvider = (): TDynamicProviderFns => {
const createUserUrl = `${providerInputs.url}/v4/organizations/${providerInputs.orgId}/projects/${providerInputs.projectId}/clusters/${providerInputs.clusterId}/users`; const createUserUrl = `${providerInputs.url}/v4/organizations/${providerInputs.orgId}/projects/${providerInputs.projectId}/clusters/${providerInputs.clusterId}/users`;
let bucketResources; const bucketResources = normalizeBucketConfiguration(providerInputs.buckets);
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 }));
} else {
// Array of bucket objects with scopes and collections
bucketResources = providerInputs.buckets.map((bucket) => ({
name: bucket.name,
scopes: bucket.scopes?.map((scope) => ({
name: scope.name,
collections: scope.collections || []
}))
}));
}
const userData: TCreateCouchbaseUser = { const userData: TCreateCouchbaseUser = {
name: username, name: username,
@@ -513,24 +513,50 @@ export const DynamicSecretCouchbaseSchema = z.object({
roles: z.array(z.string().trim().min(1)).min(1).describe("Roles to assign to the user"), roles: z.array(z.string().trim().min(1)).min(1).describe("Roles to assign to the user"),
buckets: z buckets: z
.union([ .union([
z.string().trim().min(1).default("*"), z
z.array( .string()
z.object({ .trim()
name: z.string().trim().min(1).describe("Bucket name"), .min(1)
scopes: z .default("*")
.array( .refine((val) => {
z.object({ if (val.includes(",")) {
name: z.string().trim().min(1).describe("Scope name"), const buckets = val
collections: z.array(z.string().trim().min(1)).optional().describe("Collection names") .split(",")
}) .map((b) => b.trim())
) .filter((b) => b.length > 0);
.optional() if (buckets.includes("*") && buckets.length > 1) {
.describe("Scopes within the bucket") 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("*") .default("*")
.describe("Bucket configuration: '*' for all buckets or array of bucket objects with scopes and collections"), .describe(
"Bucket configuration: '*' for all buckets, scopes, and collections or array of bucket objects with specific scopes and collections"
),
passwordRequirements: z passwordRequirements: z
.object({ .object({
length: z.number().min(8, "Password must be at least 8 characters").max(128), length: z.number().min(8, "Password must be at least 8 characters").max(128),
@@ -54,8 +54,6 @@ Create an API Key in your Couchbase Cloud following the [official documentation]
<ParamField path="Roles" type="array" required> <ParamField path="Roles" type="array" required>
Database credential roles to assign to the generated user. Available options: Database credential roles to assign to the generated user. Available options:
- **data_reader**: Read access to bucket data
- **data_writer**: Read and write access to bucket data
- **read**: Read access to bucket data (alias for data_reader) - **read**: Read access to bucket data (alias for data_reader)
- **write**: Read and write access to bucket data (alias for data_writer) - **write**: Read and write access to bucket data (alias for data_writer)
</ParamField> </ParamField>
@@ -244,8 +242,8 @@ To extend the life of the generated dynamic secret leases past its initial time
The Couchbase dynamic secret integration supports the following database credential roles: The Couchbase dynamic secret integration supports the following database credential roles:
- **data_reader** / **read**: Provides read-only access to bucket data - **read**: Provides read-only access to bucket data
- **data_writer** / **write**: Provides read and write access to bucket data - **write**: Provides read and write access to bucket data
<Note> <Note>
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. 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.
@@ -257,5 +255,5 @@ These roles are specifically for database credentials and are different from Cou
1. **Invalid API Key**: Ensure your Couchbase Cloud API key has the necessary permissions to manage database users 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 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 (data_reader, data_writer, read, write) 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 4. **Bucket Access Issues**: Ensure the specified buckets exist in your cluster and are accessible
@@ -43,12 +43,15 @@ export const useUpdateDynamicSecret = () => {
); );
return data.dynamicSecret; return data.dynamicSecret;
}, },
onSuccess: (_, { path, environmentSlug, projectSlug }) => { onSuccess: (_, { path, environmentSlug, projectSlug, name }) => {
// TODO: optimize but currently don't pass projectId // TODO: optimize but currently don't pass projectId
queryClient.invalidateQueries({ queryKey: dashboardKeys.all() }); queryClient.invalidateQueries({ queryKey: dashboardKeys.all() });
queryClient.invalidateQueries({ queryClient.invalidateQueries({
queryKey: dynamicSecretKeys.list({ path, projectSlug, environmentSlug }) queryKey: dynamicSecretKeys.list({ path, projectSlug, environmentSlug })
}); });
queryClient.invalidateQueries({
queryKey: dynamicSecretKeys.details({ path, projectSlug, environmentSlug, name })
});
} }
}); });
}; };
@@ -363,13 +363,15 @@ export type TDynamicSecretProvider =
projectId: string; projectId: string;
clusterId: string; clusterId: string;
roles: string[]; roles: string[];
buckets: string | Array<{ buckets:
name: string; | string
scopes?: Array<{ | Array<{
name: string; name: string;
collections?: string[]; scopes?: Array<{
}>; name: string;
}>; collections?: string[];
}>;
}>;
passwordRequirements?: { passwordRequirements?: {
length: number; length: number;
required: { required: {
@@ -109,7 +109,11 @@ const BucketScopesConfiguration = ({
{scopeFields[scopeIndex]?.collections?.map( {scopeFields[scopeIndex]?.collections?.map(
(collection: string, collectionIndex: number) => ( (collection: string, collectionIndex: number) => (
<div key={collection} className="flex items-center space-x-2"> <div
// eslint-disable-next-line react/no-array-index-key
key={`collection-${bucketIndex}-${scopeIndex}-${collectionIndex}`}
className="flex items-center space-x-2"
>
<FormControl className="flex-1"> <FormControl className="flex-1">
<Input <Input
value={collection || ""} value={collection || ""}
@@ -171,21 +175,11 @@ const BucketScopesConfiguration = ({
}; };
const couchbaseRoles = [ const couchbaseRoles = [
{ value: "data_reader", label: "Data Reader", description: "Read access to bucket data" }, { value: "read", label: "Read", description: "Read-only access to bucket data" },
{
value: "data_writer",
label: "Data Writer",
description: "Read and write access to bucket data"
},
{
value: "read",
label: "Read",
description: "Read access to bucket data (alias for data_reader)"
},
{ {
value: "write", value: "write",
label: "Write", label: "Write",
description: "Read and write access to bucket data (alias for data_writer)" description: "Full write access to bucket data"
} }
]; ];
@@ -295,7 +289,7 @@ export const CouchbaseInputForm = ({
defaultValues: { defaultValues: {
provider: { provider: {
url: "https://cloudapi.cloud.couchbase.com", url: "https://cloudapi.cloud.couchbase.com",
roles: ["data_reader"], roles: ["read"],
buckets: "*", buckets: "*",
useAdvancedBuckets: false, useAdvancedBuckets: false,
passwordRequirements: { passwordRequirements: {
@@ -540,7 +534,7 @@ export const CouchbaseInputForm = ({
<Controller <Controller
control={control} control={control}
name="provider.roles" name="provider.roles"
defaultValue={["data_reader"]} defaultValue={["read"]}
render={({ field: { value, onChange }, fieldState: { error } }) => ( render={({ field: { value, onChange }, fieldState: { error } }) => (
<FormControl <FormControl
label="Roles" label="Roles"
@@ -574,7 +568,7 @@ export const CouchbaseInputForm = ({
render={({ field: { value, onChange } }) => ( render={({ field: { value, onChange } }) => (
<FormControl <FormControl
label="Advanced Bucket Configuration" label="Advanced Bucket Configuration"
helperText="Enable to configure specific scopes and collections within buckets" helperText="Enable to configure specific buckets, scopes and collections. When disabled, '*' grants access to all buckets, scopes, and collections."
> >
<Switch <Switch
id="advanced-buckets-switch" id="advanced-buckets-switch"
@@ -604,13 +598,13 @@ export const CouchbaseInputForm = ({
className="w-full" className="w-full"
isError={Boolean(error?.message)} isError={Boolean(error?.message)}
errorText={error?.message} errorText={error?.message}
helperText="Specify bucket names separated by commas (e.g., 'bucket1,bucket2') or use '*' for all buckets" helperText="Specify bucket names separated by commas (e.g., 'bucket1,bucket2') or use '*' for all buckets, scopes, and collections"
> >
<Input <Input
{...field} {...field}
value={typeof field.value === "string" ? field.value : "*"} value={typeof field.value === "string" ? field.value : "*"}
onChange={(e) => field.onChange(e.target.value)} onChange={(e) => field.onChange(e.target.value)}
placeholder="* (all buckets) or bucket1,bucket2,bucket3" placeholder="* (all buckets, scopes & collections) or bucket1,bucket2,bucket3"
/> />
</FormControl> </FormControl>
)} )}
@@ -625,7 +619,8 @@ export const CouchbaseInputForm = ({
Advanced Bucket Configuration Advanced Bucket Configuration
</div> </div>
<div className="text-sm text-mineshaft-400"> <div className="text-sm text-mineshaft-400">
Configure specific buckets with their scopes and collections Configure specific buckets with their scopes and collections. Leave scopes
empty for access to all scopes in a bucket.
</div> </div>
</div> </div>
<Button <Button
@@ -109,7 +109,11 @@ const BucketScopesConfiguration = ({
{scopeFields[scopeIndex]?.collections?.map( {scopeFields[scopeIndex]?.collections?.map(
(collection: string, collectionIndex: number) => ( (collection: string, collectionIndex: number) => (
<div key={collection} className="flex items-center space-x-2"> <div
// eslint-disable-next-line react/no-array-index-key
key={`collection-${bucketIndex}-${scopeIndex}-${collectionIndex}`}
className="flex items-center space-x-2"
>
<FormControl className="flex-1"> <FormControl className="flex-1">
<Input <Input
value={collection || ""} value={collection || ""}
@@ -133,7 +137,7 @@ const BucketScopesConfiguration = ({
size="sm" size="sm"
onClick={() => removeCollection(bucketIndex, scopeIndex, collectionIndex)} onClick={() => removeCollection(bucketIndex, scopeIndex, collectionIndex)}
> >
<FontAwesomeIcon icon={faTrash} className="text-red-400" /> <FontAwesomeIcon icon={faTrash} className="mb-4 text-red-400" />
</IconButton> </IconButton>
</div> </div>
) )
@@ -170,21 +174,11 @@ const BucketScopesConfiguration = ({
}; };
const couchbaseRoles = [ const couchbaseRoles = [
{ value: "data_reader", label: "Data Reader", description: "Read access to bucket data" }, { value: "read", label: "Read", description: "Read-only access to bucket data" },
{
value: "data_writer",
label: "Data Writer",
description: "Read and write access to bucket data"
},
{
value: "read",
label: "Read",
description: "Read access to bucket data (alias for data_reader)"
},
{ {
value: "write", value: "write",
label: "Write", label: "Write",
description: "Read and write access to bucket data (alias for data_writer)" description: "Full write access to bucket data"
} }
]; ];
@@ -548,7 +542,7 @@ export const EditDynamicSecretCouchbaseForm = ({
<Controller <Controller
control={control} control={control}
name="inputs.roles" name="inputs.roles"
defaultValue={["data_reader"]} defaultValue={["read"]}
render={({ field: { value, onChange }, fieldState: { error } }) => ( render={({ field: { value, onChange }, fieldState: { error } }) => (
<FormControl <FormControl
label="Roles" label="Roles"
@@ -581,7 +575,7 @@ export const EditDynamicSecretCouchbaseForm = ({
render={({ field: { value, onChange } }) => ( render={({ field: { value, onChange } }) => (
<FormControl <FormControl
label="Advanced Bucket Configuration" label="Advanced Bucket Configuration"
helperText="Enable to configure specific scopes and collections within buckets" helperText="Enable to configure specific buckets, scopes and collections. When disabled, '*' grants access to all buckets, scopes, and collections."
> >
<Switch <Switch
id="advanced-buckets-switch" id="advanced-buckets-switch"
@@ -611,13 +605,13 @@ export const EditDynamicSecretCouchbaseForm = ({
className="w-full" className="w-full"
isError={Boolean(error?.message)} isError={Boolean(error?.message)}
errorText={error?.message} errorText={error?.message}
helperText="Specify bucket names separated by commas (e.g., 'bucket1,bucket2') or use '*' for all buckets" helperText="Specify bucket names separated by commas (e.g., 'bucket1,bucket2') or use '*' for all buckets, scopes, and collections"
> >
<Input <Input
{...field} {...field}
value={typeof field.value === "string" ? field.value : "*"} value={typeof field.value === "string" ? field.value : "*"}
onChange={(e) => field.onChange(e.target.value)} onChange={(e) => field.onChange(e.target.value)}
placeholder="* (all buckets) or bucket1,bucket2,bucket3" placeholder="* (all buckets, scopes & collections) or bucket1,bucket2,bucket3"
/> />
</FormControl> </FormControl>
)} )}
@@ -632,7 +626,8 @@ export const EditDynamicSecretCouchbaseForm = ({
Advanced Bucket Configuration Advanced Bucket Configuration
</div> </div>
<div className="text-sm text-mineshaft-400"> <div className="text-sm text-mineshaft-400">
Configure specific buckets with their scopes and collections Configure specific buckets with their scopes and collections. Leave scopes
empty for access to all scopes in a bucket.
</div> </div>
</div> </div>
<Button <Button