feat: ssh pam draft

This commit is contained in:
Sheen Capadngan
2025-11-13 03:39:18 +08:00
parent 3f68ec8016
commit ace77b516e
27 changed files with 813 additions and 46 deletions
+4 -2
View File
@@ -1,13 +1,15 @@
import { PamResourceType, PamSessionStatus } from "../enums";
import { TMySQLAccount, TMySQLResource } from "./mysql-resource";
import { TPostgresAccount, TPostgresResource } from "./postgres-resource";
import { TSSHAccount, TSSHResource } from "./ssh-resource";
export * from "./mysql-resource";
export * from "./postgres-resource";
export * from "./ssh-resource";
export type TPamResource = TPostgresResource | TMySQLResource;
export type TPamResource = TPostgresResource | TMySQLResource | TSSHResource;
export type TPamAccount = TPostgresAccount | TMySQLAccount;
export type TPamAccount = TPostgresAccount | TMySQLAccount | TSSHAccount;
export type TPamFolder = {
id: string;
@@ -0,0 +1,47 @@
import { PamResourceType } from "../enums";
import { TBasePamAccount } from "./base-account";
import { TBasePamResource } from "./base-resource";
export enum SSHAuthMethod {
Password = "password",
PublicKey = "public-key",
Certificate = "certificate"
}
export type TSSHConnectionDetails = {
host: string;
port: number;
};
export type TSSHPasswordCredentials = {
authMethod: SSHAuthMethod.Password;
username: string;
password: string;
};
export type TSSHPublicKeyCredentials = {
authMethod: SSHAuthMethod.PublicKey;
username: string;
privateKey: string;
};
export type TSSHCertificateCredentials = {
authMethod: SSHAuthMethod.Certificate;
username: string;
};
export type TSSHCredentials =
| TSSHPasswordCredentials
| TSSHPublicKeyCredentials
| TSSHCertificateCredentials;
// Resources
export type TSSHResource = TBasePamResource & { resourceType: PamResourceType.SSH } & {
connectionDetails: TSSHConnectionDetails;
rotationAccountCredentials?: TSSHCredentials | null;
};
// Accounts
export type TSSHAccount = TBasePamAccount & {
credentials: TSSHCredentials;
};
@@ -58,15 +58,22 @@ export const PamAccessAccountModal = ({ isOpen, onOpenChange, account }: Props)
return duration;
}, [duration]);
const command = useMemo(
() =>
account &&
(account.resource.resourceType === PamResourceType.Postgres ||
account.resource.resourceType === PamResourceType.MySQL)
? `infisical pam db access-account ${account.id} --duration ${cliDuration}`
: "",
[account, cliDuration]
);
const command = useMemo(() => {
if (!account) return "";
if (
account.resource.resourceType === PamResourceType.Postgres ||
account.resource.resourceType === PamResourceType.MySQL
) {
return `infisical pam db access-account ${account.id} --duration ${cliDuration}`;
}
if (account.resource.resourceType === PamResourceType.SSH) {
return `infisical pam ssh ${account.id} --duration ${cliDuration}`;
}
return "";
}, [account, cliDuration]);
if (!account) return null;
@@ -10,6 +10,7 @@ import { DiscriminativePick } from "@app/types";
import { PamAccountHeader } from "../PamAccountHeader";
import { MySQLAccountForm } from "./MySQLAccountForm";
import { PostgresAccountForm } from "./PostgresAccountForm";
import { SSHAccountForm } from "./SSHAccountForm";
type FormProps = {
onComplete: (account: TPamAccount) => void;
@@ -65,6 +66,10 @@ const CreateForm = ({
return (
<MySQLAccountForm onSubmit={onSubmit} resourceId={resourceId} resourceType={resourceType} />
);
case PamResourceType.SSH:
return (
<SSHAccountForm onSubmit={onSubmit} resourceId={resourceId} resourceType={resourceType} />
);
default:
throw new Error(`Unhandled resource: ${resourceType}`);
}
@@ -90,9 +95,11 @@ const UpdateForm = ({ account, onComplete }: UpdateFormProps) => {
switch (account.resource.resourceType) {
case PamResourceType.Postgres:
return <PostgresAccountForm account={account} onSubmit={onSubmit} />;
return <PostgresAccountForm account={account as any} onSubmit={onSubmit} />;
case PamResourceType.MySQL:
return <MySQLAccountForm account={account} onSubmit={onSubmit} />;
return <MySQLAccountForm account={account as any} onSubmit={onSubmit} />;
case PamResourceType.SSH:
return <SSHAccountForm account={account as any} onSubmit={onSubmit} />;
default:
throw new Error(`Unhandled resource: ${account.resource.resourceType}`);
}
@@ -0,0 +1,96 @@
import { FormProvider, useForm } from "react-hook-form";
import { zodResolver } from "@hookform/resolvers/zod";
import { z } from "zod";
import { Button, ModalClose } from "@app/components/v2";
import { PamResourceType, TSSHAccount } from "@app/hooks/api/pam";
import { UNCHANGED_PASSWORD_SENTINEL } from "@app/hooks/api/pam/constants";
import { SSHAuthMethod } from "@app/hooks/api/pam/types/ssh-resource";
import { GenericAccountFields, genericAccountFieldsSchema } from "./GenericAccountFields";
import { BaseSshAccountSchema } from "./shared/ssh-account-schemas";
import { SshAccountFields } from "./shared/SshAccountFields";
type Props = {
account?: TSSHAccount;
resourceId?: string;
resourceType?: PamResourceType;
onSubmit: (formData: FormData) => Promise<void>;
};
const formSchema = genericAccountFieldsSchema.extend({
credentials: BaseSshAccountSchema,
// We don't support rotation for now, just feed a false value to
// make the schema happy
rotationEnabled: z.boolean().default(false)
});
type FormData = z.infer<typeof formSchema>;
export const SSHAccountForm = ({ account, onSubmit }: Props) => {
const isUpdate = Boolean(account);
const form = useForm<FormData>({
resolver: zodResolver(formSchema),
defaultValues: account
? {
...account,
credentials:
account.credentials.authMethod === SSHAuthMethod.Password
? {
...account.credentials,
password: UNCHANGED_PASSWORD_SENTINEL
}
: account.credentials.authMethod === SSHAuthMethod.PublicKey
? {
...account.credentials,
privateKey: UNCHANGED_PASSWORD_SENTINEL
}
: account.credentials
}
: {
name: "",
description: "",
credentials: {
authMethod: SSHAuthMethod.Password,
username: "",
password: ""
}
}
});
const {
handleSubmit,
formState: { isSubmitting, isDirty }
} = form;
return (
<FormProvider {...form}>
<form
onSubmit={(e) => {
handleSubmit(onSubmit)(e);
}}
>
<GenericAccountFields />
<SshAccountFields isUpdate={isUpdate} />
<div className="mt-6 flex items-center">
<Button
className="mr-4"
size="sm"
type="submit"
colorSchema="secondary"
isLoading={isSubmitting}
isDisabled={isSubmitting || !isDirty}
>
{isUpdate ? "Update Account" : "Create Account"}
</Button>
<ModalClose asChild>
<Button colorSchema="secondary" variant="plain">
Cancel
</Button>
</ModalClose>
</div>
</form>
</FormProvider>
);
};
@@ -0,0 +1,138 @@
import { useEffect, useState } from "react";
import { Controller, useFormContext, useWatch } from "react-hook-form";
import { FormControl, Input, Select, SelectItem, TextArea } from "@app/components/v2";
import { UNCHANGED_PASSWORD_SENTINEL } from "@app/hooks/api/pam/constants";
import { SSHAuthMethod } from "@app/hooks/api/pam/types/ssh-resource";
export const SshAccountFields = ({ isUpdate }: { isUpdate: boolean }) => {
const { control, setValue } = useFormContext();
const [showPassword, setShowPassword] = useState(false);
const authMethod =
useWatch({ control, name: "credentials.authMethod" }) || SSHAuthMethod.Password;
const password = useWatch({ control, name: "credentials.password" });
useEffect(() => {
if (password === UNCHANGED_PASSWORD_SENTINEL) {
setShowPassword(false);
}
}, [password]);
return (
<div className="mb-4 rounded-sm border border-mineshaft-600 bg-mineshaft-700/70 p-3">
<Controller
name="credentials.authMethod"
control={control}
render={({ field: { value, onChange }, fieldState: { error } }) => (
<FormControl
className="mb-3"
isError={Boolean(error?.message)}
errorText={error?.message}
label="Authentication Method"
>
<Select
value={value || SSHAuthMethod.Password}
onValueChange={(newAuthMethod) => {
onChange(newAuthMethod);
// Clear out credentials from other auth methods
setValue("credentials.password", undefined, { shouldDirty: true });
setValue("credentials.privateKey", undefined, { shouldDirty: true });
}}
className="w-full border border-mineshaft-500"
>
<SelectItem value={SSHAuthMethod.Password}>Password</SelectItem>
<SelectItem value={SSHAuthMethod.PublicKey}>SSH Key</SelectItem>
<SelectItem value={SSHAuthMethod.Certificate}>Certificate</SelectItem>
</Select>
</FormControl>
)}
/>
<Controller
name="credentials.username"
control={control}
render={({ field, fieldState: { error } }) => (
<FormControl
className="mb-3"
errorText={error?.message}
isError={Boolean(error?.message)}
label="Username"
>
<Input {...field} autoComplete="off" />
</FormControl>
)}
/>
{authMethod === SSHAuthMethod.Password && (
<Controller
name="credentials.password"
control={control}
render={({ field, fieldState: { error } }) => (
<FormControl
className="mb-0"
errorText={error?.message}
isError={Boolean(error?.message)}
label="Password"
>
<Input
{...field}
type={showPassword ? "text" : "password"}
autoComplete="new-password"
onFocus={() => {
if (isUpdate && field.value === UNCHANGED_PASSWORD_SENTINEL) {
field.onChange("");
}
setShowPassword(true);
}}
onBlur={() => {
if (isUpdate && field.value === "") {
field.onChange(UNCHANGED_PASSWORD_SENTINEL);
}
setShowPassword(false);
}}
/>
</FormControl>
)}
/>
)}
{authMethod === SSHAuthMethod.PublicKey && (
<Controller
name="credentials.privateKey"
control={control}
render={({ field, fieldState: { error } }) => (
<FormControl
className="mb-0"
errorText={error?.message}
isError={Boolean(error?.message)}
label="Private Key"
>
<TextArea
{...field}
className="min-h-32 resize-y font-mono text-xs"
placeholder="-----BEGIN OPENSSH PRIVATE KEY-----&#10;...&#10;-----END OPENSSH PRIVATE KEY-----"
onFocus={() => {
if (isUpdate && field.value === UNCHANGED_PASSWORD_SENTINEL) {
field.onChange("");
}
}}
onBlur={() => {
if (isUpdate && field.value === "") {
field.onChange(UNCHANGED_PASSWORD_SENTINEL);
}
}}
/>
</FormControl>
)}
/>
)}
{authMethod === SSHAuthMethod.Certificate && (
<p className="mb-0 text-xs text-mineshaft-400">
Certificate-based authentication will use the certificate configured on the SSH resource.
</p>
)}
</div>
);
};
@@ -0,0 +1,26 @@
import { z } from "zod";
import { SSHAuthMethod } from "@app/hooks/api/pam/types/ssh-resource";
export const SSHPasswordCredentialsSchema = z.object({
authMethod: z.literal(SSHAuthMethod.Password),
username: z.string().trim().min(1, "Username is required"),
password: z.string().trim().min(1, "Password is required")
});
export const SSHPublicKeyCredentialsSchema = z.object({
authMethod: z.literal(SSHAuthMethod.PublicKey),
username: z.string().trim().min(1, "Username is required"),
privateKey: z.string().trim().min(1, "Private key is required")
});
export const SSHCertificateCredentialsSchema = z.object({
authMethod: z.literal(SSHAuthMethod.Certificate),
username: z.string().trim().min(1, "Username is required")
});
export const BaseSshAccountSchema = z.discriminatedUnion("authMethod", [
SSHPasswordCredentialsSchema,
SSHPublicKeyCredentialsSchema,
SSHCertificateCredentialsSchema
]);
@@ -11,6 +11,7 @@ import { DiscriminativePick } from "@app/types";
import { PamResourceHeader } from "../PamResourceHeader";
import { MySQLResourceForm } from "./MySQLResourceForm";
import { PostgresResourceForm } from "./PostgresResourceForm";
import { SSHResourceForm } from "./SSHResourceForm";
type FormProps = {
onComplete: (resource: TPamResource) => void;
@@ -51,6 +52,8 @@ const CreateForm = ({ resourceType, onComplete, projectId }: CreateFormProps) =>
return <PostgresResourceForm onSubmit={onSubmit} />;
case PamResourceType.MySQL:
return <MySQLResourceForm onSubmit={onSubmit} />;
case PamResourceType.SSH:
return <SSHResourceForm onSubmit={onSubmit} />;
default:
throw new Error(`Unhandled resource: ${resourceType}`);
}
@@ -79,6 +82,8 @@ const UpdateForm = ({ resource, onComplete }: UpdateFormProps) => {
return <PostgresResourceForm resource={resource} onSubmit={onSubmit} />;
case PamResourceType.MySQL:
return <MySQLResourceForm resource={resource} onSubmit={onSubmit} />;
case PamResourceType.SSH:
return <SSHResourceForm resource={resource} onSubmit={onSubmit} />;
default:
throw new Error(`Unhandled resource: ${(resource as any).resourceType}`);
}
@@ -0,0 +1,68 @@
import { FormProvider, useForm } from "react-hook-form";
import { zodResolver } from "@hookform/resolvers/zod";
import { z } from "zod";
import { Button, ModalClose } from "@app/components/v2";
import { PamResourceType, TSSHResource } from "@app/hooks/api/pam";
import { GenericResourceFields, genericResourceFieldsSchema } from "./GenericResourceFields";
import { BaseSshConnectionDetailsSchema } from "./shared/ssh-resource-schemas";
import { SshResourceFields } from "./shared/SshResourceFields";
type Props = {
resource?: TSSHResource;
onSubmit: (formData: FormData) => Promise<void>;
};
const formSchema = genericResourceFieldsSchema.extend({
resourceType: z.literal(PamResourceType.SSH),
connectionDetails: BaseSshConnectionDetailsSchema
});
type FormData = z.infer<typeof formSchema>;
export const SSHResourceForm = ({ resource, onSubmit }: Props) => {
const isUpdate = Boolean(resource);
const form = useForm<FormData>({
resolver: zodResolver(formSchema),
defaultValues: resource ?? {
resourceType: PamResourceType.SSH,
connectionDetails: {
host: "",
port: 22
}
}
});
const {
handleSubmit,
formState: { isSubmitting, isDirty }
} = form;
return (
<FormProvider {...form}>
<form onSubmit={handleSubmit(onSubmit)}>
<GenericResourceFields />
<SshResourceFields />
<div className="mt-6 flex items-center">
<Button
className="mr-4"
size="sm"
type="submit"
colorSchema="secondary"
isLoading={isSubmitting}
isDisabled={isSubmitting || !isDirty}
>
{isUpdate ? "Update Details" : "Create Resource"}
</Button>
<ModalClose asChild>
<Button colorSchema="secondary" variant="plain">
Cancel
</Button>
</ModalClose>
</div>
</form>
</FormProvider>
);
};
@@ -0,0 +1,42 @@
import { Controller, useFormContext } from "react-hook-form";
import { FormControl, Input } from "@app/components/v2";
export const SshResourceFields = () => {
const { control } = useFormContext();
return (
<div className="mb-4 rounded-sm border border-mineshaft-600 bg-mineshaft-700/70 p-3">
<div className="mt-[0.675rem] flex items-start gap-2">
<Controller
name="connectionDetails.host"
control={control}
render={({ field, fieldState: { error } }) => (
<FormControl
className="flex-1"
errorText={error?.message}
isError={Boolean(error?.message)}
label="Host"
>
<Input placeholder="example.com or 192.168.1.1" {...field} />
</FormControl>
)}
/>
<Controller
name="connectionDetails.port"
control={control}
render={({ field, fieldState: { error } }) => (
<FormControl
className="w-28"
errorText={error?.message}
isError={Boolean(error?.message)}
label="Port"
>
<Input type="number" {...field} />
</FormControl>
)}
/>
</div>
</div>
);
};
@@ -0,0 +1,31 @@
import { z } from "zod";
import { SSHAuthMethod } from "@app/hooks/api/pam/types/ssh-resource";
export const BaseSshConnectionDetailsSchema = z.object({
host: z.string().trim().min(1, "Host is required"),
port: z.number().int().min(1).max(65535)
});
export const SSHPasswordCredentialsSchema = z.object({
authMethod: z.literal(SSHAuthMethod.Password),
username: z.string().trim().min(1, "Username is required"),
password: z.string().trim().min(1, "Password is required")
});
export const SSHPublicKeyCredentialsSchema = z.object({
authMethod: z.literal(SSHAuthMethod.PublicKey),
username: z.string().trim().min(1, "Username is required"),
privateKey: z.string().trim().min(1, "Private key is required")
});
export const SSHCertificateCredentialsSchema = z.object({
authMethod: z.literal(SSHAuthMethod.Certificate),
username: z.string().trim().min(1, "Username is required")
});
export const BaseSshAccountSchema = z.discriminatedUnion("authMethod", [
SSHPasswordCredentialsSchema,
SSHPublicKeyCredentialsSchema,
SSHCertificateCredentialsSchema
]);
@@ -78,7 +78,6 @@ export const ResourceTypeSelect = ({ onSelect }: Props) => {
// We temporarily show a special license modal for these because we will have to write some code to complete the integration
if (
resource === PamResourceType.RDP ||
resource === PamResourceType.SSH ||
resource === PamResourceType.Kubernetes ||
resource === PamResourceType.MCP ||
resource === PamResourceType.Redis ||