feat(dynamic-secret): Vertica option improvements

This commit is contained in:
carlosmonastyrski
2025-06-03 10:45:58 -03:00
parent ce67e5f137
commit ce5712606f
7 changed files with 285 additions and 84 deletions
@@ -42,5 +42,5 @@ export const buildDynamicSecretProviders = ({
[DynamicSecretProviders.Totp]: TotpProvider(), [DynamicSecretProviders.Totp]: TotpProvider(),
[DynamicSecretProviders.SapAse]: SapAseProvider(), [DynamicSecretProviders.SapAse]: SapAseProvider(),
[DynamicSecretProviders.Kubernetes]: KubernetesProvider({ gatewayService }), [DynamicSecretProviders.Kubernetes]: KubernetesProvider({ gatewayService }),
[DynamicSecretProviders.Vertica]: VerticaProvider() [DynamicSecretProviders.Vertica]: VerticaProvider({ gatewayService })
}); });
@@ -300,6 +300,7 @@ export const DynamicSecretVerticaSchema = z.object({
username: z.string().trim(), username: z.string().trim(),
password: z.string().trim(), password: z.string().trim(),
database: z.string().trim(), database: z.string().trim(),
gatewayId: z.string().nullable().optional(),
creationStatement: z.string().trim(), creationStatement: z.string().trim(),
revocationStatement: z.string().trim(), revocationStatement: z.string().trim(),
passwordRequirements: z passwordRequirements: z
@@ -4,13 +4,17 @@ import knex, { Knex } from "knex";
import { z } from "zod"; import { z } from "zod";
import { BadRequestError } from "@app/lib/errors"; import { BadRequestError } from "@app/lib/errors";
import { withGatewayProxy } from "@app/lib/gateway";
import { logger } from "@app/lib/logger"; import { logger } from "@app/lib/logger";
import { alphaNumericNanoId } from "@app/lib/nanoid"; import { alphaNumericNanoId } from "@app/lib/nanoid";
import { validateHandlebarTemplate } from "@app/lib/template/validate-handlebars"; import { validateHandlebarTemplate } from "@app/lib/template/validate-handlebars";
import { TGatewayServiceFactory } from "../../gateway/gateway-service";
import { verifyHostInputValidity } from "../dynamic-secret-fns"; import { verifyHostInputValidity } from "../dynamic-secret-fns";
import { DynamicSecretVerticaSchema, PasswordRequirements, TDynamicProviderFns } from "./models"; import { DynamicSecretVerticaSchema, PasswordRequirements, TDynamicProviderFns } from "./models";
const EXTERNAL_REQUEST_TIMEOUT = 10 * 1000;
interface VersionResult { interface VersionResult {
version: string; version: string;
} }
@@ -125,11 +129,15 @@ const generateUsername = (usernameTemplate?: string | null) => {
}); });
}; };
export const VerticaProvider = (): TDynamicProviderFns => { type TVerticaProviderDTO = {
gatewayService: Pick<TGatewayServiceFactory, "fnGetGatewayClientTlsByGatewayId">;
};
export const VerticaProvider = ({ gatewayService }: TVerticaProviderDTO): TDynamicProviderFns => {
const validateProviderInputs = async (inputs: unknown) => { const validateProviderInputs = async (inputs: unknown) => {
const providerInputs = await DynamicSecretVerticaSchema.parseAsync(inputs); 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, { validateHandlebarTemplate("Vertica creation", providerInputs.creationStatement, {
allowedExpressions: (val) => ["username", "password"].includes(val) allowedExpressions: (val) => ["username", "password"].includes(val)
}); });
@@ -152,6 +160,7 @@ export const VerticaProvider = (): TDynamicProviderFns => {
password: providerInputs.password, password: providerInputs.password,
ssl: false ssl: false
}, },
acquireConnectionTimeout: EXTERNAL_REQUEST_TIMEOUT,
pool: { pool: {
min: 0, min: 0,
max: 1, max: 1,
@@ -176,27 +185,65 @@ export const VerticaProvider = (): TDynamicProviderFns => {
return client; return client;
}; };
const gatewayProxyWrapper = async (
providerInputs: z.infer<typeof DynamicSecretVerticaSchema>,
gatewayCallback: (host: string, port: number) => Promise<void>
) => {
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 validateConnection = async (inputs: unknown) => {
const providerInputs = await validateProviderInputs(inputs); const providerInputs = await validateProviderInputs(inputs);
let client: VerticaKnexClient | null = null; let isConnected = false;
try { const gatewayCallback = async (host = providerInputs.hostIp, port = providerInputs.port) => {
client = await $getClient(providerInputs); 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) { const resultFromSelectedDatabase = clientResult.rows?.[0] as VersionResult | undefined;
throw new BadRequestError({
message: "Failed to validate Vertica connection, version query failed" 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; if (providerInputs.gatewayId) {
} finally { await gatewayProxyWrapper(providerInputs, gatewayCallback);
if (client) await client.destroy(); } else {
await gatewayCallback();
} }
return isConnected;
}; };
const create = async (data: { inputs: unknown; usernameTemplate?: string | null }) => { const create = async (data: { inputs: unknown; usernameTemplate?: string | null }) => {
@@ -206,87 +253,103 @@ export const VerticaProvider = (): TDynamicProviderFns => {
const username = generateUsername(usernameTemplate); const username = generateUsername(usernameTemplate);
const password = generatePassword(providerInputs.passwordRequirements); 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 { try {
client = await $getClient(providerInputs); client = await $getClient({ ...providerInputs, hostIp: host, port });
const creationStatement = handlebars.compile(providerInputs.creationStatement, { noEscape: true })({ const creationStatement = handlebars.compile(providerInputs.creationStatement, { noEscape: true })({
username, username,
password 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 // Execute queries sequentially to maintain transaction integrity
for (const query of queries) { for (const query of queries) {
const trimmedQuery = query.trim(); const trimmedQuery = query.trim();
if (trimmedQuery) { if (trimmedQuery) {
// eslint-disable-next-line no-await-in-loop // eslint-disable-next-line no-await-in-loop
await client.raw(trimmedQuery); await client.raw(trimmedQuery);
}
} }
} finally {
if (client) await client.destroy();
} }
};
return { entityId: username, data: { DB_USERNAME: username, DB_PASSWORD: password } }; if (providerInputs.gatewayId) {
} finally { await gatewayProxyWrapper(providerInputs, gatewayCallback);
if (client) await client.destroy(); } else {
await gatewayCallback();
} }
return { entityId: username, data: { DB_USERNAME: username, DB_PASSWORD: password } };
}; };
const revoke = async (inputs: unknown, username: string) => { const revoke = async (inputs: unknown, username: string) => {
const providerInputs = await validateProviderInputs(inputs); 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 { try {
const sessionResult: DatabaseQueryResult = await client.raw( client = await $getClient({ ...providerInputs, hostIp: host, port });
"SELECT session_id FROM sessions WHERE user_name = ?",
[username]
);
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 const queries = revokeStatement.trim().replaceAll("\n", "").split(";").filter(Boolean);
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); // 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 // Execute revocation queries sequentially to maintain transaction integrity
for (const query of queries) { for (const query of queries) {
const trimmedQuery = query.trim(); const trimmedQuery = query.trim();
if (trimmedQuery) { if (trimmedQuery) {
// eslint-disable-next-line no-await-in-loop // eslint-disable-next-line no-await-in-loop
await client.raw(trimmedQuery); await client.raw(trimmedQuery);
}
} }
} finally {
if (client) await client.destroy();
} }
};
return { entityId: username }; if (providerInputs.gatewayId) {
} finally { await gatewayProxyWrapper(providerInputs, gatewayCallback);
if (client) await client.destroy(); } else {
await gatewayCallback();
} }
return { entityId: username };
}; };
const renew = async (_: unknown, username: string) => { const renew = async (_: unknown, username: string) => {
@@ -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 Maximum time-to-live for a generated secret
</ParamField> </ParamField>
<ParamField path="Gateway" type="string">
Select a gateway for private cluster access. If not specified, the Internet Gateway will be used.
</ParamField>
<ParamField path="Host" type="string" required> <ParamField path="Host" type="string" required>
Vertica database host Vertica database host
</ParamField> </ParamField>
@@ -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) ![Provision Lease](/images/platform/dynamic-secrets/provision-lease.png)
<Tip> <Tip>
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.
</Tip> </Tip>
Once you click the `Submit` button, a new secret lease will be generated and the credentials for it will be shown to you. 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 ## 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. 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) ![Provision Lease](/images/platform/dynamic-secrets/lease-data.png)
Binary file not shown.

Before

Width:  |  Height:  |  Size: 553 KiB

After

Width:  |  Height:  |  Size: 187 KiB

@@ -1,10 +1,12 @@
import { Controller, useForm } from "react-hook-form"; import { Controller, useForm } from "react-hook-form";
import { zodResolver } from "@hookform/resolvers/zod"; import { zodResolver } from "@hookform/resolvers/zod";
import { useQuery } from "@tanstack/react-query";
import ms from "ms"; import ms from "ms";
import { z } from "zod"; import { z } from "zod";
import { TtlFormLabel } from "@app/components/features"; import { TtlFormLabel } from "@app/components/features";
import { createNotification } from "@app/components/notifications"; import { createNotification } from "@app/components/notifications";
import { OrgPermissionCan } from "@app/components/permissions";
import { import {
Accordion, Accordion,
AccordionContent, AccordionContent,
@@ -14,9 +16,16 @@ import {
FilterableSelect, FilterableSelect,
FormControl, FormControl,
Input, Input,
TextArea Select,
SelectItem,
TextArea,
Tooltip
} from "@app/components/v2"; } 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 { DynamicSecretProviders } from "@app/hooks/api/dynamicSecret/types";
import { WorkspaceEnv } from "@app/hooks/api/types"; import { WorkspaceEnv } from "@app/hooks/api/types";
@@ -50,7 +59,8 @@ const formSchema = z.object({
password: z.string().min(1), password: z.string().min(1),
passwordRequirements: passwordRequirementsSchema.optional(), passwordRequirements: passwordRequirementsSchema.optional(),
creationStatement: z.string().min(1), 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) => { defaultTTL: z.string().superRefine((val, ctx) => {
const valMs = ms(val); const valMs = ms(val);
@@ -124,6 +134,7 @@ GRANT CREATE ON SCHEMA public TO {{username}};`,
}); });
const createDynamicSecret = useCreateDynamicSecret(); const createDynamicSecret = useCreateDynamicSecret();
const { data: gateways, isPending: isGatewaysLoading } = useQuery(gatewaysQueryKeys.list());
const handleCreateDynamicSecret = async ({ const handleCreateDynamicSecret = async ({
name, name,
@@ -184,7 +195,7 @@ GRANT CREATE ON SCHEMA public TO {{username}};`,
defaultValue="1h" defaultValue="1h"
render={({ field, fieldState: { error } }) => ( render={({ field, fieldState: { error } }) => (
<FormControl <FormControl
label={<TtlFormLabel label="Max TTL" />} label={<TtlFormLabel label="Default TTL" />}
isError={Boolean(error?.message)} isError={Boolean(error?.message)}
errorText={error?.message} errorText={error?.message}
> >
@@ -200,7 +211,7 @@ GRANT CREATE ON SCHEMA public TO {{username}};`,
defaultValue="24h" defaultValue="24h"
render={({ field, fieldState: { error } }) => ( render={({ field, fieldState: { error } }) => (
<FormControl <FormControl
label={<TtlFormLabel label="Default TTL" />} label={<TtlFormLabel label="Max TTL" />}
isError={Boolean(error?.message)} isError={Boolean(error?.message)}
errorText={error?.message} errorText={error?.message}
> >
@@ -214,6 +225,57 @@ GRANT CREATE ON SCHEMA public TO {{username}};`,
<div className="mb-4 mt-4 border-b border-mineshaft-500 pb-2 pl-1 font-medium text-mineshaft-200"> <div className="mb-4 mt-4 border-b border-mineshaft-500 pb-2 pl-1 font-medium text-mineshaft-200">
Configuration Configuration
</div> </div>
<div>
<OrgPermissionCan
I={OrgGatewayPermissionActions.AttachGateways}
a={OrgPermissionSubjects.Gateway}
>
{(isAllowed) => (
<Controller
control={control}
name="provider.gatewayId"
defaultValue=""
render={({ field: { value, onChange }, fieldState: { error } }) => (
<FormControl
isError={Boolean(error?.message)}
errorText={error?.message}
label="Gateway"
>
<Tooltip
isDisabled={isAllowed}
content="Restricted access. You don't have permission to attach gateways to resources."
>
<div>
<Select
isDisabled={!isAllowed}
value={value}
onValueChange={onChange}
className="w-full border border-mineshaft-500"
dropdownContainerClassName="max-w-none"
isLoading={isGatewaysLoading}
placeholder="Default: Internet Gateway"
position="popper"
>
<SelectItem
value={null as unknown as string}
onClick={() => onChange(undefined)}
>
Internet Gateway
</SelectItem>
{gateways?.map((el) => (
<SelectItem value={el.id} key={el.id}>
{el.name}
</SelectItem>
))}
</Select>
</div>
</Tooltip>
</FormControl>
)}
/>
)}
</OrgPermissionCan>
</div>
<div className="flex flex-col"> <div className="flex flex-col">
<div className="flex items-center space-x-2"> <div className="flex items-center space-x-2">
<Controller <Controller
@@ -227,7 +289,7 @@ GRANT CREATE ON SCHEMA public TO {{username}};`,
isError={Boolean(error?.message)} isError={Boolean(error?.message)}
errorText={error?.message} errorText={error?.message}
> >
<Input placeholder="92.41.22.72" {...field} /> <Input placeholder="Vertica Host" {...field} />
</FormControl> </FormControl>
)} )}
/> />
@@ -1,10 +1,12 @@
import { Controller, useForm } from "react-hook-form"; import { Controller, useForm } from "react-hook-form";
import { zodResolver } from "@hookform/resolvers/zod"; import { zodResolver } from "@hookform/resolvers/zod";
import { useQuery } from "@tanstack/react-query";
import ms from "ms"; import ms from "ms";
import { z } from "zod"; import { z } from "zod";
import { TtlFormLabel } from "@app/components/features"; import { TtlFormLabel } from "@app/components/features";
import { createNotification } from "@app/components/notifications"; import { createNotification } from "@app/components/notifications";
import { OrgPermissionCan } from "@app/components/permissions";
import { import {
Accordion, Accordion,
AccordionContent, AccordionContent,
@@ -13,9 +15,14 @@ import {
Button, Button,
FormControl, FormControl,
Input, Input,
TextArea Select,
SelectItem,
TextArea,
Tooltip
} from "@app/components/v2"; } 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"; import { TDynamicSecret } from "@app/hooks/api/dynamicSecret/types";
const passwordRequirementsSchema = z const passwordRequirementsSchema = z
@@ -49,7 +56,8 @@ const formSchema = z.object({
password: z.string().min(1), password: z.string().min(1),
passwordRequirements: passwordRequirementsSchema.optional(), passwordRequirements: passwordRequirementsSchema.optional(),
creationStatement: z.string().min(1), creationStatement: z.string().min(1),
revocationStatement: z.string().min(1) revocationStatement: z.string().min(1),
gatewayId: z.string().optional().nullable()
}) })
.partial(), .partial(),
defaultTTL: z.string().superRefine((val, ctx) => { 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 updateDynamicSecret = useUpdateDynamicSecret();
const selectedGatewayId = watch("inputs.gatewayId");
const isGatewayInActive = gateways?.findIndex((el) => el.id === selectedGatewayId) === -1;
const handleUpdateDynamicSecret = async ({ const handleUpdateDynamicSecret = async ({
inputs, inputs,
maxTTL, maxTTL,
@@ -143,7 +155,10 @@ export const EditDynamicSecretVerticaForm = ({
data: { data: {
maxTTL: maxTTL || undefined, maxTTL: maxTTL || undefined,
defaultTTL, defaultTTL,
inputs, inputs: {
...inputs,
gatewayId: isGatewayInActive ? null : inputs.gatewayId
},
newName: newName === dynamicSecret.name ? undefined : newName, newName: newName === dynamicSecret.name ? undefined : newName,
usernameTemplate: !usernameTemplate || isDefaultUsernameTemplate ? null : usernameTemplate usernameTemplate: !usernameTemplate || isDefaultUsernameTemplate ? null : usernameTemplate
} }
@@ -219,6 +234,62 @@ export const EditDynamicSecretVerticaForm = ({
<div className="mb-4 mt-4 border-b border-mineshaft-500 pb-2 pl-1 font-medium text-mineshaft-200"> <div className="mb-4 mt-4 border-b border-mineshaft-500 pb-2 pl-1 font-medium text-mineshaft-200">
Configuration Configuration
</div> </div>
<div>
<OrgPermissionCan
I={OrgGatewayPermissionActions.AttachGateways}
a={OrgPermissionSubjects.Gateway}
>
{(isAllowed) => (
<Controller
control={control}
name="inputs.gatewayId"
defaultValue=""
render={({ field: { value, onChange }, fieldState: { error } }) => (
<FormControl
isError={Boolean(error?.message) || isGatewayInActive}
errorText={
isGatewayInActive && selectedGatewayId
? `Project Gateway ${selectedGatewayId} is removed`
: error?.message
}
label="Gateway"
helperText=""
>
<Tooltip
isDisabled={isAllowed}
content="Restricted access. You don't have permission to attach gateways to resources."
>
<div>
<Select
isDisabled={!isAllowed}
value={value || undefined}
onValueChange={onChange}
className="w-full border border-mineshaft-500"
dropdownContainerClassName="max-w-none"
isLoading={isGatewaysLoading}
placeholder="Default: Internet Gateway"
position="popper"
>
<SelectItem
value={null as unknown as string}
onClick={() => onChange(undefined)}
>
Internet Gateway
</SelectItem>
{gateways?.map((el) => (
<SelectItem value={el.id} key={el.id}>
{el.name}
</SelectItem>
))}
</Select>
</div>
</Tooltip>
</FormControl>
)}
/>
)}
</OrgPermissionCan>
</div>
<div className="flex flex-col"> <div className="flex flex-col">
<div className="flex items-center space-x-2"> <div className="flex items-center space-x-2">
<Controller <Controller