diff --git a/backend/src/ee/services/dynamic-secret/providers/index.ts b/backend/src/ee/services/dynamic-secret/providers/index.ts index deed367b1..76ef7ef2a 100644 --- a/backend/src/ee/services/dynamic-secret/providers/index.ts +++ b/backend/src/ee/services/dynamic-secret/providers/index.ts @@ -42,5 +42,5 @@ export const buildDynamicSecretProviders = ({ [DynamicSecretProviders.Totp]: TotpProvider(), [DynamicSecretProviders.SapAse]: SapAseProvider(), [DynamicSecretProviders.Kubernetes]: KubernetesProvider({ gatewayService }), - [DynamicSecretProviders.Vertica]: VerticaProvider() + [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 94e0bcb46..91d26da32 100644 --- a/backend/src/ee/services/dynamic-secret/providers/models.ts +++ b/backend/src/ee/services/dynamic-secret/providers/models.ts @@ -300,6 +300,7 @@ export const DynamicSecretVerticaSchema = z.object({ 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 diff --git a/backend/src/ee/services/dynamic-secret/providers/vertica.ts b/backend/src/ee/services/dynamic-secret/providers/vertica.ts index 3763ed727..9e283ab41 100644 --- a/backend/src/ee/services/dynamic-secret/providers/vertica.ts +++ b/backend/src/ee/services/dynamic-secret/providers/vertica.ts @@ -4,13 +4,17 @@ 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; } @@ -125,11 +129,15 @@ const generateUsername = (usernameTemplate?: string | null) => { }); }; -export const VerticaProvider = (): TDynamicProviderFns => { +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); + const [hostIp] = await verifyHostInputValidity(providerInputs.host, Boolean(providerInputs.gatewayId)); validateHandlebarTemplate("Vertica creation", providerInputs.creationStatement, { allowedExpressions: (val) => ["username", "password"].includes(val) }); @@ -152,6 +160,7 @@ export const VerticaProvider = (): TDynamicProviderFns => { password: providerInputs.password, ssl: false }, + acquireConnectionTimeout: EXTERNAL_REQUEST_TIMEOUT, pool: { min: 0, max: 1, @@ -176,27 +185,65 @@ export const VerticaProvider = (): TDynamicProviderFns => { 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 client: VerticaKnexClient | null = null; + let isConnected = false; - try { - client = await $getClient(providerInputs); + const gatewayCallback = async (host = providerInputs.hostIp, port = providerInputs.port) => { + let client: VerticaKnexClient | null = null; - const clientResult: DatabaseQueryResult = await client.raw("SELECT version() AS version"); + try { + client = await $getClient({ ...providerInputs, hostIp: host, port }); - const resultFromSelectedDatabase = clientResult.rows?.[0] as VersionResult | undefined; + const clientResult: DatabaseQueryResult = await client.raw("SELECT version() AS version"); - if (!resultFromSelectedDatabase?.version) { - throw new BadRequestError({ - message: "Failed to validate Vertica connection, version query failed" - }); + 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(); } + }; - return 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 }) => { @@ -206,87 +253,103 @@ export const VerticaProvider = (): TDynamicProviderFns => { const username = generateUsername(usernameTemplate); const password = generatePassword(providerInputs.passwordRequirements); - let client: VerticaKnexClient | null = null; + const gatewayCallback = async (host = providerInputs.host, port = providerInputs.port) => { + let client: VerticaKnexClient | null = null; - try { - client = await $getClient(providerInputs); + try { + client = await $getClient({ ...providerInputs, hostIp: host, port }); - const creationStatement = handlebars.compile(providerInputs.creationStatement, { noEscape: true })({ - username, - password - }); + const creationStatement = handlebars.compile(providerInputs.creationStatement, { noEscape: true })({ + username, + password + }); - const queries = creationStatement.trim().replaceAll("\n", "").split(";").filter(Boolean); + 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); + // 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(); } + }; - return { entityId: username, data: { DB_USERNAME: username, DB_PASSWORD: password } }; - } 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); - let client: VerticaKnexClient | null = null; + const gatewayCallback = async (host = providerInputs.host, port = providerInputs.port) => { + let client: VerticaKnexClient | null = null; - try { - client = await $getClient(providerInputs); - - 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] - ); + client = await $getClient({ ...providerInputs, hostIp: host, port }); - const activeSessions = (sessionResult.rows || []) as SessionResult[]; + const revokeStatement = handlebars.compile(providerInputs.revocationStatement, { noEscape: true })({ + username + }); - // 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}`); - } - }); + const queries = revokeStatement.trim().replaceAll("\n", "").split(";").filter(Boolean); - await Promise.allSettled(sessionClosePromises); + // 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"); } - } 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); + // 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(); } + }; - return { entityId: username }; - } 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) => { diff --git a/docs/documentation/platform/dynamic-secrets/vertica.mdx b/docs/documentation/platform/dynamic-secrets/vertica.mdx index bc655e23b..3d5c7b1a9 100644 --- a/docs/documentation/platform/dynamic-secrets/vertica.mdx +++ b/docs/documentation/platform/dynamic-secrets/vertica.mdx @@ -34,6 +34,10 @@ Create a user with the required permission in your Vertica instance. This user w 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 @@ -106,7 +110,7 @@ Create a user with the required permission in your Vertica instance. This user w ![Provision Lease](/images/platform/dynamic-secrets/provision-lease.png) - Ensure that the TTL for the lease fall within the maximum TTL defined when configuring the dynamic secret. + 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. @@ -117,7 +121,7 @@ Create a user with the required permission in your Vertica instance. This user w ## 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 it's set time to live. +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) 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 index dfa119cab..a270cb214 100644 Binary files a/docs/images/platform/dynamic-secrets/vertica/dynamic-secret-setup-modal-vertica.png and b/docs/images/platform/dynamic-secrets/vertica/dynamic-secret-setup-modal-vertica.png differ 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 index f2ffe45be..d5443b1ed 100644 --- a/frontend/src/pages/secret-manager/SecretDashboardPage/components/ActionBar/CreateDynamicSecretForm/VerticaInputForm.tsx +++ b/frontend/src/pages/secret-manager/SecretDashboardPage/components/ActionBar/CreateDynamicSecretForm/VerticaInputForm.tsx @@ -1,10 +1,12 @@ 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, @@ -14,9 +16,16 @@ import { FilterableSelect, FormControl, Input, - TextArea + Select, + SelectItem, + TextArea, + Tooltip } from "@app/components/v2"; -import { useCreateDynamicSecret } from "@app/hooks/api"; +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"; @@ -50,7 +59,8 @@ const formSchema = z.object({ password: z.string().min(1), passwordRequirements: passwordRequirementsSchema.optional(), creationStatement: z.string().min(1), - revocationStatement: z.string().min(1) + revocationStatement: z.string().min(1), + gatewayId: z.string().optional() }), defaultTTL: z.string().superRefine((val, ctx) => { const valMs = ms(val); @@ -124,6 +134,7 @@ GRANT CREATE ON SCHEMA public TO {{username}};`, }); const createDynamicSecret = useCreateDynamicSecret(); + const { data: gateways, isPending: isGatewaysLoading } = useQuery(gatewaysQueryKeys.list()); const handleCreateDynamicSecret = async ({ name, @@ -184,7 +195,7 @@ GRANT CREATE ON SCHEMA public TO {{username}};`, defaultValue="1h" render={({ field, fieldState: { error } }) => ( } + label={} isError={Boolean(error?.message)} errorText={error?.message} > @@ -200,7 +211,7 @@ GRANT CREATE ON SCHEMA public TO {{username}};`, defaultValue="24h" render={({ field, fieldState: { error } }) => ( } + label={} isError={Boolean(error?.message)} errorText={error?.message} > @@ -214,6 +225,57 @@ GRANT CREATE ON SCHEMA public TO {{username}};`,
Configuration
+
+ + {(isAllowed) => ( + ( + + +
+ +
+
+
+ )} + /> + )} +
+
- + )} /> diff --git a/frontend/src/pages/secret-manager/SecretDashboardPage/components/DynamicSecretListView/EditDynamicSecretForm/EditDynamicSecretVertica.tsx b/frontend/src/pages/secret-manager/SecretDashboardPage/components/DynamicSecretListView/EditDynamicSecretForm/EditDynamicSecretVertica.tsx index fa945d981..81ccbdc3c 100644 --- a/frontend/src/pages/secret-manager/SecretDashboardPage/components/DynamicSecretListView/EditDynamicSecretForm/EditDynamicSecretVertica.tsx +++ b/frontend/src/pages/secret-manager/SecretDashboardPage/components/DynamicSecretListView/EditDynamicSecretForm/EditDynamicSecretVertica.tsx @@ -1,10 +1,12 @@ 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, @@ -13,9 +15,14 @@ import { Button, FormControl, Input, - TextArea + Select, + SelectItem, + TextArea, + Tooltip } from "@app/components/v2"; -import { useUpdateDynamicSecret } from "@app/hooks/api"; +import { OrgPermissionSubjects } from "@app/context"; +import { OrgGatewayPermissionActions } from "@app/context/OrgPermissionContext/types"; +import { gatewaysQueryKeys, useUpdateDynamicSecret } from "@app/hooks/api"; import { TDynamicSecret } from "@app/hooks/api/dynamicSecret/types"; const passwordRequirementsSchema = z @@ -49,7 +56,8 @@ const formSchema = z.object({ password: z.string().min(1), passwordRequirements: passwordRequirementsSchema.optional(), creationStatement: z.string().min(1), - revocationStatement: z.string().min(1) + revocationStatement: z.string().min(1), + gatewayId: z.string().optional().nullable() }) .partial(), defaultTTL: z.string().superRefine((val, ctx) => { @@ -122,8 +130,12 @@ export const EditDynamicSecretVerticaForm = ({ } }); + const { data: gateways, isPending: isGatewaysLoading } = useQuery(gatewaysQueryKeys.list()); const updateDynamicSecret = useUpdateDynamicSecret(); + const selectedGatewayId = watch("inputs.gatewayId"); + const isGatewayInActive = gateways?.findIndex((el) => el.id === selectedGatewayId) === -1; + const handleUpdateDynamicSecret = async ({ inputs, maxTTL, @@ -143,7 +155,10 @@ export const EditDynamicSecretVerticaForm = ({ data: { maxTTL: maxTTL || undefined, defaultTTL, - inputs, + inputs: { + ...inputs, + gatewayId: isGatewayInActive ? null : inputs.gatewayId + }, newName: newName === dynamicSecret.name ? undefined : newName, usernameTemplate: !usernameTemplate || isDefaultUsernameTemplate ? null : usernameTemplate } @@ -219,6 +234,62 @@ export const EditDynamicSecretVerticaForm = ({
Configuration
+
+ + {(isAllowed) => ( + ( + + +
+ +
+
+
+ )} + /> + )} +
+