ldap (ad) migration support

This commit is contained in:
x032205
2026-01-08 02:30:20 -05:00
parent 9e3acb8ac0
commit e5653ee5b0
8 changed files with 523 additions and 2 deletions

View File

@@ -574,4 +574,49 @@ export const registerExternalMigrationRouter = async (server: FastifyZodProvider
return { roles };
}
});
server.route({
method: "GET",
url: "/vault/ldap-roles",
config: {
rateLimit: readLimit
},
schema: {
querystring: z.object({
namespace: z.string(),
mountPath: z.string()
}),
response: {
200: z.object({
roles: z.array(
z.object({
name: z.string(),
mountPath: z.string(),
default_ttl: z.number().nullish(),
max_ttl: z.number().nullish(),
creation_ldif: z.string().nullish(),
deletion_ldif: z.string().nullish(),
rollback_ldif: z.string().nullish(),
username_template: z.string().nullish(),
config: z.object({
binddn: z.string(),
url: z.string(),
certificate: z.string().nullish()
})
})
)
})
}
},
onRequest: verifyAuth([AuthMode.JWT]),
handler: async (req) => {
const roles = await server.services.migration.getVaultLdapRoles({
actor: req.permission,
namespace: req.query.namespace,
mountPath: req.query.mountPath
});
return { roles };
}
});
};

View File

@@ -25,6 +25,8 @@ import {
THCVaultKubernetesAuthRoleWithConfig,
THCVaultKubernetesRole,
THCVaultKubernetesSecretsConfig,
THCVaultLdapConfig,
THCVaultLdapRole,
THCVaultMount,
THCVaultMountResponse
} from "./hc-vault-connection-types";
@@ -1071,3 +1073,103 @@ export const getHCVaultDatabaseRoles = async (
});
}
};
export const getHCVaultLdapRoles = async (
namespace: string,
mountPath: string,
connection: THCVaultConnection,
gatewayService: Pick<TGatewayServiceFactory, "fnGetGatewayClientTlsByGatewayId">
): Promise<THCVaultLdapRole[]> => {
// Remove trailing slash from mount path
const cleanMountPath = mountPath.endsWith("/") ? mountPath.slice(0, -1) : mountPath;
try {
const instanceUrl = await getHCVaultInstanceUrl(connection);
const accessToken = await getHCVaultAccessToken(connection, gatewayService);
// 1. Get the LDAP secrets engine configuration for this mount
const { data: configResponse } = await requestWithHCVaultGateway<{ data: THCVaultLdapConfig }>(
connection,
gatewayService,
{
url: `${instanceUrl}/v1/${cleanMountPath}/config`,
method: "GET",
headers: {
"X-Vault-Token": accessToken,
"X-Vault-Namespace": namespace
}
}
);
const ldapConfig = configResponse.data;
// 2. List all dynamic roles in this mount
let roleNames: string[] = [];
try {
const { data: roleListResponse } = await requestWithHCVaultGateway<{ data: { keys: string[] } }>(
connection,
gatewayService,
{
url: `${instanceUrl}/v1/${cleanMountPath}/role?list=true`,
method: "GET",
headers: {
"X-Vault-Token": accessToken,
"X-Vault-Namespace": namespace
}
}
);
roleNames = roleListResponse.data.keys || [];
} catch (error) {
if (isVault404Error(error)) return [];
throw error;
}
if (!roleNames || roleNames.length === 0) {
return [];
}
// 3. Fetch details for each role with concurrency control
const limiter = createConcurrencyLimiter(HC_VAULT_CONCURRENCY_LIMIT);
const roleDetailsPromises = roleNames.map((roleName) =>
limiter(async () => {
const { data: roleResponse } = await requestWithHCVaultGateway<{
data: {
default_ttl?: number;
max_ttl?: number;
creation_ldif?: string;
deletion_ldif?: string;
rollback_ldif?: string;
username_template?: string;
};
}>(connection, gatewayService, {
url: `${instanceUrl}/v1/${cleanMountPath}/role/${roleName}`,
method: "GET",
headers: {
"X-Vault-Token": accessToken,
"X-Vault-Namespace": namespace
}
});
return {
name: roleName,
default_ttl: roleResponse.data.default_ttl,
max_ttl: roleResponse.data.max_ttl,
creation_ldif: roleResponse.data.creation_ldif,
deletion_ldif: roleResponse.data.deletion_ldif,
rollback_ldif: roleResponse.data.rollback_ldif,
username_template: roleResponse.data.username_template,
config: ldapConfig,
mountPath: cleanMountPath
} as THCVaultLdapRole;
})
);
return await Promise.all(roleDetailsPromises);
} catch (error: unknown) {
logger.error(error, "Unable to list HC Vault LDAP secrets engine roles");
throw new BadRequestError({
message: `Failed to list LDAP secrets engine roles: ${getVaultErrorMessage(error, "Unknown error")}`
});
}
};

View File

@@ -139,3 +139,21 @@ export type THCVaultDatabaseRole = {
config: THCVaultDatabaseConfig;
mountPath: string;
};
export type THCVaultLdapConfig = {
binddn: string;
url: string;
certificate?: string;
};
export type THCVaultLdapRole = {
name: string;
default_ttl?: number;
max_ttl?: number;
creation_ldif?: string;
deletion_ldif?: string;
rollback_ldif?: string;
username_template?: string;
config: THCVaultLdapConfig;
mountPath: string;
};

View File

@@ -21,6 +21,7 @@ import {
getHCVaultDatabaseRoles,
getHCVaultKubernetesAuthRoles,
getHCVaultKubernetesRoles,
getHCVaultLdapRoles,
getHCVaultSecretsForPath,
HCVaultAuthType,
listHCVaultMounts,
@@ -600,6 +601,19 @@ export const externalMigrationServiceFactory = ({
return getHCVaultDatabaseRoles(namespace, mountPath, connection, gatewayService);
};
const getVaultLdapRoles = async ({
actor,
namespace,
mountPath
}: {
actor: OrgServiceActor;
namespace: string;
mountPath: string;
}) => {
const connection = await getVaultConnectionForNamespace(actor, namespace, "get LDAP roles");
return getHCVaultLdapRoles(namespace, mountPath, connection, gatewayService);
};
return {
importEnvKeyData,
importVaultData,
@@ -616,6 +630,7 @@ export const externalMigrationServiceFactory = ({
importVaultSecrets,
getVaultKubernetesAuthRoles,
getVaultKubernetesRoles,
getVaultDatabaseRoles
getVaultDatabaseRoles,
getVaultLdapRoles
};
};

View File

@@ -7,7 +7,8 @@ import {
TVaultExternalMigrationConfig,
VaultDatabaseRole,
VaultKubernetesAuthRole,
VaultKubernetesRole
VaultKubernetesRole,
VaultLdapRole
} from "./types";
export const externalMigrationQueryKeys = {
@@ -43,6 +44,11 @@ export const externalMigrationQueryKeys = {
"vault-database-roles",
namespace,
mountPath
],
vaultLdapRoles: (namespace?: string, mountPath?: string) => [
"vault-ldap-roles",
namespace,
mountPath
]
};
@@ -238,3 +244,26 @@ export const useGetVaultDatabaseRoles = (
enabled: enabled && !!namespace && !!mountPath
});
};
export const useGetVaultLdapRoles = (enabled = true, namespace?: string, mountPath?: string) => {
return useQuery({
queryKey: externalMigrationQueryKeys.vaultLdapRoles(namespace, mountPath),
queryFn: async () => {
if (!namespace || !mountPath) {
throw new Error("Both namespace and mountPath are required");
}
const { data } = await apiRequest.get<{
roles: VaultLdapRole[];
}>("/api/v3/external-migration/vault/ldap-roles", {
params: {
namespace,
mountPath
}
});
return data.roles;
},
enabled: enabled && !!namespace && !!mountPath
});
};

View File

@@ -89,3 +89,19 @@ export type VaultDatabaseRole = {
plugin_name: string;
};
};
export type VaultLdapRole = {
name: string;
mountPath: string;
default_ttl?: number;
max_ttl?: number;
creation_ldif?: string;
deletion_ldif?: string;
rollback_ldif?: string;
username_template?: string;
config: {
binddn: string;
url: string;
certificate?: string;
};
};

View File

@@ -1,8 +1,11 @@
import { useState } from "react";
import { Controller, useForm } from "react-hook-form";
import { zodResolver } from "@hookform/resolvers/zod";
import ms from "ms";
import { z } from "zod";
import { createNotification } from "@app/components/notifications";
import { TtlFormLabel } from "@app/components/features";
import {
Button,
@@ -15,9 +18,13 @@ import {
} from "@app/components/v2";
import { useCreateDynamicSecret } from "@app/hooks/api";
import { DynamicSecretProviders } from "@app/hooks/api/dynamicSecret/types";
import { VaultLdapRole } from "@app/hooks/api/migration/types";
import { ProjectEnv } from "@app/hooks/api/types";
import { slugSchema } from "@app/lib/schemas";
import { LoadFromVaultBanner } from "./components/LoadFromVaultBanner";
import { VaultLdapImportModal } from "./VaultLdapImportModal";
enum CredentialType {
Dynamic = "dynamic",
Static = "static"
@@ -98,6 +105,8 @@ export const LdapInputForm = ({
environments,
isSingleEnvironmentMode
}: Props) => {
const [isVaultImportModalOpen, setIsVaultImportModalOpen] = useState(false);
const {
control,
formState: { isSubmitting },
@@ -126,6 +135,64 @@ export const LdapInputForm = ({
const createDynamicSecret = useCreateDynamicSecret();
const handleVaultImport = (role: VaultLdapRole) => {
try {
setValue("name", role.name);
if (role.config.url) {
setValue("provider.url", role.config.url);
}
if (role.config.binddn) {
setValue("provider.binddn", role.config.binddn);
}
if (role.config.certificate) {
setValue("provider.ca", role.config.certificate);
}
// Set credential type to Dynamic if creation_ldif is present
if (role.creation_ldif) {
setValue("provider.credentialType", CredentialType.Dynamic);
setValue("provider.creationLdif", role.creation_ldif);
if (role.deletion_ldif) {
setValue("provider.revocationLdif", role.deletion_ldif);
}
if (role.rollback_ldif) {
setValue("provider.rollbackLdif", role.rollback_ldif);
}
}
// Set TTLs
if (role.default_ttl) {
const defaultTTL = `${role.default_ttl}s`;
setValue("defaultTTL", defaultTTL);
}
if (role.max_ttl) {
const maxTTL = `${role.max_ttl}s`;
setValue("maxTTL", maxTTL);
}
// Set username template
if (role.username_template) {
setValue("usernameTemplate", role.username_template);
}
createNotification({
type: "info",
text: "Configuration loaded successfully from HashiCorp Vault"
});
} catch {
createNotification({
type: "error",
text: "Failed to load configuration from HashiCorp Vault"
});
}
};
const handleCreateDynamicSecret = async ({
name,
maxTTL,
@@ -155,6 +222,7 @@ export const LdapInputForm = ({
return (
<form onSubmit={handleSubmit(handleCreateDynamicSecret)} autoComplete="off">
<div>
<LoadFromVaultBanner onClick={() => setIsVaultImportModalOpen(true)} />
<div className="flex items-center space-x-2">
<div className="grow">
<Controller
@@ -425,6 +493,11 @@ export const LdapInputForm = ({
Cancel
</Button>
</div>
<VaultLdapImportModal
isOpen={isVaultImportModalOpen}
onOpenChange={setIsVaultImportModalOpen}
onImport={handleVaultImport}
/>
</form>
);
};

View File

@@ -0,0 +1,223 @@
import { useEffect, useState } from "react";
import { faInfoCircle } from "@fortawesome/free-solid-svg-icons";
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
import { createNotification } from "@app/components/notifications";
import {
Button,
FilterableSelect,
FormControl,
Modal,
ModalClose,
ModalContent
} from "@app/components/v2";
import {
useGetVaultLdapRoles,
useGetVaultMounts,
useGetVaultNamespaces
} from "@app/hooks/api/migration/queries";
import { VaultLdapRole } from "@app/hooks/api/migration/types";
type Props = {
isOpen: boolean;
onOpenChange: (isOpen: boolean) => void;
onImport: (role: VaultLdapRole) => void;
};
type ContentProps = {
onClose: () => void;
onImport: (role: VaultLdapRole) => void;
};
const Content = ({ onClose, onImport }: ContentProps) => {
const [selectedNamespace, setSelectedNamespace] = useState<string | null>(null);
const [selectedMountPath, setSelectedMountPath] = useState<string | null>(null);
const [selectedRole, setSelectedRole] = useState<VaultLdapRole | null>(null);
const [shouldFetchRoles, setShouldFetchRoles] = useState(false);
const [shouldFetchMounts, setShouldFetchMounts] = useState(false);
const { data: namespaces, isLoading: isLoadingNamespaces } = useGetVaultNamespaces();
const { data: roles, isLoading: isLoadingRoles } = useGetVaultLdapRoles(
shouldFetchRoles,
selectedNamespace ?? undefined,
selectedMountPath ?? undefined
);
const { data: mounts, isLoading: isLoadingMounts } = useGetVaultMounts(
shouldFetchMounts,
selectedNamespace ?? undefined
);
// Filter to only show LDAP mounts
const ldapMounts = mounts?.filter((mount) => mount.type === "ldap");
// Enable fetching mounts when namespace is selected
useEffect(() => {
if (selectedNamespace) {
setShouldFetchMounts(true);
}
}, [selectedNamespace]);
// Enable fetching roles when both namespace and mount path are selected
useEffect(() => {
if (selectedNamespace && selectedMountPath) {
setShouldFetchRoles(true);
} else {
setShouldFetchRoles(false);
}
}, [selectedNamespace, selectedMountPath]);
const handleImport = () => {
if (!selectedRole) {
createNotification({
type: "error",
text: "Please select a Vault LDAP role to load"
});
return;
}
if (!selectedNamespace) {
createNotification({ type: "error", text: "Please select a namespace" });
return;
}
if (!mounts || mounts.length === 0) {
createNotification({
type: "error",
text: "No Vault mounts found. Please ensure you have LDAP secrets engine configured."
});
return;
}
onImport(selectedRole);
onClose();
};
return (
<>
<div className="mb-4 rounded-md bg-primary/10 p-3 text-sm text-mineshaft-200">
<div className="flex items-start gap-2">
<FontAwesomeIcon icon={faInfoCircle} className="mt-0.5 text-primary" />
<div className="space-y-1.5 text-xs leading-relaxed">
<p>
Select an LDAP secrets engine role from Vault to pre-fill the form with its
configuration including connection details, LDIF statements, TTL settings, etc.
</p>
</div>
</div>
</div>
<FormControl
label="Namespace"
className="mb-4"
tooltipText="Select the Vault namespace containing the LDAP secrets engine."
>
<>
<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);
setSelectedMountPath(null);
setSelectedRole(null);
}
}}
options={namespaces || []}
getOptionValue={(option) => option.name}
getOptionLabel={(option) => (option.name === "/" ? "root" : option.name)}
isDisabled={isLoadingNamespaces}
placeholder="Select namespace..."
className="w-full"
/>
<p className="mt-1 text-xs text-mineshaft-400">
Select the Vault namespace to fetch available LDAP secrets engines
</p>
</>
</FormControl>
<FormControl
label="LDAP Secrets Engine"
className="mb-4"
tooltipText="Select the LDAP secrets engine mount to fetch available roles."
>
<>
<FilterableSelect
value={ldapMounts?.find((mount) => mount.path === selectedMountPath)}
onChange={(value) => {
if (value && !Array.isArray(value)) {
const mount = value as { path: string; type: string; version: string | null };
setSelectedMountPath(mount.path.replace(/\/$/, "")); // Remove trailing slash
setSelectedRole(null);
}
}}
options={ldapMounts || []}
getOptionValue={(option) => option.path}
getOptionLabel={(option) => option.path.replace(/\/$/, "")}
isDisabled={isLoadingMounts || !ldapMounts?.length}
placeholder="Select LDAP secrets engine..."
className="w-full"
/>
<p className="mt-1 text-xs text-mineshaft-400">
Choose an LDAP secrets engine mount to list available roles
</p>
</>
</FormControl>
<FormControl label="LDAP Role" className="mb-6">
<>
<FilterableSelect
value={selectedRole}
onChange={(value) => {
if (value && !Array.isArray(value)) {
setSelectedRole(value as VaultLdapRole);
} else {
setSelectedRole(null);
}
}}
options={roles || []}
getOptionValue={(option) => option.name}
getOptionLabel={(option) => option.name}
isDisabled={isLoadingRoles || !roles?.length || !selectedMountPath}
placeholder={
!selectedMountPath ? "Select a mount path first..." : "Select a role to load..."
}
isClearable
className="w-full"
/>
<p className="mt-1 text-xs text-mineshaft-400">
Choose an LDAP role from the selected mount to load its configuration
</p>
</>
</FormControl>
<div className="mt-8 flex space-x-4">
<Button
onClick={handleImport}
isDisabled={!selectedRole || isLoadingMounts || isLoadingRoles}
>
Load Configuration
</Button>
<ModalClose asChild>
<Button colorSchema="secondary" variant="plain">
Cancel
</Button>
</ModalClose>
</div>
</>
);
};
export const VaultLdapImportModal = ({ isOpen, onOpenChange, onImport }: Props) => {
return (
<Modal isOpen={isOpen} onOpenChange={onOpenChange}>
<ModalContent
bodyClassName="overflow-visible"
title="Load from HashiCorp Vault"
subTitle="Select an LDAP secrets engine role to load its configuration."
className="max-w-2xl"
>
<Content onClose={() => onOpenChange(false)} onImport={onImport} />
</ModalContent>
</Modal>
);
};