diff --git a/backend/src/ee/services/dynamic-secret/providers/index.ts b/backend/src/ee/services/dynamic-secret/providers/index.ts index 3384e3781..76ef7ef2a 100644 --- a/backend/src/ee/services/dynamic-secret/providers/index.ts +++ b/backend/src/ee/services/dynamic-secret/providers/index.ts @@ -17,6 +17,7 @@ import { SapAseProvider } from "./sap-ase"; import { SapHanaProvider } from "./sap-hana"; import { SqlDatabaseProvider } from "./sql-database"; import { TotpProvider } from "./totp"; +import { VerticaProvider } from "./vertica"; type TBuildDynamicSecretProviderDTO = { gatewayService: Pick; @@ -40,5 +41,6 @@ export const buildDynamicSecretProviders = ({ [DynamicSecretProviders.Snowflake]: SnowflakeProvider(), [DynamicSecretProviders.Totp]: TotpProvider(), [DynamicSecretProviders.SapAse]: SapAseProvider(), - [DynamicSecretProviders.Kubernetes]: KubernetesProvider({ gatewayService }) + [DynamicSecretProviders.Kubernetes]: KubernetesProvider({ gatewayService }), + [DynamicSecretProviders.Vertica]: VerticaProvider({ gatewayService }) }); diff --git a/backend/src/ee/services/dynamic-secret/providers/models.ts b/backend/src/ee/services/dynamic-secret/providers/models.ts index ed59f4d67..91d26da32 100644 --- a/backend/src/ee/services/dynamic-secret/providers/models.ts +++ b/backend/src/ee/services/dynamic-secret/providers/models.ts @@ -16,7 +16,8 @@ export enum SqlProviders { MySQL = "mysql2", Oracle = "oracledb", MsSQL = "mssql", - SapAse = "sap-ase" + SapAse = "sap-ase", + Vertica = "vertica" } export enum ElasticSearchAuthTypes { @@ -293,6 +294,39 @@ export const DynamicSecretKubernetesSchema = z.object({ audiences: z.array(z.string().trim().min(1)) }); +export const DynamicSecretVerticaSchema = z.object({ + host: z.string().trim().toLowerCase(), + port: z.number(), + username: z.string().trim(), + password: z.string().trim(), + database: z.string().trim(), + gatewayId: z.string().nullable().optional(), + creationStatement: z.string().trim(), + revocationStatement: 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") +}); + export const DynamicSecretTotpSchema = z.discriminatedUnion("configType", [ z.object({ configType: z.literal(TotpConfigType.URL), @@ -337,7 +371,8 @@ export enum DynamicSecretProviders { Snowflake = "snowflake", Totp = "totp", SapAse = "sap-ase", - Kubernetes = "kubernetes" + Kubernetes = "kubernetes", + Vertica = "vertica" } export const DynamicSecretProviderSchema = z.discriminatedUnion("type", [ @@ -356,7 +391,8 @@ export const DynamicSecretProviderSchema = z.discriminatedUnion("type", [ z.object({ type: z.literal(DynamicSecretProviders.Ldap), inputs: LdapSchema }), z.object({ type: z.literal(DynamicSecretProviders.Snowflake), inputs: DynamicSecretSnowflakeSchema }), z.object({ type: z.literal(DynamicSecretProviders.Totp), inputs: DynamicSecretTotpSchema }), - z.object({ type: z.literal(DynamicSecretProviders.Kubernetes), inputs: DynamicSecretKubernetesSchema }) + z.object({ type: z.literal(DynamicSecretProviders.Kubernetes), inputs: DynamicSecretKubernetesSchema }), + z.object({ type: z.literal(DynamicSecretProviders.Vertica), inputs: DynamicSecretVerticaSchema }) ]); export type TDynamicProviderFns = { diff --git a/backend/src/ee/services/dynamic-secret/providers/vertica.ts b/backend/src/ee/services/dynamic-secret/providers/vertica.ts new file mode 100644 index 000000000..9e283ab41 --- /dev/null +++ b/backend/src/ee/services/dynamic-secret/providers/vertica.ts @@ -0,0 +1,367 @@ +import { randomInt } from "crypto"; +import handlebars from "handlebars"; +import knex, { Knex } from "knex"; +import { z } from "zod"; + +import { BadRequestError } from "@app/lib/errors"; +import { withGatewayProxy } from "@app/lib/gateway"; +import { logger } from "@app/lib/logger"; +import { alphaNumericNanoId } from "@app/lib/nanoid"; +import { validateHandlebarTemplate } from "@app/lib/template/validate-handlebars"; + +import { TGatewayServiceFactory } from "../../gateway/gateway-service"; +import { verifyHostInputValidity } from "../dynamic-secret-fns"; +import { DynamicSecretVerticaSchema, PasswordRequirements, TDynamicProviderFns } from "./models"; + +const EXTERNAL_REQUEST_TIMEOUT = 10 * 1000; + +interface VersionResult { + version: string; +} + +interface SessionResult { + session_id?: string; +} + +interface DatabaseQueryResult { + rows?: Array>; +} + +// Extended Knex client interface to handle Vertica-specific overrides +interface VerticaKnexClient extends Knex { + client: { + parseVersion?: () => string; + }; +} + +const DEFAULT_PASSWORD_REQUIREMENTS = { + length: 48, + required: { + lowercase: 1, + uppercase: 1, + digits: 1, + symbols: 0 + }, + allowedSymbols: "-_.~!*" +}; + +const generatePassword = (requirements?: PasswordRequirements) => { + const finalReqs = requirements || DEFAULT_PASSWORD_REQUIREMENTS; + + 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 = (usernameTemplate?: string | null) => { + const randomUsername = `inf_${alphaNumericNanoId(25)}`; // Username must start with an ascii letter, so we prepend the username with "inf-" + if (!usernameTemplate) return randomUsername; + + return handlebars.compile(usernameTemplate)({ + randomUsername, + unixTimestamp: Math.floor(Date.now() / 100) + }); +}; + +type TVerticaProviderDTO = { + gatewayService: Pick; +}; + +export const VerticaProvider = ({ gatewayService }: TVerticaProviderDTO): TDynamicProviderFns => { + const validateProviderInputs = async (inputs: unknown) => { + const providerInputs = await DynamicSecretVerticaSchema.parseAsync(inputs); + + const [hostIp] = await verifyHostInputValidity(providerInputs.host, Boolean(providerInputs.gatewayId)); + validateHandlebarTemplate("Vertica creation", providerInputs.creationStatement, { + allowedExpressions: (val) => ["username", "password"].includes(val) + }); + if (providerInputs.revocationStatement) { + validateHandlebarTemplate("Vertica revoke", providerInputs.revocationStatement, { + allowedExpressions: (val) => ["username"].includes(val) + }); + } + return { ...providerInputs, hostIp }; + }; + + const $getClient = async (providerInputs: z.infer & { hostIp: string }) => { + const config = { + client: "pg", + connection: { + host: providerInputs.hostIp, + port: providerInputs.port, + database: providerInputs.database, + user: providerInputs.username, + password: providerInputs.password, + ssl: false + }, + acquireConnectionTimeout: EXTERNAL_REQUEST_TIMEOUT, + pool: { + min: 0, + max: 1, + acquireTimeoutMillis: 30000, + createTimeoutMillis: 30000, + destroyTimeoutMillis: 5000, + idleTimeoutMillis: 30000, + reapIntervalMillis: 1000, + createRetryIntervalMillis: 100 + }, + // Disable version checking for Vertica compatibility + version: "9.6.0" // Fake a compatible PostgreSQL version + }; + + const client = knex(config) as VerticaKnexClient; + + // Override the version parsing to prevent errors with Vertica + if (client.client && typeof client.client.parseVersion !== "undefined") { + client.client.parseVersion = () => "9.6.0"; + } + + return client; + }; + + const gatewayProxyWrapper = async ( + providerInputs: z.infer, + gatewayCallback: (host: string, port: number) => Promise + ) => { + const relayDetails = await gatewayService.fnGetGatewayClientTlsByGatewayId(providerInputs.gatewayId as string); + const [relayHost, relayPort] = relayDetails.relayAddress.split(":"); + await withGatewayProxy( + async (port) => { + await gatewayCallback("localhost", port); + }, + { + targetHost: providerInputs.host, + targetPort: providerInputs.port, + relayHost, + relayPort: Number(relayPort), + identityId: relayDetails.identityId, + orgId: relayDetails.orgId, + tlsOptions: { + ca: relayDetails.certChain, + cert: relayDetails.certificate, + key: relayDetails.privateKey.toString() + } + } + ); + }; + + const validateConnection = async (inputs: unknown) => { + const providerInputs = await validateProviderInputs(inputs); + let isConnected = false; + + const gatewayCallback = async (host = providerInputs.hostIp, port = providerInputs.port) => { + let client: VerticaKnexClient | null = null; + + try { + client = await $getClient({ ...providerInputs, hostIp: host, port }); + + const clientResult: DatabaseQueryResult = await client.raw("SELECT version() AS version"); + + const resultFromSelectedDatabase = clientResult.rows?.[0] as VersionResult | undefined; + + if (!resultFromSelectedDatabase?.version) { + throw new BadRequestError({ + message: "Failed to validate Vertica connection, version query failed" + }); + } + + isConnected = true; + } finally { + if (client) await client.destroy(); + } + }; + + if (providerInputs.gatewayId) { + await gatewayProxyWrapper(providerInputs, gatewayCallback); + } else { + await gatewayCallback(); + } + + return isConnected; + }; + + const create = async (data: { inputs: unknown; usernameTemplate?: string | null }) => { + const { inputs, usernameTemplate } = data; + const providerInputs = await validateProviderInputs(inputs); + + const username = generateUsername(usernameTemplate); + const password = generatePassword(providerInputs.passwordRequirements); + + const gatewayCallback = async (host = providerInputs.host, port = providerInputs.port) => { + let client: VerticaKnexClient | null = null; + + try { + client = await $getClient({ ...providerInputs, hostIp: host, port }); + + const creationStatement = handlebars.compile(providerInputs.creationStatement, { noEscape: true })({ + username, + password + }); + + const queries = creationStatement.trim().replaceAll("\n", "").split(";").filter(Boolean); + + // Execute queries sequentially to maintain transaction integrity + for (const query of queries) { + const trimmedQuery = query.trim(); + if (trimmedQuery) { + // eslint-disable-next-line no-await-in-loop + await client.raw(trimmedQuery); + } + } + } finally { + if (client) await client.destroy(); + } + }; + + if (providerInputs.gatewayId) { + await gatewayProxyWrapper(providerInputs, gatewayCallback); + } else { + await gatewayCallback(); + } + + return { entityId: username, data: { DB_USERNAME: username, DB_PASSWORD: password } }; + }; + + const revoke = async (inputs: unknown, username: string) => { + const providerInputs = await validateProviderInputs(inputs); + + const gatewayCallback = async (host = providerInputs.host, port = providerInputs.port) => { + let client: VerticaKnexClient | null = null; + + try { + client = await $getClient({ ...providerInputs, hostIp: host, port }); + + const revokeStatement = handlebars.compile(providerInputs.revocationStatement, { noEscape: true })({ + username + }); + + const queries = revokeStatement.trim().replaceAll("\n", "").split(";").filter(Boolean); + + // Check for active sessions and close them + try { + const sessionResult: DatabaseQueryResult = await client.raw( + "SELECT session_id FROM sessions WHERE user_name = ?", + [username] + ); + + const activeSessions = (sessionResult.rows || []) as SessionResult[]; + + // Close all sessions in parallel since they're independent operations + if (activeSessions.length > 0) { + const sessionClosePromises = activeSessions.map(async (session) => { + try { + await client!.raw("SELECT close_session(?)", [session.session_id]); + } catch (error) { + // Continue if session is already closed + logger.error(error, `Failed to close session ${session.session_id}`); + } + }); + + await Promise.allSettled(sessionClosePromises); + } + } catch (error) { + // Continue if we can't query sessions (permissions, etc.) + logger.error(error, "Could not query/close active sessions"); + } + + // Execute revocation queries sequentially to maintain transaction integrity + for (const query of queries) { + const trimmedQuery = query.trim(); + if (trimmedQuery) { + // eslint-disable-next-line no-await-in-loop + await client.raw(trimmedQuery); + } + } + } finally { + if (client) await client.destroy(); + } + }; + + if (providerInputs.gatewayId) { + await gatewayProxyWrapper(providerInputs, gatewayCallback); + } else { + await gatewayCallback(); + } + + return { entityId: username }; + }; + + const renew = async (_: unknown, username: string) => { + // No need for renewal + return { entityId: username }; + }; + + return { + validateProviderInputs, + validateConnection, + create, + revoke, + renew + }; +}; diff --git a/docs/documentation/platform/dynamic-secrets/vertica.mdx b/docs/documentation/platform/dynamic-secrets/vertica.mdx new file mode 100644 index 000000000..3d5c7b1a9 --- /dev/null +++ b/docs/documentation/platform/dynamic-secrets/vertica.mdx @@ -0,0 +1,134 @@ +--- +title: "Vertica" +description: "Learn how to dynamically generate Vertica database users." +--- + +The Infisical Vertica dynamic secret allows you to generate Vertica database credentials on demand based on configured role. + +## Prerequisite + +Create a user with the required permission in your Vertica instance. This user will be used to create new accounts on-demand. + +## Set up Dynamic Secrets with Vertica + + + + Open the Secret Overview dashboard and select the environment in which you would like to add a dynamic secret. + + + ![Add Dynamic Secret Button](../../../images/platform/dynamic-secrets/add-dynamic-secret-button.png) + + + ![Dynamic Secret Modal](../../../images/platform/dynamic-secrets/vertica/dynamic-secret-modal-vertica.png) + + + + Name by which you want the secret to be referenced + + + + Default time-to-live for a generated secret (it is possible to modify this value after a secret is generated) + + + + Maximum time-to-live for a generated secret + + + + Select a gateway for private cluster access. If not specified, the Internet Gateway will be used. + + + + Vertica database host + + + + Vertica database port (default: 5433) + + + + Name of the Vertica database for which you want to create dynamic secrets + + + + Username that will be used to create dynamic secrets + + + + Password that will be used to create dynamic secrets + + + ![Dynamic Secret Setup Modal](../../../images/platform/dynamic-secrets/vertica/dynamic-secret-setup-modal-vertica.png) + + + + ![Modify SQL Statements Modal](../../../images/platform/dynamic-secrets/vertica/modify-sql-statements-vertica.png) + + Specifies a template for generating usernames. This field allows customization of how usernames are automatically created. + + Allowed template variables are + - `{{randomUsername}}`: Random username string + - `{{unixTimestamp}}`: Current Unix timestamp + + + Customize the SQL statement used to create new users. Default creates a user with basic schema permissions. + + + Customize the SQL statement used to revoke users. Default revokes a user. + + + + + Length of generated passwords (1-250 characters) + + + Minimum required character counts: + - **Lowercase Count**: Minimum lowercase letters (default: 1) + - **Uppercase Count**: Minimum uppercase letters (default: 1) + - **Digit Count**: Minimum digits (default: 1) + - **Symbol Count**: Minimum symbols (default: 0) + + + Symbols allowed in generated passwords + + + + After submitting the form, you will see a dynamic secret created in the dashboard. + + ![Dynamic Secret](../../../images/platform/dynamic-secrets/vertica/dynamic-secret-vertica.png) + + + Once you've successfully configured the dynamic secret, you're ready to generate on-demand credentials. + 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) + + 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) + + + Ensure that the TTL for the lease falls within the maximum TTL defined when configuring the dynamic secret. + + + 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) + + + +## Audit or Revoke Leases +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 the lease before its set time to live. + +![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) + + + Lease renewals cannot exceed the maximum TTL set when configuring the dynamic secret + diff --git a/docs/images/platform/dynamic-secrets/vertica/dynamic-secret-modal-vertica.png b/docs/images/platform/dynamic-secrets/vertica/dynamic-secret-modal-vertica.png new file mode 100644 index 000000000..0424286d9 Binary files /dev/null and b/docs/images/platform/dynamic-secrets/vertica/dynamic-secret-modal-vertica.png differ diff --git a/docs/images/platform/dynamic-secrets/vertica/dynamic-secret-setup-modal-vertica.png b/docs/images/platform/dynamic-secrets/vertica/dynamic-secret-setup-modal-vertica.png new file mode 100644 index 000000000..a270cb214 Binary files /dev/null and b/docs/images/platform/dynamic-secrets/vertica/dynamic-secret-setup-modal-vertica.png differ diff --git a/docs/images/platform/dynamic-secrets/vertica/dynamic-secret-vertica.png b/docs/images/platform/dynamic-secrets/vertica/dynamic-secret-vertica.png new file mode 100644 index 000000000..6effe4574 Binary files /dev/null and b/docs/images/platform/dynamic-secrets/vertica/dynamic-secret-vertica.png differ diff --git a/docs/images/platform/dynamic-secrets/vertica/modify-sql-statements-vertica.png b/docs/images/platform/dynamic-secrets/vertica/modify-sql-statements-vertica.png new file mode 100644 index 000000000..dd97ccc4e Binary files /dev/null and b/docs/images/platform/dynamic-secrets/vertica/modify-sql-statements-vertica.png differ diff --git a/docs/mint.json b/docs/mint.json index 9a7ce2a2e..52beaa64e 100644 --- a/docs/mint.json +++ b/docs/mint.json @@ -225,7 +225,8 @@ "documentation/platform/dynamic-secrets/sap-hana", "documentation/platform/dynamic-secrets/snowflake", "documentation/platform/dynamic-secrets/totp", - "documentation/platform/dynamic-secrets/kubernetes" + "documentation/platform/dynamic-secrets/kubernetes", + "documentation/platform/dynamic-secrets/vertica" ] }, { diff --git a/frontend/src/hooks/api/dynamicSecret/types.ts b/frontend/src/hooks/api/dynamicSecret/types.ts index 45fb20955..f9aa6d4d0 100644 --- a/frontend/src/hooks/api/dynamicSecret/types.ts +++ b/frontend/src/hooks/api/dynamicSecret/types.ts @@ -33,7 +33,8 @@ export enum DynamicSecretProviders { Snowflake = "snowflake", Totp = "totp", SapAse = "sap-ase", - Kubernetes = "kubernetes" + Kubernetes = "kubernetes", + Vertica = "vertica" } export enum SqlProviders { @@ -277,6 +278,18 @@ export type TDynamicSecretProvider = sslEnabled: boolean; audiences: string[]; }; + } + | { + type: DynamicSecretProviders.Vertica; + inputs: { + host: string; + port: number; + database: string; + username: string; + password: string; + creationStatement: string; + revocationStatement: string; + }; }; export type TCreateDynamicSecretDTO = { diff --git a/frontend/src/pages/secret-manager/SecretDashboardPage/components/ActionBar/CreateDynamicSecretForm/CreateDynamicSecretForm.tsx b/frontend/src/pages/secret-manager/SecretDashboardPage/components/ActionBar/CreateDynamicSecretForm/CreateDynamicSecretForm.tsx index 9d9cca5d3..5a73fa6d8 100644 --- a/frontend/src/pages/secret-manager/SecretDashboardPage/components/ActionBar/CreateDynamicSecretForm/CreateDynamicSecretForm.tsx +++ b/frontend/src/pages/secret-manager/SecretDashboardPage/components/ActionBar/CreateDynamicSecretForm/CreateDynamicSecretForm.tsx @@ -36,6 +36,7 @@ import { SapHanaInputForm } from "./SapHanaInputForm"; import { SnowflakeInputForm } from "./SnowflakeInputForm"; import { SqlDatabaseInputForm } from "./SqlDatabaseInputForm"; import { TotpInputForm } from "./TotpInputForm"; +import { VerticaInputForm } from "./VerticaInputForm"; type Props = { isOpen?: boolean; @@ -127,6 +128,11 @@ const DYNAMIC_SECRET_LIST = [ provider: DynamicSecretProviders.Totp, title: "TOTP" }, + { + icon: , + provider: DynamicSecretProviders.Vertica, + title: "Vertica" + }, { icon: , provider: DynamicSecretProviders.Kubernetes, @@ -498,6 +504,25 @@ export const CreateDynamicSecretForm = ({ /> )} + {wizardStep === WizardSteps.ProviderInputs && + selectedProvider === DynamicSecretProviders.Vertica && ( + + + + )} diff --git a/frontend/src/pages/secret-manager/SecretDashboardPage/components/ActionBar/CreateDynamicSecretForm/VerticaInputForm.tsx b/frontend/src/pages/secret-manager/SecretDashboardPage/components/ActionBar/CreateDynamicSecretForm/VerticaInputForm.tsx new file mode 100644 index 000000000..d5443b1ed --- /dev/null +++ b/frontend/src/pages/secret-manager/SecretDashboardPage/components/ActionBar/CreateDynamicSecretForm/VerticaInputForm.tsx @@ -0,0 +1,610 @@ +import { Controller, useForm } from "react-hook-form"; +import { zodResolver } from "@hookform/resolvers/zod"; +import { useQuery } from "@tanstack/react-query"; +import ms from "ms"; +import { z } from "zod"; + +import { TtlFormLabel } from "@app/components/features"; +import { createNotification } from "@app/components/notifications"; +import { OrgPermissionCan } from "@app/components/permissions"; +import { + Accordion, + AccordionContent, + AccordionItem, + AccordionTrigger, + Button, + FilterableSelect, + FormControl, + Input, + Select, + SelectItem, + TextArea, + Tooltip +} from "@app/components/v2"; +import { + OrgGatewayPermissionActions, + OrgPermissionSubjects +} from "@app/context/OrgPermissionContext/types"; +import { gatewaysQueryKeys, useCreateDynamicSecret } from "@app/hooks/api"; +import { DynamicSecretProviders } from "@app/hooks/api/dynamicSecret/types"; +import { WorkspaceEnv } from "@app/hooks/api/types"; + +const passwordRequirementsSchema = z + .object({ + length: z.number().min(8).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({ + host: z.string().toLowerCase().min(1), + port: z.coerce.number(), + 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), + gatewayId: z.string().optional() + }), + defaultTTL: z.string().superRefine((val, ctx) => { + const valMs = ms(val); + if (valMs < 60 * 1000) + ctx.addIssue({ code: z.ZodIssueCode.custom, message: "TTL must be a greater than 1min" }); + if (valMs > 24 * 60 * 60 * 1000) + ctx.addIssue({ code: z.ZodIssueCode.custom, message: "TTL must be less than a day" }); + }), + maxTTL: z + .string() + .optional() + .superRefine((val, ctx) => { + if (!val) return; + const valMs = ms(val); + if (valMs < 60 * 1000) + ctx.addIssue({ code: z.ZodIssueCode.custom, message: "TTL must be a greater than 1min" }); + if (valMs > 24 * 60 * 60 * 1000) + ctx.addIssue({ code: z.ZodIssueCode.custom, message: "TTL must be less than a day" }); + }), + name: z.string().refine((val) => val.toLowerCase() === val, "Must be lowercase"), + environment: z.object({ name: z.string(), slug: z.string() }), + usernameTemplate: z.string().nullable().optional() +}); +type TForm = z.infer; + +type Props = { + onCompleted: () => void; + onCancel: () => void; + secretPath: string; + projectSlug: string; + environments: WorkspaceEnv[]; + isSingleEnvironmentMode?: boolean; +}; + +export const VerticaInputForm = ({ + onCompleted, + onCancel, + environments, + secretPath, + projectSlug, + isSingleEnvironmentMode +}: Props) => { + const { + control, + watch, + formState: { isSubmitting }, + handleSubmit + } = useForm({ + resolver: zodResolver(formSchema), + defaultValues: { + provider: { + database: "", + port: 5433, + passwordRequirements: { + length: 48, + required: { + lowercase: 1, + uppercase: 1, + digits: 1, + symbols: 0 + }, + allowedSymbols: "-_.~!*" + }, + creationStatement: `CREATE USER {{username}} IDENTIFIED BY '{{password}}'; +GRANT CREATE ON SCHEMA public TO {{username}};`, + revocationStatement: "DROP USER {{username}} CASCADE;" + }, + environment: isSingleEnvironmentMode ? environments[0] : undefined, + usernameTemplate: "{{randomUsername}}" + } + }); + + const createDynamicSecret = useCreateDynamicSecret(); + const { data: gateways, isPending: isGatewaysLoading } = useQuery(gatewaysQueryKeys.list()); + + const handleCreateDynamicSecret = async ({ + name, + maxTTL, + provider, + defaultTTL, + environment, + usernameTemplate + }: TForm) => { + if (createDynamicSecret.isPending) return; + try { + const isDefaultUsernameTemplate = usernameTemplate === "{{randomUsername}}"; + await createDynamicSecret.mutateAsync({ + provider: { type: DynamicSecretProviders.Vertica, inputs: provider }, + maxTTL, + name, + path: secretPath, + defaultTTL, + projectSlug, + environmentSlug: environment.slug, + usernameTemplate: + !usernameTemplate || isDefaultUsernameTemplate ? undefined : usernameTemplate + }); + onCompleted(); + } catch { + createNotification({ + type: "error", + text: "Failed to create dynamic secret" + }); + } + }; + + return ( +
+
+
+
+
+ ( + + + + )} + /> +
+
+ ( + } + isError={Boolean(error?.message)} + errorText={error?.message} + > + + + )} + /> +
+
+ ( + } + isError={Boolean(error?.message)} + errorText={error?.message} + > + + + )} + /> +
+
+
+
+ Configuration +
+
+ + {(isAllowed) => ( + ( + + +
+ +
+
+
+ )} + /> + )} +
+
+
+
+ ( + + + + )} + /> + ( + + + + )} + /> + ( + + + + )} + /> +
+
+ ( + + + + )} + /> + ( + + + + )} + /> +
+
+ + + Modify SQL Statements + + ( + + + + )} + /> + ( + +