mirror of
https://github.com/awatertrevi/infisical.git
synced 2026-10-14 00:29:34 +00:00
Merge pull request #4372 from Infisical/ENG-3366
Add Couchbase dynamic secrets feature
This commit is contained in:
@@ -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<CouchbaseUserResponse> => {
|
||||
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<boolean> => {
|
||||
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
|
||||
};
|
||||
};
|
||||
@@ -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()
|
||||
});
|
||||
|
||||
@@ -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 = {
|
||||
|
||||
Reference in New Issue
Block a user