diff --git a/backend/src/ee/services/dynamic-secret/providers/models.ts b/backend/src/ee/services/dynamic-secret/providers/models.ts index 621c3c631..449f6d8f6 100644 --- a/backend/src/ee/services/dynamic-secret/providers/models.ts +++ b/backend/src/ee/services/dynamic-secret/providers/models.ts @@ -1,5 +1,16 @@ import { z } from "zod"; +export type PasswordRequirements = { + length: number; + required: { + lowercase: number; + uppercase: number; + digits: number; + symbols: number; + }; + allowedSymbols?: string; +}; + export enum SqlProviders { Postgres = "postgres", MySQL = "mysql2", @@ -100,6 +111,28 @@ export const DynamicSecretSqlDBSchema = z.object({ database: z.string().trim(), username: z.string().trim(), password: z.string().trim(), + passwordRequirements: z + .object({ + length: z.number().min(1).max(250), + required: z + .object({ + lowercase: z.number().min(0), + uppercase: z.number().min(0), + digits: z.number().min(0), + symbols: z.number().min(0) + }) + .refine((data) => { + const total = Object.values(data).reduce((sum, count) => sum + count, 0); + return total <= 250; + }, "Sum of required characters cannot exceed 250"), + allowedSymbols: z.string().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"), 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..eea9fef94 100644 --- a/backend/src/ee/services/dynamic-secret/providers/sql-database.ts +++ b/backend/src/ee/services/dynamic-secret/providers/sql-database.ts @@ -1,6 +1,6 @@ +import { randomInt } from "crypto"; import handlebars from "handlebars"; import knex from "knex"; -import { customAlphabet } from "nanoid"; import { z } from "zod"; import { withGatewayProxy } from "@app/lib/gateway"; @@ -8,16 +8,99 @@ 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 = { + length: 48, + required: { + lowercase: 1, + uppercase: 1, + digits: 1, + symbols: 0 + }, + allowedSymbols: "-_.~!*" +}; - const charset = "abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789-_.~!*"; - return customAlphabet(charset, 48)(size); +const ORACLE_PASSWORD_REQUIREMENTS = { + ...DEFAULT_PASSWORD_REQUIREMENTS, + length: 30 +}; + +const generatePassword = (provider: SqlProviders, requirements?: PasswordRequirements) => { + const defaultReqs = provider === SqlProviders.Oracle ? ORACLE_PASSWORD_REQUIREMENTS : DEFAULT_PASSWORD_REQUIREMENTS; + const finalReqs = requirements || defaultReqs; + + try { + const { length, required, allowedSymbols } = finalReqs; + + const chars = { + lowercase: "abcdefghijklmnopqrstuvwxyz", + uppercase: "ABCDEFGHIJKLMNOPQRSTUVWXYZ", + digits: "0123456789", + symbols: allowedSymbols || "-_.~!*" + }; + + const parts: string[] = []; + + if (required.lowercase > 0) { + parts.push( + ...Array(required.lowercase) + .fill(0) + .map(() => chars.lowercase[randomInt(chars.lowercase.length)]) + ); + } + + if (required.uppercase > 0) { + parts.push( + ...Array(required.uppercase) + .fill(0) + .map(() => chars.uppercase[randomInt(chars.uppercase.length)]) + ); + } + + if (required.digits > 0) { + parts.push( + ...Array(required.digits) + .fill(0) + .map(() => chars.digits[randomInt(chars.digits.length)]) + ); + } + + if (required.symbols > 0) { + parts.push( + ...Array(required.symbols) + .fill(0) + .map(() => chars.symbols[randomInt(chars.symbols.length)]) + ); + } + + const requiredTotal = Object.values(required).reduce((a, b) => a + b, 0); + const remainingLength = Math.max(length - requiredTotal, 0); + + const allowedChars = Object.entries(chars) + .filter(([key]) => required[key as keyof typeof required] > 0) + .map(([, value]) => value) + .join(""); + + parts.push( + ...Array(remainingLength) + .fill(0) + .map(() => allowedChars[randomInt(allowedChars.length)]) + ); + + // shuffle the array to mix up the characters + for (let i = parts.length - 1; i > 0; i -= 1) { + const j = randomInt(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 +198,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/backend/src/server/routes/v2/password-router.ts b/backend/src/server/routes/v2/password-router.ts index 165130dec..63b6d8aac 100644 --- a/backend/src/server/routes/v2/password-router.ts +++ b/backend/src/server/routes/v2/password-router.ts @@ -1,10 +1,10 @@ import { z } from "zod"; import { authRateLimit } from "@app/server/config/rateLimiter"; -import { validatePasswordResetAuthorization } from "@app/services/auth/auth-fns"; -import { AuthMode } from "@app/services/auth/auth-type"; import { verifyAuth } from "@app/server/plugins/auth/verify-auth"; +import { validatePasswordResetAuthorization } from "@app/services/auth/auth-fns"; import { ResetPasswordV2Type } from "@app/services/auth/auth-password-type"; +import { AuthMode } from "@app/services/auth/auth-type"; export const registerPasswordRouter = async (server: FastifyZodProvider) => { server.route({ diff --git a/backend/src/services/auth/auth-password-service.ts b/backend/src/services/auth/auth-password-service.ts index f5f42a236..14fb58258 100644 --- a/backend/src/services/auth/auth-password-service.ts +++ b/backend/src/services/auth/auth-password-service.ts @@ -7,6 +7,7 @@ import { generateSrpServerKey, srpCheckClientProof } from "@app/lib/crypto"; import { infisicalSymmetricDecrypt, infisicalSymmetricEncypt } from "@app/lib/crypto/encryption"; import { generateUserSrpKeys } from "@app/lib/crypto/srp"; import { BadRequestError } from "@app/lib/errors"; +import { logger } from "@app/lib/logger"; import { OrgServiceActor } from "@app/lib/types"; import { TAuthTokenServiceFactory } from "../auth-token/auth-token-service"; @@ -25,7 +26,6 @@ import { TSetupPasswordViaBackupKeyDTO } from "./auth-password-type"; import { ActorType, AuthMethod, AuthTokenType } from "./auth-type"; -import { logger } from "@app/lib/logger"; type TAuthPasswordServiceFactoryDep = { authDAL: TAuthDALFactory; 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..063b2d08b 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,23 @@ 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({ + length: z.number().min(1).max(250), + required: z.object({ + lowercase: z.number().min(0), + uppercase: z.number().min(0), + digits: z.number().min(0), + symbols: z.number().min(0) + }).refine((data) => { + const total = Object.values(data).reduce((sum, count) => sum + count, 0); + return total <= 250; + }, "Sum of required characters cannot exceed 250"), + allowedSymbols: z.string().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 formSchema = z.object({ provider: z.object({ client: z.nativeEnum(SqlProviders), @@ -31,6 +48,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(), @@ -133,11 +151,24 @@ export const SqlDatabaseInputForm = ({ control, setValue, formState: { isSubmitting }, - handleSubmit + handleSubmit, + watch } = useForm({ resolver: zodResolver(formSchema), defaultValues: { - provider: getSqlStatements(SqlProviders.Postgres) + provider: { + ...getSqlStatements(SqlProviders.Postgres), + passwordRequirements: { + length: 48, + required: { + lowercase: 1, + uppercase: 1, + digits: 1, + symbols: 0 + }, + allowedSymbols: '-_.~!*' + } + } } }); @@ -174,6 +205,10 @@ 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 length = type === SqlProviders.Oracle ? 30 : 48; + setValue("provider.passwordRequirements.length", length); }; return ( @@ -197,6 +232,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))} + /> + + )} + /> +
+ +
+

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

+ ( + + + + )} + /> +
+
+
+
+
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..85fb6456e 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,23 @@ 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({ + length: z.number().min(1).max(250), + required: z.object({ + lowercase: z.number().min(0), + uppercase: z.number().min(0), + digits: z.number().min(0), + symbols: z.number().min(0) + }).refine((data) => { + const total = Object.values(data).reduce((sum, count) => sum + count, 0); + return total <= 250; // Sanity check for individual validation + }, "Sum of required characters cannot exceed 250"), + allowedSymbols: z.string().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 formSchema = z.object({ inputs: z .object({ @@ -32,6 +49,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 +100,17 @@ export const EditDynamicSecretSqlProviderForm = ({ secretPath, projectSlug }: Props) => { + const getDefaultPasswordRequirements = (provider: SqlProviders) => ({ + length: provider === SqlProviders.Oracle ? 30 : 48, + required: { + lowercase: 1, + uppercase: 1, + digits: 1, + symbols: 0 + }, + allowedSymbols: '-_.~!*' + }); + const { control, watch, @@ -94,10 +123,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 +379,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))} + /> + + )} + /> +
+ +
+

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

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