misc: filter optimizations

This commit is contained in:
Sheen Capadngan
2025-10-16 05:08:48 +08:00
parent efb8616c63
commit a95cb63682
8 changed files with 300 additions and 47 deletions

View File

@@ -332,6 +332,35 @@ export const registerExternalMigrationRouter = async (server: FastifyZodProvider
}
});
server.route({
method: "GET",
url: "/vault/auth-mounts",
config: {
rateLimit: readLimit
},
schema: {
querystring: z.object({
namespace: z.string(),
authType: z.string().optional()
}),
response: {
200: z.object({
mounts: z.array(z.object({ path: z.string(), type: z.string() }))
})
}
},
onRequest: verifyAuth([AuthMode.JWT]),
handler: async (req) => {
const mounts = await server.services.migration.getVaultAuthMounts({
actor: req.permission,
namespace: req.query.namespace,
authType: req.query.authType
});
return { mounts };
}
});
server.route({
method: "POST",
url: "/vault/import-secrets",
@@ -372,7 +401,8 @@ export const registerExternalMigrationRouter = async (server: FastifyZodProvider
},
schema: {
querystring: z.object({
namespace: z.string()
namespace: z.string(),
mountPath: z.string()
}),
response: {
200: z.object({
@@ -384,7 +414,8 @@ export const registerExternalMigrationRouter = async (server: FastifyZodProvider
handler: async (req) => {
const secretPaths = await server.services.migration.getVaultSecretPaths({
actor: req.permission,
namespace: req.query.namespace
namespace: req.query.namespace,
mountPath: req.query.mountPath
});
return { secretPaths };
@@ -399,7 +430,8 @@ export const registerExternalMigrationRouter = async (server: FastifyZodProvider
},
schema: {
querystring: z.object({
namespace: z.string()
namespace: z.string(),
mountPath: z.string()
}),
response: {
@@ -437,7 +469,8 @@ export const registerExternalMigrationRouter = async (server: FastifyZodProvider
handler: async (req) => {
const roles = await server.services.migration.getVaultKubernetesAuthRoles({
actor: req.permission,
namespace: req.query.namespace
namespace: req.query.namespace,
mountPath: req.query.mountPath
});
return { roles };

View File

@@ -455,7 +455,8 @@ export const listHCVaultMounts = async (
export const listHCVaultSecretPaths = async (
namespace: string,
connection: THCVaultConnection,
gatewayService: Pick<TGatewayServiceFactory, "fnGetGatewayClientTlsByGatewayId">
gatewayService: Pick<TGatewayServiceFactory, "fnGetGatewayClientTlsByGatewayId">,
filterMountPath?: string
) => {
const instanceUrl = await getHCVaultInstanceUrl(connection);
const accessToken = await getHCVaultAccessToken(connection, gatewayService);
@@ -532,7 +533,13 @@ export const listHCVaultSecretPaths = async (
const mounts = await listHCVaultMounts(connection, gatewayService, namespace);
// Filter for KV mounts (kv, kv-v1, kv-v2)
const kvMounts = mounts.filter((mount) => mount.type === "kv" || mount.type.startsWith("kv"));
let kvMounts = mounts.filter((mount) => mount.type === "kv" || mount.type.startsWith("kv"));
// If filterMountPath is provided, filter to only that mount
if (filterMountPath) {
const normalizedFilterPath = filterMountPath.replace(/\/$/, ""); // Remove trailing slash
kvMounts = kvMounts.filter((mount) => mount.path.replace(/\/$/, "") === normalizedFilterPath);
}
// Create concurrency limiter to avoid overwhelming the Vault instance
const limiter = createConcurrencyLimiter(HC_VAULT_CONCURRENCY_LIMIT);
@@ -648,7 +655,7 @@ export const getHCVaultSecretsForPath = async (
export const getHCVaultAuthMounts = async (
namespace: string,
authType: HCVaultAuthType,
authType: HCVaultAuthType | undefined,
connection: THCVaultConnection,
gatewayService: Pick<TGatewayServiceFactory, "fnGetGatewayClientTlsByGatewayId">
): Promise<THCVaultAuthMount[]> => {
@@ -668,7 +675,8 @@ export const getHCVaultAuthMounts = async (
const authMounts: THCVaultAuthMount[] = [];
Object.entries(data.data).forEach(([path, authMethod]) => {
if (authMethod.type === authType) {
// If authType is specified, filter by it; otherwise, include all
if (!authType || authMethod.type === authType) {
authMounts.push({
path,
type: authMethod.type,
@@ -680,16 +688,17 @@ export const getHCVaultAuthMounts = async (
return authMounts;
} catch (error: unknown) {
logger.error(error, `Unable to list HC Vault ${authType} auth mounts`);
const authTypeStr = authType || "all";
logger.error(error, `Unable to list HC Vault ${authTypeStr} auth mounts`);
if (error instanceof AxiosError) {
throw new BadRequestError({
message: `Failed to list ${authType} auth mounts: ${error.message || "Unknown error"}`
message: `Failed to list ${authTypeStr} auth mounts: ${error.message || "Unknown error"}`
});
}
throw new BadRequestError({
message: `Unable to list ${authType} auth mounts from HashiCorp Vault`
message: `Unable to list ${authTypeStr} auth mounts from HashiCorp Vault`
});
}
};

View File

@@ -444,7 +444,15 @@ export const externalMigrationServiceFactory = ({
return mounts;
};
const getVaultSecretPaths = async ({ actor, namespace }: { actor: OrgServiceActor; namespace: string }) => {
const getVaultSecretPaths = async ({
actor,
namespace,
mountPath
}: {
actor: OrgServiceActor;
namespace: string;
mountPath: string;
}) => {
const { hasRole } = await permissionService.getOrgPermission(
actor.type,
actor.id,
@@ -482,7 +490,7 @@ export const externalMigrationServiceFactory = ({
credentials
} as THCVaultConnection;
const secretPaths = await listHCVaultSecretPaths(namespace, connection, gatewayService);
const secretPaths = await listHCVaultSecretPaths(namespace, connection, gatewayService, mountPath);
return secretPaths;
};
@@ -617,7 +625,66 @@ export const externalMigrationServiceFactory = ({
return deletedConfig;
};
const getVaultKubernetesAuthRoles = async ({ actor, namespace }: { actor: OrgServiceActor; namespace: string }) => {
const getVaultAuthMounts = async ({
actor,
namespace,
authType
}: {
actor: OrgServiceActor;
namespace: string;
authType?: string;
}) => {
const { hasRole } = await permissionService.getOrgPermission(
actor.type,
actor.id,
actor.orgId,
actor.authMethod,
actor.orgId
);
if (!hasRole(OrgMembershipRole.Admin)) {
throw new ForbiddenRequestError({ message: "Only admins can view vault auth mounts" });
}
const vaultConfig = await vaultExternalMigrationConfigDAL.findOne({
orgId: actor.orgId,
namespace
});
if (!vaultConfig) {
throw new NotFoundError({ message: "Vault migration config not found for this namespace" });
}
if (!vaultConfig.connection) {
throw new BadRequestError({ message: "Vault migration connection is not configured for this namespace" });
}
const credentials = await decryptAppConnectionCredentials({
orgId: vaultConfig.orgId,
encryptedCredentials: vaultConfig.connection.encryptedCredentials,
kmsService,
projectId: null
});
const connection = {
...vaultConfig.connection,
credentials
} as THCVaultConnection;
const authMounts = await getHCVaultAuthMounts(namespace, authType as HCVaultAuthType, connection, gatewayService);
return authMounts;
};
const getVaultKubernetesAuthRoles = async ({
actor,
namespace,
mountPath
}: {
actor: OrgServiceActor;
namespace: string;
mountPath: string;
}) => {
const { hasRole } = await permissionService.getOrgPermission(
actor.type,
actor.id,
@@ -655,18 +722,10 @@ export const externalMigrationServiceFactory = ({
credentials
} as THCVaultConnection;
// Get all Kubernetes auth mounts for this namespace
const authMounts = await getHCVaultAuthMounts(namespace, HCVaultAuthType.Kubernetes, connection, gatewayService);
// Get roles for the specified mount path only
const roles = await getHCVaultKubernetesAuthRoles(namespace, mountPath, connection, gatewayService);
// For each mount, get all roles with their configuration
const allRolesPromises = authMounts.map(async (mount) => {
const roles = await getHCVaultKubernetesAuthRoles(namespace, mount.path, connection, gatewayService);
return roles;
});
const rolesPerMount = await Promise.all(allRolesPromises);
return rolesPerMount.flat();
return roles;
};
return {
@@ -680,6 +739,7 @@ export const externalMigrationServiceFactory = ({
getVaultNamespaces,
getVaultPolicies,
getVaultMounts,
getVaultAuthMounts,
getVaultSecretPaths,
importVaultSecrets,
getVaultKubernetesAuthRoles

Binary file not shown.

Before

Width:  |  Height:  |  Size: 432 KiB

After

Width:  |  Height:  |  Size: 429 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 489 KiB

After

Width:  |  Height:  |  Size: 505 KiB

View File

@@ -17,8 +17,21 @@ export const externalMigrationQueryKeys = {
vaultNamespaces: () => ["vault-namespaces"],
vaultPolicies: (namespace?: string) => ["vault-policies", namespace],
vaultMounts: (namespace?: string) => ["vault-mounts", namespace],
vaultSecretPaths: (namespace?: string) => ["vault-secret-paths", namespace],
vaultKubernetesAuthRoles: (namespace?: string) => ["vault-kubernetes-auth-roles", namespace]
vaultAuthMounts: (namespace?: string, authType?: string) => [
"vault-auth-mounts",
namespace,
authType
],
vaultSecretPaths: (namespace?: string, mountPath?: string) => [
"vault-secret-paths",
namespace,
mountPath
],
vaultKubernetesAuthRoles: (namespace?: string, mountPath?: string) => [
"vault-kubernetes-auth-roles",
namespace,
mountPath
]
};
export const useHasCustomMigrationAvailable = (provider: ExternalMigrationProviders) => {
@@ -91,38 +104,71 @@ export const useGetVaultMounts = (enabled = true, namespace?: string) => {
});
};
export const useGetVaultSecretPaths = (enabled = true, namespace?: string) => {
export const useGetVaultSecretPaths = (enabled = true, namespace?: string, mountPath?: string) => {
return useQuery({
queryKey: externalMigrationQueryKeys.vaultSecretPaths(namespace),
queryKey: externalMigrationQueryKeys.vaultSecretPaths(namespace, mountPath),
queryFn: async () => {
if (!namespace || !mountPath) {
throw new Error("Both namespace and mountPath are required");
}
const { data } = await apiRequest.get<{
secretPaths: string[];
}>("/api/v3/external-migration/vault/secret-paths", {
params: {
namespace
namespace,
mountPath
}
});
return data.secretPaths;
},
enabled: enabled && !!namespace && !!mountPath
});
};
export const useGetVaultAuthMounts = (enabled = true, namespace?: string, authType?: string) => {
return useQuery({
queryKey: externalMigrationQueryKeys.vaultAuthMounts(namespace, authType),
queryFn: async () => {
const { data } = await apiRequest.get<{
mounts: Array<{ path: string; type: string }>;
}>("/api/v3/external-migration/vault/auth-mounts", {
params: {
namespace,
...(authType && { authType })
}
});
return data.mounts;
},
enabled
});
};
export const useGetVaultKubernetesAuthRoles = (enabled = true, namespace?: string) => {
export const useGetVaultKubernetesAuthRoles = (
enabled = true,
namespace?: string,
mountPath?: string
) => {
return useQuery({
queryKey: externalMigrationQueryKeys.vaultKubernetesAuthRoles(namespace),
queryKey: externalMigrationQueryKeys.vaultKubernetesAuthRoles(namespace, mountPath),
queryFn: async () => {
if (!namespace || !mountPath) {
throw new Error("Both namespace and mountPath are required");
}
const { data } = await apiRequest.get<{
roles: VaultKubernetesAuthRole[];
}>("/api/v3/external-migration/vault/auth-roles/kubernetes", {
params: {
namespace
namespace,
mountPath
}
});
return data.roles;
},
enabled
enabled: enabled && !!namespace && !!mountPath
});
};

View File

@@ -10,6 +10,7 @@ import {
ModalContent
} from "@app/components/v2";
import {
useGetVaultAuthMounts,
useGetVaultKubernetesAuthRoles,
useGetVaultNamespaces
} from "@app/hooks/api/migration/queries";
@@ -28,21 +29,39 @@ type ContentProps = {
const Content = ({ onClose, onImport }: ContentProps) => {
const [selectedNamespace, setSelectedNamespace] = useState<string | null>(null);
const [selectedMountPath, setSelectedMountPath] = useState<string | null>(null);
const [selectedRole, setSelectedRole] = useState<VaultKubernetesAuthRole | null>(null);
const [shouldFetchRoles, setShouldFetchRoles] = useState(false);
const [shouldFetchMounts, setShouldFetchMounts] = useState(false);
const { data: namespaces, isLoading: isLoadingNamespaces } = useGetVaultNamespaces();
const { data: authMounts, isLoading: isLoadingMounts } = useGetVaultAuthMounts(
shouldFetchMounts,
selectedNamespace ?? undefined,
"kubernetes"
);
const { data: roles, isLoading: isLoadingRoles } = useGetVaultKubernetesAuthRoles(
shouldFetchRoles,
selectedNamespace ?? undefined
selectedNamespace ?? undefined,
selectedMountPath ?? undefined
);
// Enable fetching mounts when namespace is selected
useEffect(() => {
if (selectedNamespace) {
setShouldFetchRoles(true);
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 handleImportAndApply = () => {
if (!selectedRole) {
createNotification({
@@ -70,6 +89,7 @@ const Content = ({ onClose, onImport }: ContentProps) => {
if (value && !Array.isArray(value)) {
const namespace = value as { id: string; name: string };
setSelectedNamespace(namespace.name);
setSelectedMountPath(null);
setSelectedRole(null);
}
}}
@@ -81,7 +101,42 @@ const Content = ({ onClose, onImport }: ContentProps) => {
className="w-full"
/>
<p className="mt-1 text-xs text-mineshaft-400">
Select the Vault namespace to fetch available Kubernetes auth roles
Select the Vault namespace to fetch available auth mounts
</p>
</>
</FormControl>
<FormControl
label="Auth Engine"
className="mb-4"
tooltipText="Select the Kubernetes auth engine to narrow down available roles."
>
<>
<FilterableSelect
value={
selectedMountPath
? authMounts?.find((mount) => mount.path === selectedMountPath)
: null
}
onChange={(value) => {
if (value && !Array.isArray(value)) {
const mount = value as { path: string; type: string };
setSelectedMountPath(mount.path.replace(/\/$/, "")); // Remove trailing slash
setSelectedRole(null);
} else {
setSelectedMountPath(null);
}
}}
options={authMounts || []}
getOptionValue={(option) => option.path}
getOptionLabel={(option) => option.path.replace(/\/$/, "")}
isDisabled={isLoadingMounts || !authMounts?.length}
placeholder="Select auth engine..."
isClearable
className="w-full"
/>
<p className="mt-1 text-xs text-mineshaft-400">
Choose a Kubernetes auth engine to filter available roles
</p>
</>
</FormControl>
@@ -99,9 +154,13 @@ const Content = ({ onClose, onImport }: ContentProps) => {
}}
options={roles || []}
getOptionValue={(option) => option.name}
getOptionLabel={(option) => `${option.name} (${option.mountPath})`}
isDisabled={isLoadingRoles || !roles?.length}
placeholder="Select a Kubernetes role to load..."
getOptionLabel={(option) => option.name}
isDisabled={isLoadingRoles || !roles?.length || !selectedMountPath}
placeholder={
!selectedMountPath
? "Select an auth engine first..."
: "Select a Kubernetes role to load..."
}
isClearable
className="w-full"
/>

View File

@@ -34,6 +34,7 @@ type ContentProps = {
const Content = ({ onClose, environment, secretPath, onImport }: ContentProps) => {
const [selectedNamespace, setSelectedNamespace] = useState<string | null>(null);
const [selectedMountPath, setSelectedMountPath] = useState<string | null>(null);
const [selectedPath, setSelectedPath] = useState<string | null>(null);
const [shouldFetchPaths, setShouldFetchPaths] = useState(false);
const [shouldFetchMounts, setShouldFetchMounts] = useState(false);
@@ -41,21 +42,33 @@ const Content = ({ onClose, environment, secretPath, onImport }: ContentProps) =
const { data: namespaces, isLoading: isLoadingNamespaces } = useGetVaultNamespaces();
const { data: secretPaths, isLoading: isLoadingPaths } = useGetVaultSecretPaths(
shouldFetchPaths,
selectedNamespace ?? undefined
selectedNamespace ?? undefined,
selectedMountPath ?? undefined
);
const { data: mounts, isLoading: isLoadingMounts } = useGetVaultMounts(
shouldFetchMounts,
selectedNamespace ?? undefined
);
// Enable fetching paths and mounts when namespace is selected
// Filter to only show KV mounts
const kvMounts = mounts?.filter((mount) => mount.type === "kv" || mount.type.startsWith("kv"));
// Enable fetching mounts when namespace is selected
useEffect(() => {
if (selectedNamespace) {
setShouldFetchPaths(true);
setShouldFetchMounts(true);
}
}, [selectedNamespace]);
// Enable fetching paths when both namespace and mount path are selected
useEffect(() => {
if (selectedNamespace && selectedMountPath) {
setShouldFetchPaths(true);
} else {
setShouldFetchPaths(false);
}
}, [selectedNamespace, selectedMountPath]);
const handleImport = () => {
if (!selectedPath) {
createNotification({ type: "error", text: "Please select a Vault secret path to import" });
@@ -111,6 +124,7 @@ const Content = ({ onClose, environment, secretPath, onImport }: ContentProps) =
if (value && !Array.isArray(value)) {
const namespace = value as { id: string; name: string };
setSelectedNamespace(namespace.name);
setSelectedMountPath(null);
setSelectedPath(null);
}
}}
@@ -122,7 +136,35 @@ const Content = ({ onClose, environment, secretPath, onImport }: ContentProps) =
className="w-full"
/>
<p className="mt-1 text-xs text-mineshaft-400">
Select the Vault namespace to fetch available secret paths
Select the Vault namespace to fetch available mounts
</p>
</>
</FormControl>
<FormControl
label="Secrets Engine"
className="mb-4"
tooltipText="Select the KV secrets engine to narrow down secret paths."
>
<>
<FilterableSelect
value={kvMounts?.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
setSelectedPath(null);
}
}}
options={kvMounts || []}
getOptionValue={(option) => option.path}
getOptionLabel={(option) => option.path.replace(/\/$/, "")}
isDisabled={isLoadingMounts || !kvMounts?.length}
placeholder="Select secrets engine..."
className="w-full"
/>
<p className="mt-1 text-xs text-mineshaft-400">
Choose a KV secrets engine to filter available secret paths
</p>
</>
</FormControl>
@@ -141,13 +183,17 @@ const Content = ({ onClose, environment, secretPath, onImport }: ContentProps) =
options={(secretPaths || []).map((path) => ({ path }))}
getOptionValue={(option) => option.path}
getOptionLabel={(option) => option.path}
isDisabled={isLoadingPaths || !secretPaths?.length}
placeholder="Select a Vault path to import..."
isDisabled={isLoadingPaths || !secretPaths?.length || !selectedMountPath}
placeholder={
!selectedMountPath
? "Select a mount path first..."
: "Select a Vault path to import..."
}
isClearable
className="w-full"
/>
<p className="mt-1 text-xs text-mineshaft-400">
Choose a secret path from your Vault namespace to import into Infisical
Choose a secret path from the selected mount to import into Infisical
</p>
</>
</FormControl>