diff --git a/backend/src/ee/services/dynamic-secret/providers/models.ts b/backend/src/ee/services/dynamic-secret/providers/models.ts index 621c3c631..93af4eabc 100644 --- a/backend/src/ee/services/dynamic-secret/providers/models.ts +++ b/backend/src/ee/services/dynamic-secret/providers/models.ts @@ -1,5 +1,22 @@ import { z } from "zod"; +export type PasswordRequirements = { + minLength: number; + maxLength: number; + required: { + lowercase: number; + uppercase: number; + digits: number; + symbols: number; + }; + allowedCharacters?: { + lowercase?: string; + uppercase?: string; + digits?: string; + symbols?: string; + }; +}; + export enum SqlProviders { Postgres = "postgres", MySQL = "mysql2", @@ -100,6 +117,27 @@ export const DynamicSecretSqlDBSchema = z.object({ database: z.string().trim(), username: z.string().trim(), password: z.string().trim(), + passwordRequirements: z + .object({ + minLength: z.number().min(1).max(100), + maxLength: z.number().min(1).max(100), + required: z.object({ + lowercase: z.number().min(0), + uppercase: z.number().min(0), + digits: z.number().min(0), + symbols: z.number().min(0) + }), + allowedCharacters: z + .object({ + lowercase: z.string().optional(), + uppercase: z.string().optional(), + digits: z.string().optional(), + symbols: z.string().optional() + }) + .optional() + }) + .optional() + .describe('Password generation requirements'), creationStatement: z.string().trim(), revocationStatement: z.string().trim(), renewStatement: z.string().trim().optional(), diff --git a/backend/src/ee/services/dynamic-secret/providers/sql-database.ts b/backend/src/ee/services/dynamic-secret/providers/sql-database.ts index 68089ea4c..7d76db697 100644 --- a/backend/src/ee/services/dynamic-secret/providers/sql-database.ts +++ b/backend/src/ee/services/dynamic-secret/providers/sql-database.ts @@ -8,16 +8,92 @@ import { alphaNumericNanoId } from "@app/lib/nanoid"; import { TGatewayServiceFactory } from "../../gateway/gateway-service"; import { verifyHostInputValidity } from "../dynamic-secret-fns"; -import { DynamicSecretSqlDBSchema, SqlProviders, TDynamicProviderFns } from "./models"; +import { DynamicSecretSqlDBSchema, PasswordRequirements, SqlProviders, TDynamicProviderFns } from "./models"; const EXTERNAL_REQUEST_TIMEOUT = 10 * 1000; -const generatePassword = (provider: SqlProviders) => { - // oracle has limit of 48 password length - const size = provider === SqlProviders.Oracle ? 30 : 48; +const DEFAULT_PASSWORD_REQUIREMENTS = { + minLength: 48, + maxLength: 48, + required: { + lowercase: 1, + uppercase: 1, + digits: 1, + symbols: 0 + }, + allowedCharacters: { + lowercase: 'abcdefghijklmnopqrstuvwxyz', + uppercase: 'ABCDEFGHIJKLMNOPQRSTUVWXYZ', + digits: '0123456789', + symbols: '-_.~!*' + } +}; - const charset = "abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789-_.~!*"; - return customAlphabet(charset, 48)(size); +const ORACLE_PASSWORD_REQUIREMENTS = { + ...DEFAULT_PASSWORD_REQUIREMENTS, + minLength: 30, + maxLength: 30 +}; + +const generatePassword = (provider: SqlProviders, requirements?: PasswordRequirements) => { + const defaultReqs = provider === SqlProviders.Oracle ? ORACLE_PASSWORD_REQUIREMENTS : DEFAULT_PASSWORD_REQUIREMENTS; + const finalReqs = requirements || defaultReqs; + + try { + const { minLength, maxLength, required, allowedCharacters = {} } = finalReqs; + + const chars = { + lowercase: allowedCharacters.lowercase || 'abcdefghijklmnopqrstuvwxyz', + uppercase: allowedCharacters.uppercase || 'ABCDEFGHIJKLMNOPQRSTUVWXYZ', + digits: allowedCharacters.digits || '0123456789', + symbols: allowedCharacters.symbols || '-_.~!*' + }; + + const parts: string[] = []; + + if (required.lowercase > 0) { + parts.push(...Array(required.lowercase).fill(0).map(() => + chars.lowercase[Math.floor(Math.random() * chars.lowercase.length)] + )); + } + + if (required.uppercase > 0) { + parts.push(...Array(required.uppercase).fill(0).map(() => + chars.uppercase[Math.floor(Math.random() * chars.uppercase.length)] + )); + } + + if (required.digits > 0) { + parts.push(...Array(required.digits).fill(0).map(() => + chars.digits[Math.floor(Math.random() * chars.digits.length)] + )); + } + + if (required.symbols > 0) { + parts.push(...Array(required.symbols).fill(0).map(() => + chars.symbols[Math.floor(Math.random() * chars.symbols.length)] + )); + } + + const requiredTotal = Object.values(required).reduce((a, b) => a + b, 0); + const remainingLength = Math.max(minLength - requiredTotal, 0); + + const allChars = Object.values(chars).join(''); + parts.push(...Array(remainingLength).fill(0).map(() => + allChars[Math.floor(Math.random() * allChars.length)] + )); + + // shuffle the array to mix up the characters + for (let i = parts.length - 1; i > 0; i--) { + const j = Math.floor(Math.random() * (i + 1)); + [parts[i], parts[j]] = [parts[j], parts[i]]; + } + + return parts.join(''); + } catch (error: unknown) { + const message = error instanceof Error ? error.message : 'Unknown error'; + throw new Error(`Failed to generate password: ${message}`); + } }; const generateUsername = (provider: SqlProviders) => { @@ -115,7 +191,7 @@ export const SqlDatabaseProvider = ({ gatewayService }: TSqlDatabaseProviderDTO) const create = async (inputs: unknown, expireAt: number) => { const providerInputs = await validateProviderInputs(inputs); const username = generateUsername(providerInputs.client); - const password = generatePassword(providerInputs.client); + const password = generatePassword(providerInputs.client, providerInputs.passwordRequirements); const gatewayCallback = async (host = providerInputs.host, port = providerInputs.port) => { const db = await $getClient({ ...providerInputs, port, host }); try { diff --git a/frontend/src/hooks/api/dynamicSecret/types.ts b/frontend/src/hooks/api/dynamicSecret/types.ts index 7d8c6920a..154449d5b 100644 --- a/frontend/src/hooks/api/dynamicSecret/types.ts +++ b/frontend/src/hooks/api/dynamicSecret/types.ts @@ -50,6 +50,7 @@ export type TDynamicSecretProvider = database: string; username: string; password: string; + passwordRegex?: string; creationStatement: string; revocationStatement: string; renewStatement?: string; diff --git a/frontend/src/pages/secret-manager/SecretDashboardPage/components/ActionBar/CreateDynamicSecretForm/SqlDatabaseInputForm.tsx b/frontend/src/pages/secret-manager/SecretDashboardPage/components/ActionBar/CreateDynamicSecretForm/SqlDatabaseInputForm.tsx index a4962d10c..354f226f8 100644 --- a/frontend/src/pages/secret-manager/SecretDashboardPage/components/ActionBar/CreateDynamicSecretForm/SqlDatabaseInputForm.tsx +++ b/frontend/src/pages/secret-manager/SecretDashboardPage/components/ActionBar/CreateDynamicSecretForm/SqlDatabaseInputForm.tsx @@ -23,6 +23,25 @@ import { useWorkspace } from "@app/context"; import { gatewaysQueryKeys, useCreateDynamicSecret } from "@app/hooks/api"; import { DynamicSecretProviders, SqlProviders } from "@app/hooks/api/dynamicSecret/types"; +const passwordRequirementsSchema = z.object({ + minLength: z.number().min(1).max(100), + maxLength: z.number().min(1).max(100), + required: z.object({ + lowercase: z.number().min(0), + uppercase: z.number().min(0), + digits: z.number().min(0), + symbols: z.number().min(0) + }), + allowedCharacters: z + .object({ + lowercase: z.string().optional(), + uppercase: z.string().optional(), + digits: z.string().optional(), + symbols: z.string().optional() + }) + .optional() +}); + const formSchema = z.object({ provider: z.object({ client: z.nativeEnum(SqlProviders), @@ -31,6 +50,7 @@ const formSchema = z.object({ database: z.string().min(1), username: z.string().min(1), password: z.string().min(1), + passwordRequirements: passwordRequirementsSchema.optional(), creationStatement: z.string().min(1), revocationStatement: z.string().min(1), renewStatement: z.string().optional(), @@ -137,7 +157,19 @@ export const SqlDatabaseInputForm = ({ } = useForm({ resolver: zodResolver(formSchema), defaultValues: { - provider: getSqlStatements(SqlProviders.Postgres) + provider: { + ...getSqlStatements(SqlProviders.Postgres), + passwordRequirements: { + minLength: 48, + maxLength: 48, + required: { + lowercase: 1, + uppercase: 1, + digits: 1, + symbols: 0 + } + } + } } }); @@ -174,6 +206,11 @@ export const SqlDatabaseInputForm = ({ setValue("provider.renewStatement", sqlStatment.renewStatement); setValue("provider.revocationStatement", sqlStatment.revocationStatement); setValue("provider.port", getDefaultPort(type)); + + // Update password requirements based on provider + const minMaxLength = type === SqlProviders.Oracle ? 30 : 48; + setValue("provider.passwordRequirements.minLength", minMaxLength); + setValue("provider.passwordRequirements.maxLength", minMaxLength); }; return ( @@ -197,6 +234,7 @@ export const SqlDatabaseInputForm = ({ )} /> +
)} /> - - - Modify SQL Statements + + + Creation, Revocation & Renew Statements (optional) +
+ Customize SQL statements for managing database user lifecycle +
+ + + Password Configuration (optional) + +
+ Set constraints on the generated database password +
+
+
+ ( + + field.onChange(Number(e.target.value))} + /> + + )} + /> + ( + + field.onChange(Number(e.target.value))} + /> + + )} + /> +
+ +
+

Required Characters

+
+ ( + + 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 Characters (Optional)

+
+ ( + + + + )} + /> + ( + + + + )} + /> + ( + + + + )} + /> + ( + + + + )} + /> +
+
+
+
+
+
diff --git a/frontend/src/pages/secret-manager/SecretDashboardPage/components/DynamicSecretListView/EditDynamicSecretForm/EditDynamicSecretSqlProviderForm.tsx b/frontend/src/pages/secret-manager/SecretDashboardPage/components/DynamicSecretListView/EditDynamicSecretForm/EditDynamicSecretSqlProviderForm.tsx index 39655e431..5cf5c6524 100644 --- a/frontend/src/pages/secret-manager/SecretDashboardPage/components/DynamicSecretListView/EditDynamicSecretForm/EditDynamicSecretSqlProviderForm.tsx +++ b/frontend/src/pages/secret-manager/SecretDashboardPage/components/DynamicSecretListView/EditDynamicSecretForm/EditDynamicSecretSqlProviderForm.tsx @@ -23,6 +23,25 @@ import { useWorkspace } from "@app/context"; import { gatewaysQueryKeys, useUpdateDynamicSecret } from "@app/hooks/api"; import { SqlProviders, TDynamicSecret } from "@app/hooks/api/dynamicSecret/types"; +const passwordRequirementsSchema = z.object({ + minLength: z.number().min(1).max(100), + maxLength: z.number().min(1).max(100), + required: z.object({ + lowercase: z.number().min(0), + uppercase: z.number().min(0), + digits: z.number().min(0), + symbols: z.number().min(0) + }), + allowedCharacters: z + .object({ + lowercase: z.string().optional(), + uppercase: z.string().optional(), + digits: z.string().optional(), + symbols: z.string().optional() + }) + .optional() +}); + const formSchema = z.object({ inputs: z .object({ @@ -32,6 +51,7 @@ const formSchema = z.object({ database: z.string().min(1), username: z.string().min(1), password: z.string().min(1), + passwordRequirements: passwordRequirementsSchema.optional(), creationStatement: z.string().min(1), revocationStatement: z.string().min(1), renewStatement: z.string().optional(), @@ -82,6 +102,17 @@ export const EditDynamicSecretSqlProviderForm = ({ secretPath, projectSlug }: Props) => { + const getDefaultPasswordRequirements = (provider: SqlProviders) => ({ + minLength: provider === SqlProviders.Oracle ? 30 : 48, + maxLength: provider === SqlProviders.Oracle ? 30 : 48, + required: { + lowercase: 1, + uppercase: 1, + digits: 1, + symbols: 0 + } + }); + const { control, watch, @@ -94,10 +125,13 @@ export const EditDynamicSecretSqlProviderForm = ({ maxTTL: dynamicSecret.maxTTL, newName: dynamicSecret.name, inputs: { - ...(dynamicSecret.inputs as TForm["inputs"]) + ...(dynamicSecret.inputs as TForm["inputs"]), + passwordRequirements: (dynamicSecret.inputs as TForm["inputs"])?.passwordRequirements || + getDefaultPasswordRequirements((dynamicSecret.inputs as TForm["inputs"])?.client || SqlProviders.Postgres) } } }); + const { currentWorkspace } = useWorkspace(); const { data: projectGateways, isPending: isProjectGatewaysLoading } = useQuery( gatewaysQueryKeys.listProjectGateways({ projectId: currentWorkspace.id }) @@ -347,10 +381,13 @@ export const EditDynamicSecretSqlProviderForm = ({ )} /> - - - Modify SQL Statements + + + Creation, Revocation & Renew Statements (optional) +
+ Customize SQL statements for managing database user lifecycle +
+ + + Password Configuration (optional) + +
+ Set constraints on the generated database password +
+
+
+ ( + + field.onChange(Number(e.target.value))} + /> + + )} + /> + ( + + field.onChange(Number(e.target.value))} + /> + + )} + /> +
+ +
+

Required Characters

+
+ ( + + 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 Characters (Optional)

+
+ ( + + + + )} + /> + ( + + + + )} + /> + ( + + + + )} + /> + ( + + + + )} + /> +
+
+
+
+
+