Merge pull request #3746 from Infisical/feat/kubernetes-dynamic-secret-improvements

feat: added dynamic credential support and gateway auth to k8 dynamic secret
This commit is contained in:
Sheen
2025-06-09 03:17:20 +08:00
committed by GitHub
9 changed files with 1353 additions and 327 deletions
+31 -11
View File
@@ -37,6 +37,11 @@ export enum DynamicSecretProviders {
Vertica = "vertica"
}
export enum KubernetesDynamicSecretCredentialType {
Static = "static",
Dynamic = "dynamic"
}
export enum SqlProviders {
Postgres = "postgres",
MySql = "mysql2",
@@ -283,17 +288,32 @@ export type TDynamicSecretProvider =
}
| {
type: DynamicSecretProviders.Kubernetes;
inputs: {
url: string;
clusterToken: string;
ca?: string;
serviceAccountName: string;
credentialType: "dynamic" | "static";
namespace: string;
gatewayId?: string;
sslEnabled: boolean;
audiences: string[];
};
inputs:
| {
url: string;
clusterToken?: string;
ca?: string;
serviceAccountName: string;
credentialType: KubernetesDynamicSecretCredentialType.Static;
namespace: string;
gatewayId?: string;
sslEnabled: boolean;
audiences: string[];
authMethod: string;
}
| {
url: string;
clusterToken?: string;
ca?: string;
credentialType: KubernetesDynamicSecretCredentialType.Dynamic;
namespace: string;
gatewayId?: string;
sslEnabled: boolean;
audiences: string[];
roleType: string;
role: string;
authMethod: string;
};
}
| {
type: DynamicSecretProviders.Vertica;
@@ -29,55 +29,101 @@ import {
import { OrgPermissionSubjects } from "@app/context/OrgPermissionContext";
import { OrgGatewayPermissionActions } from "@app/context/OrgPermissionContext/types";
import { gatewaysQueryKeys, useCreateDynamicSecret } from "@app/hooks/api";
import { DynamicSecretProviders } from "@app/hooks/api/dynamicSecret/types";
import {
DynamicSecretProviders,
KubernetesDynamicSecretCredentialType
} from "@app/hooks/api/dynamicSecret/types";
import { WorkspaceEnv } from "@app/hooks/api/types";
import { slugSchema } from "@app/lib/schemas";
enum CredentialType {
Dynamic = "dynamic",
Static = "static"
enum RoleType {
ClusterRole = "cluster-role",
Role = "role"
}
export enum AuthMethod {
Api = "api",
Gateway = "gateway"
}
const credentialTypes = [
{
label: "Static",
value: CredentialType.Static
value: KubernetesDynamicSecretCredentialType.Static
},
{
label: "Dynamic",
value: KubernetesDynamicSecretCredentialType.Dynamic
}
] as const;
const formSchema = z.object({
provider: z.object({
url: z.string().url().trim().min(1),
clusterToken: z.string().trim().min(1),
ca: z.string().optional(),
sslEnabled: z.boolean().default(false),
credentialType: z.literal(CredentialType.Static),
serviceAccountName: z.string().trim().min(1),
namespace: z.string().trim().min(1),
gatewayId: z.string().optional(),
audiences: z.array(z.string().trim().min(1))
}),
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 formSchema = z
.object({
provider: z.discriminatedUnion("credentialType", [
z.object({
url: z.string().url().trim().min(1),
clusterToken: z.string().trim().optional(),
ca: z.string().optional(),
sslEnabled: z.boolean().default(false),
credentialType: z.literal(KubernetesDynamicSecretCredentialType.Static),
serviceAccountName: z.string().trim().min(1),
namespace: z.string().trim().min(1),
gatewayId: z.string().optional(),
audiences: z.array(z.string().trim().min(1)),
authMethod: z.nativeEnum(AuthMethod).default(AuthMethod.Api)
}),
z.object({
url: z.string().url().trim().min(1),
clusterToken: z.string().trim().optional(),
ca: z.string().optional(),
sslEnabled: z.boolean().default(false),
credentialType: z.literal(KubernetesDynamicSecretCredentialType.Dynamic),
namespace: z.string().trim().min(1),
gatewayId: z.string().optional(),
audiences: z.array(z.string().trim().min(1)),
roleType: z.nativeEnum(RoleType),
role: z.string().trim().min(1),
authMethod: z.nativeEnum(AuthMethod).default(AuthMethod.Api)
})
]),
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" });
}),
name: slugSchema(),
environment: z.object({ name: z.string(), slug: z.string() })
});
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: slugSchema(),
environment: z.object({ name: z.string(), slug: z.string() }),
usernameTemplate: z.string().trim().optional()
})
.superRefine((data, ctx) => {
if (data.provider.authMethod === AuthMethod.Gateway && !data.provider.gatewayId) {
ctx.addIssue({
path: ["provider.gatewayId"],
code: z.ZodIssueCode.custom,
message: "When auth method is set to Gateway, a gateway must be selected"
});
}
if (data.provider.authMethod === AuthMethod.Api && !data.provider.clusterToken) {
ctx.addIssue({
path: ["provider.clusterToken"],
code: z.ZodIssueCode.custom,
message: "When auth method is set to Token, a cluster token must be provided"
});
}
});
type TForm = z.infer<typeof formSchema> & FieldValues;
@@ -113,10 +159,11 @@ export const KubernetesInputForm = ({
sslEnabled: false,
serviceAccountName: "",
namespace: "",
credentialType: CredentialType.Static,
credentialType: KubernetesDynamicSecretCredentialType.Static,
gatewayId: undefined,
audiences: []
},
audiences: [],
authMethod: AuthMethod.Api
} as const,
environment: isSingleEnvironmentMode ? environments[0] : undefined
}
});
@@ -130,12 +177,16 @@ export const KubernetesInputForm = ({
const { data: gateways, isPending: isGatewaysLoading } = useQuery(gatewaysQueryKeys.list());
const sslEnabled = watch("provider.sslEnabled");
const credentialType = watch("provider.credentialType");
const authMethod = watch("provider.authMethod");
const handleCreateDynamicSecret = async (formData: TForm) => {
const { provider, ...rest } = formData;
const { provider, usernameTemplate, ...rest } = formData;
// wait till previous request is finished
if (createDynamicSecret.isPending) return;
try {
const isDefaultUsernameTemplate = usernameTemplate === "{{randomUsername}}";
await createDynamicSecret.mutateAsync({
provider: { type: DynamicSecretProviders.Kubernetes, inputs: provider },
maxTTL: rest.maxTTL,
@@ -143,7 +194,9 @@ export const KubernetesInputForm = ({
path: secretPath,
defaultTTL: rest.defaultTTL,
projectSlug,
environmentSlug: rest.environment.slug
environmentSlug: rest.environment.slug,
usernameTemplate:
!usernameTemplate || isDefaultUsernameTemplate ? undefined : usernameTemplate
});
onCompleted();
@@ -343,20 +396,45 @@ export const KubernetesInputForm = ({
</FormControl>
)}
/>
<Controller
control={control}
name="provider.clusterToken"
name="provider.authMethod"
defaultValue={AuthMethod.Api}
render={({ field, fieldState: { error } }) => (
<FormControl
label="Cluster Token"
label="Auth Method"
isError={Boolean(error?.message)}
errorText={error?.message}
className="w-full"
tooltipText="Select the method of authentication. Token (API) uses a direct API token, while Gateway uses the service account of a Gateway deployed in a Kubernetes cluster to generate the service account token."
>
<Input {...field} type="password" autoComplete="new-password" />
<Select
defaultValue={field.value}
{...field}
className="w-full"
onValueChange={(e) => field.onChange(e)}
>
<SelectItem value={AuthMethod.Api}>Token (API)</SelectItem>
<SelectItem value={AuthMethod.Gateway}>Gateway</SelectItem>
</Select>
</FormControl>
)}
/>
{authMethod === AuthMethod.Api && (
<Controller
control={control}
name="provider.clusterToken"
render={({ field, fieldState: { error } }) => (
<FormControl
label="Cluster Token"
isError={Boolean(error?.message)}
errorText={error?.message}
>
<Input {...field} type="password" autoComplete="new-password" />
</FormControl>
)}
/>
)}
<Controller
control={control}
name="provider.credentialType"
@@ -366,6 +444,7 @@ export const KubernetesInputForm = ({
isError={Boolean(error?.message)}
errorText={error?.message}
className="w-full"
tooltipText="Choose 'Static' to generate service account tokens for a predefined service account. Choose 'Dynamic' to create a temporary service account, assign it to a defined role/cluster-role, and generate the service account token. Only 'Dynamic' supports role assignment."
>
<Select
defaultValue={field.value}
@@ -373,12 +452,9 @@ export const KubernetesInputForm = ({
className="w-full"
onValueChange={(e) => field.onChange(e)}
>
{credentialTypes.map((credentialType) => (
<SelectItem
value={credentialType.value}
key={`credential-type-${credentialType.value}`}
>
{credentialType.label}
{credentialTypes.map((ct) => (
<SelectItem value={ct.value} key={`credential-type-${ct.value}`}>
{ct.label}
</SelectItem>
))}
</Select>
@@ -386,21 +462,45 @@ export const KubernetesInputForm = ({
)}
/>
<div className="flex items-center space-x-2">
<div className="flex-1">
<Controller
control={control}
name="provider.serviceAccountName"
render={({ field, fieldState: { error } }) => (
<FormControl
label="Service Account Name"
isError={Boolean(error?.message)}
errorText={error?.message}
>
<Input {...field} autoComplete="new-password" />
</FormControl>
)}
/>
</div>
{credentialType === KubernetesDynamicSecretCredentialType.Static && (
<div className="flex-1">
<Controller
control={control}
name="provider.serviceAccountName"
render={({ field, fieldState: { error } }) => (
<FormControl
label="Service Account Name"
isError={Boolean(error?.message)}
errorText={error?.message}
>
<Input {...field} autoComplete="new-password" />
</FormControl>
)}
/>
</div>
)}
{credentialType === KubernetesDynamicSecretCredentialType.Dynamic && (
<div className="flex-1">
<Controller
control={control}
name="usernameTemplate"
defaultValue="{{randomUsername}}"
render={({ field, fieldState: { error } }) => (
<FormControl
label="Username Template"
isError={Boolean(error?.message)}
errorText={error?.message}
>
<Input
{...field}
value={field.value || undefined}
className="border-mineshaft-600 bg-mineshaft-900 text-sm"
/>
</FormControl>
)}
/>
</div>
)}
<div className="flex-1">
<Controller
control={control}
@@ -417,6 +517,56 @@ export const KubernetesInputForm = ({
/>
</div>
</div>
{credentialType === KubernetesDynamicSecretCredentialType.Dynamic && (
<div className="flex items-center space-x-2">
<div className="flex-1">
<Controller
control={control}
name="provider.roleType"
defaultValue={RoleType.ClusterRole}
render={({ field, fieldState: { error } }) => (
<FormControl
label="Role Type"
isError={Boolean(error?.message)}
errorText={error?.message}
>
<Select
defaultValue={field.value}
{...field}
className="w-full"
onValueChange={(e) => field.onChange(e)}
>
<SelectItem
value={RoleType.ClusterRole}
key={`role-type-${RoleType.ClusterRole}`}
>
Cluster Role
</SelectItem>
<SelectItem value={RoleType.Role} key={`role-type-${RoleType.Role}`}>
Role
</SelectItem>
</Select>
</FormControl>
)}
/>
</div>
<div className="flex-1">
<Controller
control={control}
name="provider.role"
render={({ field, fieldState: { error } }) => (
<FormControl
label="Role"
isError={Boolean(error?.message)}
errorText={error?.message}
>
<Input {...field} />
</FormControl>
)}
/>
</div>
</div>
)}
<div className="mt-2 w-1/2">
<Controller
control={control}
@@ -28,53 +28,99 @@ import {
import { OrgPermissionSubjects } from "@app/context/OrgPermissionContext";
import { OrgGatewayPermissionActions } from "@app/context/OrgPermissionContext/types";
import { gatewaysQueryKeys, useUpdateDynamicSecret } from "@app/hooks/api";
import { TDynamicSecret } from "@app/hooks/api/dynamicSecret/types";
import {
KubernetesDynamicSecretCredentialType,
TDynamicSecret
} from "@app/hooks/api/dynamicSecret/types";
import { slugSchema } from "@app/lib/schemas";
enum CredentialType {
Dynamic = "dynamic",
Static = "static"
enum RoleType {
ClusterRole = "cluster-role",
Role = "role"
}
enum AuthMethod {
Api = "api",
Gateway = "gateway"
}
const credentialTypes = [
{
label: "Static",
value: CredentialType.Static
value: KubernetesDynamicSecretCredentialType.Static
},
{
label: "Dynamic",
value: KubernetesDynamicSecretCredentialType.Dynamic
}
] as const;
const formSchema = z.object({
inputs: z.object({
url: z.string().url().trim().min(1),
clusterToken: z.string().trim().min(1),
ca: z.string().optional(),
sslEnabled: z.boolean().default(false),
credentialType: z.literal(CredentialType.Static),
serviceAccountName: z.string().trim().min(1),
namespace: z.string().trim().min(1),
gatewayId: z.string().optional(),
audiences: z.array(z.string().trim().min(1))
}),
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 formSchema = z
.object({
inputs: z.discriminatedUnion("credentialType", [
z.object({
url: z.string().url().trim().min(1),
clusterToken: z.string().trim().optional(),
ca: z.string().optional(),
sslEnabled: z.boolean().default(false),
credentialType: z.literal(KubernetesDynamicSecretCredentialType.Static),
serviceAccountName: z.string().trim().min(1),
namespace: z.string().trim().min(1),
gatewayId: z.string().optional(),
audiences: z.array(z.string().trim().min(1)),
authMethod: z.nativeEnum(AuthMethod).default(AuthMethod.Api)
}),
z.object({
url: z.string().url().trim().min(1),
clusterToken: z.string().trim().optional(),
ca: z.string().optional(),
sslEnabled: z.boolean().default(false),
credentialType: z.literal(KubernetesDynamicSecretCredentialType.Dynamic),
namespace: z.string().trim().min(1),
gatewayId: z.string().optional(),
audiences: z.array(z.string().trim().min(1)),
roleType: z.nativeEnum(RoleType),
role: z.string().trim().min(1),
authMethod: z.nativeEnum(AuthMethod).default(AuthMethod.Api)
})
]),
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" });
}),
newName: slugSchema().optional()
});
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" });
}),
newName: slugSchema().optional(),
usernameTemplate: z.string().trim().optional()
})
.superRefine((data, ctx) => {
if (data.inputs.authMethod === AuthMethod.Gateway && !data.inputs.gatewayId) {
ctx.addIssue({
path: ["inputs.gatewayId"],
code: z.ZodIssueCode.custom,
message: "When auth method is set to Gateway, a gateway must be selected"
});
}
if (data.inputs.authMethod === AuthMethod.Api && !data.inputs.clusterToken) {
ctx.addIssue({
path: ["inputs.clusterToken"],
code: z.ZodIssueCode.custom,
message: "When auth method is set to Token, a cluster token must be provided"
});
}
});
type TForm = z.infer<typeof formSchema> & FieldValues;
@@ -103,6 +149,7 @@ export const EditDynamicSecretKubernetesForm = ({
values: {
newName: dynamicSecret.name,
defaultTTL: dynamicSecret.defaultTTL,
usernameTemplate: dynamicSecret?.usernameTemplate || "{{randomUsername}}",
maxTTL: dynamicSecret.maxTTL,
inputs: dynamicSecret.inputs as TForm["inputs"]
}
@@ -110,17 +157,20 @@ export const EditDynamicSecretKubernetesForm = ({
const { fields, append, remove } = useFieldArray({
control,
name: "inputs.audiences" as const
name: "inputs.audiences"
});
const updateDynamicSecret = useUpdateDynamicSecret();
const { data: gateways, isPending: isGatewaysLoading } = useQuery(gatewaysQueryKeys.list());
const sslEnabled = watch("inputs.sslEnabled");
const credentialType = watch("inputs.credentialType");
const authMethod = watch("inputs.authMethod");
const handleUpdateDynamicSecret = async (formData: TForm) => {
// wait till previous request is finished
if (updateDynamicSecret.isPending) return;
const isDefaultUsernameTemplate = formData.usernameTemplate === "{{randomUsername}}";
try {
await updateDynamicSecret.mutateAsync({
name: dynamicSecret.name,
@@ -131,9 +181,14 @@ export const EditDynamicSecretKubernetesForm = ({
inputs: formData.inputs,
newName: formData.newName === dynamicSecret.name ? undefined : formData.newName,
defaultTTL: formData.defaultTTL,
maxTTL: formData.maxTTL
maxTTL: formData.maxTTL,
usernameTemplate:
!formData.usernameTemplate || isDefaultUsernameTemplate
? null
: formData.usernameTemplate
}
});
onClose();
createNotification({
type: "success",
@@ -339,17 +394,43 @@ export const EditDynamicSecretKubernetesForm = ({
<Controller
control={control}
name="inputs.clusterToken"
name="inputs.authMethod"
defaultValue={AuthMethod.Api}
render={({ field, fieldState: { error } }) => (
<FormControl
label="Cluster Token"
label="Auth Method"
isError={Boolean(error?.message)}
errorText={error?.message}
className="w-full"
tooltipText="Select the method of authentication. Token (API) uses a direct API token, while Gateway uses the service account of a Gateway deployed in a Kubernetes cluster to generate the service account token."
>
<Input {...field} type="password" autoComplete="new-password" />
<Select
defaultValue={field.value}
{...field}
className="w-full"
onValueChange={(e) => field.onChange(e)}
>
<SelectItem value={AuthMethod.Api}>Token (API)</SelectItem>
<SelectItem value={AuthMethod.Gateway}>Gateway</SelectItem>
</Select>
</FormControl>
)}
/>
{authMethod === AuthMethod.Api && (
<Controller
control={control}
name="inputs.clusterToken"
render={({ field, fieldState: { error } }) => (
<FormControl
label="Cluster Token"
isError={Boolean(error?.message)}
errorText={error?.message}
>
<Input {...field} type="password" autoComplete="new-password" />
</FormControl>
)}
/>
)}
<Controller
control={control}
name="inputs.credentialType"
@@ -359,6 +440,7 @@ export const EditDynamicSecretKubernetesForm = ({
isError={Boolean(error?.message)}
errorText={error?.message}
className="w-full"
tooltipText="Choose 'Static' to generate service account tokens for a predefined service account. Choose 'Dynamic' to create a temporary service account, assign it to a defined role/cluster-role, and generate the service account token. Only 'Dynamic' supports role assignment."
>
<Select
defaultValue={field.value}
@@ -366,12 +448,9 @@ export const EditDynamicSecretKubernetesForm = ({
className="w-full"
onValueChange={(e) => field.onChange(e)}
>
{credentialTypes.map((credentialType) => (
<SelectItem
value={credentialType.value}
key={`credential-type-${credentialType.value}`}
>
{credentialType.label}
{credentialTypes.map((ct) => (
<SelectItem value={ct.value} key={`credential-type-${ct.value}`}>
{ct.label}
</SelectItem>
))}
</Select>
@@ -379,21 +458,44 @@ export const EditDynamicSecretKubernetesForm = ({
)}
/>
<div className="flex items-center space-x-2">
<div className="flex-1">
<Controller
control={control}
name="inputs.serviceAccountName"
render={({ field, fieldState: { error } }) => (
<FormControl
label="Service Account Name"
isError={Boolean(error?.message)}
errorText={error?.message}
>
<Input {...field} autoComplete="new-password" />
</FormControl>
)}
/>
</div>
{credentialType === KubernetesDynamicSecretCredentialType.Static && (
<div className="flex-1">
<Controller
control={control}
name="inputs.serviceAccountName"
render={({ field, fieldState: { error } }) => (
<FormControl
label="Service Account Name"
isError={Boolean(error?.message)}
errorText={error?.message}
>
<Input {...field} autoComplete="new-password" />
</FormControl>
)}
/>
</div>
)}
{credentialType === KubernetesDynamicSecretCredentialType.Dynamic && (
<div className="flex-1">
<Controller
control={control}
name="usernameTemplate"
render={({ field, fieldState: { error } }) => (
<FormControl
label="Username Template"
isError={Boolean(error?.message)}
errorText={error?.message}
>
<Input
{...field}
value={field.value || undefined}
className="border-mineshaft-600 bg-mineshaft-900 text-sm"
/>
</FormControl>
)}
/>
</div>
)}
<div className="flex-1">
<Controller
control={control}
@@ -410,6 +512,59 @@ export const EditDynamicSecretKubernetesForm = ({
/>
</div>
</div>
{credentialType === KubernetesDynamicSecretCredentialType.Dynamic && (
<div className="flex items-center space-x-2">
<div className="flex-1">
<Controller
control={control}
name="inputs.roleType"
defaultValue={RoleType.ClusterRole}
render={({ field, fieldState: { error } }) => (
<FormControl
label="Role Type"
isError={Boolean(error?.message)}
errorText={error?.message}
>
<Select
defaultValue={field.value}
{...field}
className="w-full"
onValueChange={(e) => field.onChange(e)}
>
<SelectItem
value={RoleType.ClusterRole}
key={`role-type-${RoleType.ClusterRole}`}
>
Cluster Role
</SelectItem>
<SelectItem
value={RoleType.Role}
key={`role-type-${RoleType.Role}`}
>
Role
</SelectItem>
</Select>
</FormControl>
)}
/>
</div>
<div className="flex-1">
<Controller
control={control}
name="inputs.role"
render={({ field, fieldState: { error } }) => (
<FormControl
label="Role"
isError={Boolean(error?.message)}
errorText={error?.message}
>
<Input {...field} />
</FormControl>
)}
/>
</div>
</div>
)}
</div>
</div>
<div className="mt-2 w-1/2">