Merge pull request #3552 from Infisical/ENG-2705

feat(dynamic-secrets): GCP IAM
This commit is contained in:
x032205
2025-06-12 18:01:17 -04:00
committed by GitHub
40 changed files with 844 additions and 76 deletions
@@ -34,7 +34,8 @@ export enum DynamicSecretProviders {
Totp = "totp",
SapAse = "sap-ase",
Kubernetes = "kubernetes",
Vertica = "vertica"
Vertica = "vertica",
GcpIam = "gcp-iam"
}
export enum KubernetesDynamicSecretCredentialType {
@@ -326,6 +327,12 @@ export type TDynamicSecretProvider =
creationStatement: string;
revocationStatement: string;
};
}
| {
type: DynamicSecretProviders.GcpIam;
inputs: {
serviceAccountEmail: string;
};
};
export type TCreateDynamicSecretDTO = {
@@ -11,7 +11,7 @@ import {
SiSnowflake
} from "react-icons/si";
import { VscAzure } from "react-icons/vsc";
import { faAws } from "@fortawesome/free-brands-svg-icons";
import { faAws, faGoogle } from "@fortawesome/free-brands-svg-icons";
import { faClock, faDatabase } from "@fortawesome/free-solid-svg-icons";
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
import { AnimatePresence, motion } from "framer-motion";
@@ -25,6 +25,7 @@ import { AwsIamInputForm } from "./AwsIamInputForm";
import { AzureEntraIdInputForm } from "./AzureEntraIdInputForm";
import { CassandraInputForm } from "./CassandraInputForm";
import { ElasticSearchInputForm } from "./ElasticSearchInputForm";
import { GcpIamInputForm } from "./GcpIamInputForm";
import { KubernetesInputForm } from "./KubernetesInputForm";
import { LdapInputForm } from "./LdapInputForm";
import { MongoAtlasInputForm } from "./MongoAtlasInputForm";
@@ -137,6 +138,11 @@ const DYNAMIC_SECRET_LIST = [
icon: <SiKubernetes size="1.5rem" />,
provider: DynamicSecretProviders.Kubernetes,
title: "Kubernetes"
},
{
icon: <FontAwesomeIcon icon={faGoogle} size="lg" />,
provider: DynamicSecretProviders.GcpIam,
title: "GCP IAM"
}
];
@@ -523,6 +529,25 @@ export const CreateDynamicSecretForm = ({
/>
</motion.div>
)}
{wizardStep === WizardSteps.ProviderInputs &&
selectedProvider === DynamicSecretProviders.GcpIam && (
<motion.div
key="dynamic-gcp-iam-step"
transition={{ duration: 0.1 }}
initial={{ opacity: 0, translateX: 30 }}
animate={{ opacity: 1, translateX: 0 }}
exit={{ opacity: 0, translateX: -30 }}
>
<GcpIamInputForm
onCompleted={handleFormReset}
onCancel={handleFormReset}
projectSlug={projectSlug}
secretPath={secretPath}
environments={environments}
isSingleEnvironmentMode={isSingleEnvironmentMode}
/>
</motion.div>
)}
</AnimatePresence>
</ModalContent>
</Modal>
@@ -0,0 +1,237 @@
import { Controller, useForm } from "react-hook-form";
import { zodResolver } from "@hookform/resolvers/zod";
import ms from "ms";
import { z } from "zod";
import { TtlFormLabel } from "@app/components/features";
import { createNotification } from "@app/components/notifications";
import { Button, FilterableSelect, FormControl, Input } from "@app/components/v2";
import { useCreateDynamicSecret } from "@app/hooks/api";
import { DynamicSecretProviders } from "@app/hooks/api/dynamicSecret/types";
import { WorkspaceEnv } from "@app/hooks/api/types";
const validateTTL = (val: string, ctx: z.RefinementCtx) => {
if (!val) return;
const valMs = ms(val);
if (valMs === undefined) {
ctx.addIssue({ code: z.ZodIssueCode.custom, message: "Invalid TTL format" });
return;
}
if (valMs < 1000)
ctx.addIssue({ code: z.ZodIssueCode.custom, message: "TTL must be a greater than 1 second" });
if (valMs > 60 * 60 * 1000)
ctx.addIssue({ code: z.ZodIssueCode.custom, message: "TTL must be less than 1 hour" });
};
const formSchema = z
.object({
provider: z.object({
serviceAccountEmail: z.string().email().trim().min(1, "Service account email required")
}),
defaultTTL: z.string().superRefine(validateTTL),
maxTTL: z
.string()
.optional()
.superRefine((val, ctx) => {
if (val) validateTTL(val, ctx);
}),
name: z.string().refine((val) => val.toLowerCase() === val, "Must be lowercase"),
environment: z.object({ name: z.string(), slug: z.string() })
})
.refine((d) => !d.maxTTL || ms(d.maxTTL)! >= ms(d.defaultTTL)!, {
path: ["maxTTL"],
message: "Max TTL must be greater than or equal to Default TTL"
});
type TForm = z.infer<typeof formSchema>;
type Props = {
onCompleted: () => void;
onCancel: () => void;
secretPath: string;
projectSlug: string;
environments: WorkspaceEnv[];
isSingleEnvironmentMode?: boolean;
};
export const GcpIamInputForm = ({
onCompleted,
onCancel,
environments,
secretPath,
projectSlug,
isSingleEnvironmentMode
}: Props) => {
const {
control,
formState: { isSubmitting },
handleSubmit
} = useForm<TForm>({
resolver: zodResolver(formSchema),
defaultValues: {
environment: isSingleEnvironmentMode && environments.length > 0 ? environments[0] : undefined
}
});
const createDynamicSecret = useCreateDynamicSecret();
const handleCreateDynamicSecret = async ({
name,
maxTTL,
provider,
defaultTTL,
environment
}: TForm) => {
// wait till previous request is finished
if (createDynamicSecret.isPending) return;
try {
await createDynamicSecret.mutateAsync({
provider: {
type: DynamicSecretProviders.GcpIam,
inputs: {
...provider
}
},
maxTTL,
name,
path: secretPath,
defaultTTL,
projectSlug,
environmentSlug: environment.slug
});
onCompleted();
} catch {
createNotification({
type: "error",
text: "Failed to create dynamic secret"
});
}
};
return (
<div>
<form onSubmit={handleSubmit(handleCreateDynamicSecret)} autoComplete="off">
<div>
<div className="flex items-center space-x-2">
<div className="flex-grow">
<Controller
control={control}
defaultValue=""
name="name"
render={({ field, fieldState: { error } }) => (
<FormControl
label="Secret Name"
isError={Boolean(error)}
errorText={error?.message}
>
<Input {...field} placeholder="dynamic-secret" />
</FormControl>
)}
/>
</div>
<div className="w-32">
<Controller
control={control}
name="defaultTTL"
defaultValue="30m"
render={({ field, fieldState: { error } }) => (
<FormControl
label={<TtlFormLabel label="Default TTL" />}
isError={Boolean(error?.message)}
errorText={error?.message}
>
<Input {...field} />
</FormControl>
)}
/>
</div>
<div className="w-32">
<Controller
control={control}
name="maxTTL"
defaultValue="1h"
render={({ field, fieldState: { error } }) => (
<FormControl
label={<TtlFormLabel label="Max TTL" />}
isError={Boolean(error?.message)}
errorText={error?.message}
>
<Input {...field} />
</FormControl>
)}
/>
</div>
</div>
<div>
<div className="mb-4 mt-4 border-b border-mineshaft-500 pb-2 pl-1 font-medium text-mineshaft-200">
Configuration
</div>
<div className="flex flex-col">
<Controller
control={control}
name="provider.serviceAccountEmail"
defaultValue=""
render={({ field, fieldState: { error } }) => (
<FormControl
label="Service Account Email"
className="flex-grow"
isError={Boolean(error?.message)}
errorText={error?.message}
helperText={
<span>
Don&apos;t know where to get this value?{" "}
<a
href="https://infisical.com/docs/documentation/platform/dynamic-secrets/gcp-iam#param-service-account-email"
target="_blank"
className="underline"
rel="noreferrer"
>
Read our docs
</a>
</span>
}
>
<Input placeholder="[email protected]" {...field} />
</FormControl>
)}
/>
</div>
{!isSingleEnvironmentMode && (
<Controller
control={control}
name="environment"
render={({ field: { value, onChange }, fieldState: { error } }) => (
<FormControl
label="Environment"
isError={Boolean(error)}
errorText={error?.message}
>
<FilterableSelect
options={environments}
value={value}
onChange={onChange}
placeholder="Select the environment to create secret in..."
getOptionLabel={(option) => option.name}
getOptionValue={(option) => option.slug}
menuPlacement="top"
/>
</FormControl>
)}
/>
)}
</div>
</div>
<div className="mt-4 flex items-center space-x-4">
<Button type="submit" isLoading={isSubmitting}>
Submit
</Button>
<Button variant="outline_bg" onClick={onCancel}>
Cancel
</Button>
</div>
</form>
</div>
);
};
@@ -350,6 +350,24 @@ const renderOutputForm = (
);
}
if (provider === DynamicSecretProviders.GcpIam) {
const { TOKEN, SERVICE_ACCOUNT_EMAIL } = data as {
SERVICE_ACCOUNT_EMAIL: string;
TOKEN: string;
};
return (
<div>
<OutputDisplay label="Service Account Email" value={SERVICE_ACCOUNT_EMAIL} />
<OutputDisplay
label="Token"
value={TOKEN}
helperText="Important: Copy these credentials now. You will not be able to see them again after you close the modal."
/>
</div>
);
}
return null;
};
@@ -252,6 +252,7 @@ export const DynamicSecretLease = ({
projectSlug={projectSlug}
leaseId={(popUp.renewSecret?.data as { leaseId: string })?.leaseId}
dynamicSecretName={dynamicSecretName}
dynamicSecret={dynamicSecret}
secretPath={secretPath}
environment={environment}
/>
@@ -9,6 +9,7 @@ import { EditDynamicSecretAwsIamForm } from "./EditDynamicSecretAwsIamForm";
import { EditDynamicSecretAzureEntraIdForm } from "./EditDynamicSecretAzureEntraIdForm";
import { EditDynamicSecretCassandraForm } from "./EditDynamicSecretCassandraForm";
import { EditDynamicSecretElasticSearchForm } from "./EditDynamicSecretElasticSearchForm";
import { EditDynamicSecretGcpIamForm } from "./EditDynamicSecretGcpIamForm";
import { EditDynamicSecretKubernetesForm } from "./EditDynamicSecretKubernetesForm";
import { EditDynamicSecretLdapForm } from "./EditDynamicSecretLdapForm";
import { EditDynamicSecretMongoAtlasForm } from "./EditDynamicSecretMongoAtlasForm";
@@ -348,6 +349,23 @@ export const EditDynamicSecretForm = ({
/>
</motion.div>
)}
{dynamicSecretDetails?.type === DynamicSecretProviders.GcpIam && (
<motion.div
key="gcp-iam-edit"
transition={{ duration: 0.1 }}
initial={{ opacity: 0, translateX: 30 }}
animate={{ opacity: 1, translateX: 0 }}
exit={{ opacity: 0, translateX: -30 }}
>
<EditDynamicSecretGcpIamForm
onClose={onClose}
projectSlug={projectSlug}
secretPath={secretPath}
dynamicSecret={dynamicSecretDetails}
environment={environment}
/>
</motion.div>
)}
</AnimatePresence>
);
};
@@ -0,0 +1,189 @@
import { Controller, useForm } from "react-hook-form";
import { zodResolver } from "@hookform/resolvers/zod";
import ms from "ms";
import { z } from "zod";
import { TtlFormLabel } from "@app/components/features";
import { createNotification } from "@app/components/notifications";
import { Button, FormControl, Input } from "@app/components/v2";
import { useUpdateDynamicSecret } from "@app/hooks/api";
import { TDynamicSecret } from "@app/hooks/api/dynamicSecret/types";
const validateTTL = (val: string, ctx: z.RefinementCtx) => {
if (!val) return;
const valMs = ms(val);
if (valMs === undefined) {
ctx.addIssue({ code: z.ZodIssueCode.custom, message: "Invalid TTL format" });
return;
}
if (valMs < 1000)
ctx.addIssue({ code: z.ZodIssueCode.custom, message: "TTL must be a greater than 1 second" });
if (valMs > 60 * 60 * 1000)
ctx.addIssue({ code: z.ZodIssueCode.custom, message: "TTL must be less than 1 hour" });
};
const formSchema = z
.object({
inputs: z.object({
serviceAccountEmail: z.string().email().trim().min(1, "Service account email required")
}),
defaultTTL: z.string().superRefine(validateTTL),
maxTTL: z
.string()
.optional()
.superRefine((val, ctx) => {
if (val) validateTTL(val, ctx);
}),
newName: z.string().refine((val) => val.toLowerCase() === val, "Must be lowercase")
})
.refine((d) => !d.maxTTL || ms(d.maxTTL)! >= ms(d.defaultTTL)!, {
path: ["maxTTL"],
message: "Max TTL must be greater than or equal to Default TTL"
});
type TForm = z.infer<typeof formSchema>;
type Props = {
onClose: () => void;
dynamicSecret: TDynamicSecret & { inputs: unknown };
secretPath: string;
environment: string;
projectSlug: string;
};
export const EditDynamicSecretGcpIamForm = ({
onClose,
dynamicSecret,
secretPath,
environment,
projectSlug
}: Props) => {
const {
control,
formState: { isSubmitting },
handleSubmit
} = useForm<TForm>({
resolver: zodResolver(formSchema),
values: {
defaultTTL: dynamicSecret.defaultTTL,
maxTTL: dynamicSecret.maxTTL,
newName: dynamicSecret.name,
inputs: {
...(dynamicSecret.inputs as TForm["inputs"])
}
}
});
const updateDynamicSecret = useUpdateDynamicSecret();
const handleUpdateDynamicSecret = async ({ inputs, maxTTL, defaultTTL, newName }: TForm) => {
// wait till previous request is finished
if (updateDynamicSecret.isPending) return;
try {
await updateDynamicSecret.mutateAsync({
name: dynamicSecret.name,
path: secretPath,
projectSlug,
environmentSlug: environment,
data: {
maxTTL: maxTTL || undefined,
defaultTTL,
inputs,
newName: newName === dynamicSecret.name ? undefined : newName
}
});
onClose();
createNotification({
type: "success",
text: "Successfully updated dynamic secret"
});
} catch {
createNotification({
type: "error",
text: "Failed to update dynamic secret"
});
}
};
return (
<div>
<form onSubmit={handleSubmit(handleUpdateDynamicSecret)} autoComplete="off">
<div>
<div className="flex items-center space-x-2">
<div className="flex-grow">
<Controller
control={control}
name="newName"
render={({ field, fieldState: { error } }) => (
<FormControl
label="Secret Name"
isError={Boolean(error)}
errorText={error?.message}
>
<Input {...field} placeholder="dynamic-secret" />
</FormControl>
)}
/>
</div>
<div className="w-32">
<Controller
control={control}
name="defaultTTL"
render={({ field, fieldState: { error } }) => (
<FormControl
label={<TtlFormLabel label="Default TTL" />}
isError={Boolean(error?.message)}
errorText={error?.message}
>
<Input {...field} />
</FormControl>
)}
/>
</div>
<div className="w-32">
<Controller
control={control}
name="maxTTL"
render={({ field, fieldState: { error } }) => (
<FormControl
label={<TtlFormLabel label="Max TTL" />}
isError={Boolean(error?.message)}
errorText={error?.message}
>
<Input {...field} />
</FormControl>
)}
/>
</div>
</div>
<div>
<div className="mb-4 mt-4 border-b border-mineshaft-500 pb-2 pl-1 font-medium text-mineshaft-200">
Configuration
</div>
<Controller
control={control}
name="inputs.serviceAccountEmail"
render={({ field, fieldState: { error } }) => (
<FormControl
label="Service Account Email"
className="flex-grow"
isError={Boolean(error?.message)}
errorText={error?.message}
>
<Input {...field} />
</FormControl>
)}
/>
</div>
</div>
<div className="mt-4 flex items-center space-x-4">
<Button type="submit" isLoading={isSubmitting}>
Submit
</Button>
<Button variant="outline_bg" onClick={onClose}>
Cancel
</Button>
</div>
</form>
</div>
);
};
@@ -7,24 +7,13 @@ import { TtlFormLabel } from "@app/components/features";
import { createNotification } from "@app/components/notifications";
import { Button, FormControl, Input } from "@app/components/v2";
import { useRenewDynamicSecretLease } from "@app/hooks/api";
const formSchema = z.object({
ttl: z.string().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" });
// a day
if (valMs > 24 * 60 * 60 * 1000)
ctx.addIssue({ code: z.ZodIssueCode.custom, message: "TTL must be less than a day" });
})
});
type TForm = z.infer<typeof formSchema>;
import { TDynamicSecret } from "@app/hooks/api/dynamicSecret/types";
type Props = {
onClose: () => void;
leaseId: string;
dynamicSecretName: string;
dynamicSecret: TDynamicSecret;
projectSlug: string;
environment: string;
secretPath: string;
@@ -36,8 +25,30 @@ export const RenewDynamicSecretLease = ({
dynamicSecretName,
leaseId,
secretPath,
environment
environment,
dynamicSecret
}: Props) => {
const maxTtlMs = ms(dynamicSecret.maxTTL);
const formSchema = z.object({
ttl: z.string().superRefine((val, ctx) => {
if (!val) return;
const valMs = ms(val);
if (valMs < 1000)
ctx.addIssue({
code: z.ZodIssueCode.custom,
message: "TTL must be greater than 1 second"
});
if (valMs > maxTtlMs)
ctx.addIssue({
code: z.ZodIssueCode.custom,
message: `TTL must be less than ${dynamicSecret.maxTTL}`
});
})
});
type TForm = z.infer<typeof formSchema>;
const {
control,
formState: { isSubmitting },
@@ -45,7 +56,7 @@ export const RenewDynamicSecretLease = ({
} = useForm<TForm>({
resolver: zodResolver(formSchema),
defaultValues: {
ttl: "1h"
ttl: dynamicSecret.defaultTTL
}
});
@@ -81,7 +92,7 @@ export const RenewDynamicSecretLease = ({
<Controller
control={control}
name="ttl"
defaultValue="1h"
defaultValue={dynamicSecret.defaultTTL}
render={({ field, fieldState: { error } }) => (
<FormControl
label={<TtlFormLabel label="TTL" />}