mirror of
https://github.com/awatertrevi/infisical.git
synced 2026-10-11 16:29:00 +00:00
feat: add in-platform migrator for kubernetes auth
This commit is contained in:
@@ -2,7 +2,11 @@ import { useQuery } from "@tanstack/react-query";
|
||||
|
||||
import { apiRequest } from "@app/config/request";
|
||||
|
||||
import { ExternalMigrationProviders, TExternalMigrationConfig } from "./types";
|
||||
import {
|
||||
ExternalMigrationProviders,
|
||||
TExternalMigrationConfig,
|
||||
VaultKubernetesAuthRole
|
||||
} from "./types";
|
||||
|
||||
export const externalMigrationQueryKeys = {
|
||||
customMigrationAvailable: (provider: ExternalMigrationProviders) => [
|
||||
@@ -13,7 +17,8 @@ export const externalMigrationQueryKeys = {
|
||||
vaultNamespaces: () => ["vault-namespaces"],
|
||||
vaultPolicies: () => ["vault-policies"],
|
||||
vaultMounts: () => ["vault-mounts"],
|
||||
vaultSecretPaths: () => ["vault-secret-paths"]
|
||||
vaultSecretPaths: () => ["vault-secret-paths"],
|
||||
vaultKubernetesAuthRoles: (namespace?: string) => ["vault-kubernetes-auth-roles", namespace]
|
||||
};
|
||||
|
||||
export const useHasCustomMigrationAvailable = (provider: ExternalMigrationProviders) => {
|
||||
@@ -107,3 +112,21 @@ export const useGetVaultSecretPaths = (enabled = true, namespace?: string) => {
|
||||
enabled
|
||||
});
|
||||
};
|
||||
|
||||
export const useGetVaultKubernetesAuthRoles = (enabled = true, namespace?: string) => {
|
||||
return useQuery({
|
||||
queryKey: externalMigrationQueryKeys.vaultKubernetesAuthRoles(namespace),
|
||||
queryFn: async () => {
|
||||
const { data } = await apiRequest.get<{
|
||||
roles: VaultKubernetesAuthRole[];
|
||||
}>("/api/v3/external-migration/vault/auth-roles/kubernetes", {
|
||||
params: {
|
||||
namespace
|
||||
}
|
||||
});
|
||||
|
||||
return data.roles;
|
||||
},
|
||||
enabled
|
||||
});
|
||||
};
|
||||
|
||||
@@ -19,3 +19,28 @@ export type TImportVaultSecretsDTO = {
|
||||
vaultNamespace: string;
|
||||
vaultSecretPath: string;
|
||||
};
|
||||
|
||||
export type VaultKubernetesAuthRole = {
|
||||
name: string;
|
||||
bound_service_account_names: string[];
|
||||
bound_service_account_namespaces: string[];
|
||||
token_ttl?: number;
|
||||
token_max_ttl?: number;
|
||||
token_policies?: string[];
|
||||
token_bound_cidrs?: string[];
|
||||
token_explicit_max_ttl?: number;
|
||||
token_no_default_policy?: boolean;
|
||||
token_num_uses?: number;
|
||||
token_period?: number;
|
||||
token_type?: string;
|
||||
audience?: string;
|
||||
alias_name_source?: string;
|
||||
mountPath: string;
|
||||
config: {
|
||||
kubernetes_host: string;
|
||||
kubernetes_ca_cert?: string;
|
||||
issuer?: string;
|
||||
disable_iss_validation?: boolean;
|
||||
disable_local_ca_jwt?: boolean;
|
||||
};
|
||||
};
|
||||
|
||||
+155
-6
@@ -1,6 +1,6 @@
|
||||
import { useEffect, useState } from "react";
|
||||
import { Controller, useFieldArray, useForm } from "react-hook-form";
|
||||
import { faPlus, faXmark } from "@fortawesome/free-solid-svg-icons";
|
||||
import { faInfoCircle, faPlus, faXmark } from "@fortawesome/free-solid-svg-icons";
|
||||
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
|
||||
import { zodResolver } from "@hookform/resolvers/zod";
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
@@ -37,9 +37,15 @@ import {
|
||||
IdentityKubernetesAuthTokenReviewMode,
|
||||
IdentityTrustedIp
|
||||
} from "@app/hooks/api/identities/types";
|
||||
import { UsePopUpState } from "@app/hooks/usePopUp";
|
||||
import { useGetExternalMigrationConfig } from "@app/hooks/api/migration/queries";
|
||||
import {
|
||||
ExternalMigrationProviders,
|
||||
VaultKubernetesAuthRole
|
||||
} from "@app/hooks/api/migration/types";
|
||||
import { usePopUp, UsePopUpState } from "@app/hooks/usePopUp";
|
||||
|
||||
import { IdentityFormTab } from "./types";
|
||||
import { VaultKubernetesAuthImportModal } from "./VaultKubernetesAuthImportModal";
|
||||
|
||||
const schema = z
|
||||
.object({
|
||||
@@ -121,6 +127,12 @@ export const IdentityKubernetesAuthForm = ({
|
||||
enabled: isUpdate
|
||||
});
|
||||
|
||||
const { popUp, handlePopUpToggle: handleImportPopUpToggle } = usePopUp([
|
||||
"importFromVault"
|
||||
] as const);
|
||||
const { data: vaultConfig } = useGetExternalMigrationConfig(ExternalMigrationProviders.Vault);
|
||||
const hasVaultConnection = Boolean(vaultConfig?.connectionId);
|
||||
|
||||
const {
|
||||
control,
|
||||
handleSubmit,
|
||||
@@ -192,6 +204,106 @@ export const IdentityKubernetesAuthForm = ({
|
||||
}
|
||||
}, [data]);
|
||||
|
||||
const handleImportFromVault = (role: VaultKubernetesAuthRole) => {
|
||||
try {
|
||||
setValue("kubernetesHost", role.config.kubernetes_host, {
|
||||
shouldDirty: true,
|
||||
shouldTouch: true,
|
||||
shouldValidate: true
|
||||
});
|
||||
|
||||
if (role.config.token_reviewer_jwt) {
|
||||
setValue("tokenReviewerJwt", role.config.token_reviewer_jwt, {
|
||||
shouldDirty: true,
|
||||
shouldTouch: true
|
||||
});
|
||||
}
|
||||
|
||||
if (role.bound_service_account_names?.length > 0) {
|
||||
// In Vault, "*" means allow all; in Infisical, empty field means allow any
|
||||
const allowedNames = role.bound_service_account_names.includes("*")
|
||||
? ""
|
||||
: role.bound_service_account_names.join(", ");
|
||||
setValue("allowedNames", allowedNames, {
|
||||
shouldDirty: true,
|
||||
shouldTouch: true
|
||||
});
|
||||
}
|
||||
|
||||
if (role.bound_service_account_namespaces?.length > 0) {
|
||||
// In Vault, "*" means allow all; in Infisical, empty field means allow any
|
||||
const allowedNamespaces = role.bound_service_account_namespaces.includes("*")
|
||||
? ""
|
||||
: role.bound_service_account_namespaces.join(", ");
|
||||
setValue("allowedNamespaces", allowedNamespaces, {
|
||||
shouldDirty: true,
|
||||
shouldTouch: true
|
||||
});
|
||||
}
|
||||
|
||||
if (role.token_ttl !== undefined) {
|
||||
setValue("accessTokenTTL", String(role.token_ttl), {
|
||||
shouldDirty: true,
|
||||
shouldTouch: true
|
||||
});
|
||||
}
|
||||
|
||||
if (role.token_max_ttl !== undefined) {
|
||||
setValue("accessTokenMaxTTL", String(role.token_max_ttl), {
|
||||
shouldDirty: true,
|
||||
shouldTouch: true
|
||||
});
|
||||
}
|
||||
|
||||
if (role.token_num_uses !== undefined) {
|
||||
setValue("accessTokenNumUsesLimit", String(role.token_num_uses), {
|
||||
shouldDirty: true,
|
||||
shouldTouch: true
|
||||
});
|
||||
}
|
||||
|
||||
if (role.audience) {
|
||||
setValue("allowedAudience", role.audience, {
|
||||
shouldDirty: true,
|
||||
shouldTouch: true
|
||||
});
|
||||
}
|
||||
|
||||
if (role.config.kubernetes_ca_cert) {
|
||||
setValue("caCert", role.config.kubernetes_ca_cert, {
|
||||
shouldDirty: true,
|
||||
shouldTouch: true
|
||||
});
|
||||
}
|
||||
|
||||
if (
|
||||
subscription?.ipAllowlisting &&
|
||||
role.token_bound_cidrs &&
|
||||
role.token_bound_cidrs.length > 0
|
||||
) {
|
||||
setValue(
|
||||
"accessTokenTrustedIps",
|
||||
role.token_bound_cidrs.map((cidr) => ({ ipAddress: cidr })),
|
||||
{
|
||||
shouldDirty: true,
|
||||
shouldTouch: true
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
createNotification({
|
||||
type: "success",
|
||||
text: `Successfully imported Kubernetes auth configuration from Vault role: ${role.name}`
|
||||
});
|
||||
} catch (err) {
|
||||
console.error("Import error:", err);
|
||||
createNotification({
|
||||
type: "error",
|
||||
text: "Failed to import Kubernetes auth configuration"
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
const onFormSubmit = async ({
|
||||
kubernetesHost,
|
||||
tokenReviewerJwt,
|
||||
@@ -301,6 +413,28 @@ export const IdentityKubernetesAuthForm = ({
|
||||
<Tab value={IdentityFormTab.Advanced}>Advanced</Tab>
|
||||
</TabList>
|
||||
<TabPanel value={IdentityFormTab.Configuration}>
|
||||
{hasVaultConnection && !isUpdate && (
|
||||
<div className="bg-primary/10 border-primary/30 mb-4 flex items-center justify-between rounded-md border p-3">
|
||||
<div className="flex items-start gap-2 text-sm">
|
||||
<FontAwesomeIcon icon={faInfoCircle} className="text-primary mt-0.5" />
|
||||
<span className="text-mineshaft-200">Load values from HashiCorp Vault</span>
|
||||
</div>
|
||||
<Button
|
||||
variant="outline_bg"
|
||||
size="xs"
|
||||
leftIcon={
|
||||
<img
|
||||
src="/images/integrations/Vault.png"
|
||||
alt="HashiCorp Vault"
|
||||
className="h-4 w-4"
|
||||
/>
|
||||
}
|
||||
onClick={() => handleImportPopUpToggle("importFromVault", true)}
|
||||
>
|
||||
Load from Vault
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
<div className="flex w-full items-center gap-2">
|
||||
<div className="w-full flex-1">
|
||||
<OrgPermissionCan
|
||||
@@ -343,7 +477,7 @@ export const IdentityKubernetesAuthForm = ({
|
||||
);
|
||||
}
|
||||
}}
|
||||
className="w-full border border-mineshaft-500"
|
||||
className="border-mineshaft-500 w-full border"
|
||||
dropdownContainerClassName="max-w-none"
|
||||
isLoading={isGatewayLoading}
|
||||
placeholder="Default: Internet Gateway"
|
||||
@@ -407,6 +541,7 @@ export const IdentityKubernetesAuthForm = ({
|
||||
placeholder="https://my-example-k8s-api-host.com"
|
||||
type="text"
|
||||
value={field.value || ""}
|
||||
autoComplete="off"
|
||||
/>
|
||||
</FormControl>
|
||||
)}
|
||||
@@ -425,7 +560,7 @@ export const IdentityKubernetesAuthForm = ({
|
||||
errorText={error?.message}
|
||||
tooltipText="Optional JWT token for accessing Kubernetes TokenReview API. If provided, this long-lived token will be used to validate service account tokens during authentication. If omitted, the client's own JWT will be used instead, which requires the client to have the system:auth-delegator ClusterRole binding."
|
||||
>
|
||||
<Input {...field} placeholder="" type="password" />
|
||||
<Input {...field} placeholder="" type="password" autoComplete="new-password" />
|
||||
</FormControl>
|
||||
)}
|
||||
/>
|
||||
@@ -441,7 +576,12 @@ export const IdentityKubernetesAuthForm = ({
|
||||
errorText={error?.message}
|
||||
tooltipText="A comma-separated list of trusted namespaces that service accounts must belong to authenticate with Infisical."
|
||||
>
|
||||
<Input {...field} placeholder="namespaceA, namespaceB" type="text" />
|
||||
<Input
|
||||
{...field}
|
||||
placeholder="namespaceA, namespaceB"
|
||||
type="text"
|
||||
autoComplete="off"
|
||||
/>
|
||||
</FormControl>
|
||||
)}
|
||||
/>
|
||||
@@ -456,7 +596,11 @@ export const IdentityKubernetesAuthForm = ({
|
||||
tooltipText="An optional comma-separated list of trusted service account names that are allowed to authenticate with Infisical. Leave empty to allow any service account."
|
||||
errorText={error?.message}
|
||||
>
|
||||
<Input {...field} placeholder="service-account-1-name, service-account-1-name" />
|
||||
<Input
|
||||
{...field}
|
||||
placeholder="service-account-1-name, service-account-1-name"
|
||||
autoComplete="off"
|
||||
/>
|
||||
</FormControl>
|
||||
)}
|
||||
/>
|
||||
@@ -628,6 +772,11 @@ export const IdentityKubernetesAuthForm = ({
|
||||
Cancel
|
||||
</Button>
|
||||
</div>
|
||||
<VaultKubernetesAuthImportModal
|
||||
isOpen={popUp.importFromVault.isOpen}
|
||||
onOpenChange={(isOpen) => handleImportPopUpToggle("importFromVault", isOpen)}
|
||||
onImport={handleImportFromVault}
|
||||
/>
|
||||
</form>
|
||||
);
|
||||
};
|
||||
|
||||
+143
@@ -0,0 +1,143 @@
|
||||
import { useEffect, useState } from "react";
|
||||
|
||||
import { createNotification } from "@app/components/notifications";
|
||||
import {
|
||||
Button,
|
||||
FilterableSelect,
|
||||
FormControl,
|
||||
Modal,
|
||||
ModalClose,
|
||||
ModalContent
|
||||
} from "@app/components/v2";
|
||||
import {
|
||||
useGetVaultKubernetesAuthRoles,
|
||||
useGetVaultNamespaces
|
||||
} from "@app/hooks/api/migration/queries";
|
||||
import { VaultKubernetesAuthRole } from "@app/hooks/api/migration/types";
|
||||
|
||||
type Props = {
|
||||
isOpen: boolean;
|
||||
onOpenChange: (isOpen: boolean) => void;
|
||||
onImport: (role: VaultKubernetesAuthRole) => void;
|
||||
};
|
||||
|
||||
type ContentProps = {
|
||||
onClose: () => void;
|
||||
onImport: (role: VaultKubernetesAuthRole) => void;
|
||||
};
|
||||
|
||||
const Content = ({ onClose, onImport }: ContentProps) => {
|
||||
const [selectedNamespace, setSelectedNamespace] = useState<string>("default");
|
||||
const [selectedRole, setSelectedRole] = useState<VaultKubernetesAuthRole | null>(null);
|
||||
const [shouldFetchRoles, setShouldFetchRoles] = useState(false);
|
||||
|
||||
const { data: namespaces, isLoading: isLoadingNamespaces } = useGetVaultNamespaces();
|
||||
const {
|
||||
data: roles,
|
||||
isLoading: isLoadingRoles,
|
||||
refetch: refetchRoles
|
||||
} = useGetVaultKubernetesAuthRoles(shouldFetchRoles, selectedNamespace);
|
||||
|
||||
useEffect(() => {
|
||||
if (selectedNamespace) {
|
||||
setShouldFetchRoles(true);
|
||||
}
|
||||
}, [selectedNamespace]);
|
||||
|
||||
const handleImportAndApply = () => {
|
||||
if (!selectedRole) {
|
||||
createNotification({
|
||||
type: "error",
|
||||
text: "Please select a Kubernetes role to load"
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
onImport(selectedRole);
|
||||
onClose();
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
<FormControl
|
||||
label="Namespace"
|
||||
className="mb-4"
|
||||
tooltipText="Select the Vault namespace containing the Kubernetes auth configuration."
|
||||
>
|
||||
<>
|
||||
<FilterableSelect
|
||||
value={namespaces?.find((ns) => ns.name === selectedNamespace)}
|
||||
onChange={(value) => {
|
||||
if (value && !Array.isArray(value)) {
|
||||
const namespace = value as { id: string; name: string };
|
||||
setSelectedNamespace(namespace.name);
|
||||
setSelectedRole(null);
|
||||
// Refetch roles when namespace changes
|
||||
refetchRoles();
|
||||
}
|
||||
}}
|
||||
options={namespaces || []}
|
||||
getOptionValue={(option) => option.name}
|
||||
getOptionLabel={(option) => option.name}
|
||||
isDisabled={isLoadingNamespaces}
|
||||
placeholder="Select namespace..."
|
||||
className="w-full"
|
||||
/>
|
||||
<p className="text-mineshaft-400 mt-1 text-xs">
|
||||
Select the Vault namespace to fetch available Kubernetes auth roles
|
||||
</p>
|
||||
</>
|
||||
</FormControl>
|
||||
|
||||
<FormControl label="Kubernetes Role" className="mb-6">
|
||||
<>
|
||||
<FilterableSelect
|
||||
value={selectedRole}
|
||||
onChange={(value) => {
|
||||
if (value && !Array.isArray(value)) {
|
||||
setSelectedRole(value as VaultKubernetesAuthRole);
|
||||
} else {
|
||||
setSelectedRole(null);
|
||||
}
|
||||
}}
|
||||
options={roles || []}
|
||||
getOptionValue={(option) => option.name}
|
||||
getOptionLabel={(option) => `${option.name} (${option.mountPath})`}
|
||||
isDisabled={isLoadingRoles || !roles?.length}
|
||||
placeholder="Select a Kubernetes role to load..."
|
||||
isClearable
|
||||
className="w-full"
|
||||
/>
|
||||
<p className="text-mineshaft-400 mt-1 text-xs">
|
||||
Select the Kubernetes role to load configuration from
|
||||
</p>
|
||||
</>
|
||||
</FormControl>
|
||||
|
||||
<div className="mt-8 flex space-x-4">
|
||||
<Button onClick={handleImportAndApply} isDisabled={!selectedRole || isLoadingRoles}>
|
||||
Load
|
||||
</Button>
|
||||
<ModalClose asChild>
|
||||
<Button colorSchema="secondary" variant="plain">
|
||||
Cancel
|
||||
</Button>
|
||||
</ModalClose>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
export const VaultKubernetesAuthImportModal = ({ isOpen, onOpenChange, onImport }: Props) => {
|
||||
return (
|
||||
<Modal isOpen={isOpen} onOpenChange={onOpenChange}>
|
||||
<ModalContent
|
||||
title="Load Kubernetes Auth from HashiCorp Vault"
|
||||
subTitle="Load Kubernetes authentication configuration from your Vault instance. The auth method and role settings will be automatically translated and prefilled in the form."
|
||||
className="max-w-2xl"
|
||||
>
|
||||
<Content onClose={() => onOpenChange(false)} onImport={onImport} />
|
||||
</ModalContent>
|
||||
</Modal>
|
||||
);
|
||||
};
|
||||
Reference in New Issue
Block a user