mirror of
https://github.com/awatertrevi/infisical.git
synced 2026-10-09 16:28:19 +00:00
feat: ssh pam draft
This commit is contained in:
@@ -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>
|
||||
);
|
||||
};
|
||||
+138
@@ -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----- ... -----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>
|
||||
);
|
||||
};
|
||||
+26
@@ -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}`);
|
||||
}
|
||||
|
||||
+68
@@ -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>
|
||||
);
|
||||
};
|
||||
+42
@@ -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>
|
||||
);
|
||||
};
|
||||
+31
@@ -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 ||
|
||||
|
||||
Reference in New Issue
Block a user