diff --git a/backend/package-lock.json b/backend/package-lock.json index a871c38b1..808cf3204 100644 --- a/backend/package-lock.json +++ b/backend/package-lock.json @@ -128,6 +128,7 @@ "sjcl": "^1.0.8", "smee-client": "^2.0.0", "snowflake-sdk": "^1.14.0", + "ssh2": "^1.17.0", "tedious": "^18.2.1", "tweetnacl": "^1.0.3", "tweetnacl-util": "^0.15.1", @@ -164,6 +165,7 @@ "@types/resolve": "^1.20.6", "@types/safe-regex": "^1.1.6", "@types/sjcl": "^1.0.34", + "@types/ssh2": "^1.15.5", "@types/uuid": "^9.0.7", "@typescript-eslint/eslint-plugin": "^6.20.0", "@typescript-eslint/parser": "^6.20.0", @@ -15634,6 +15636,33 @@ "dev": true, "license": "MIT" }, + "node_modules/@types/ssh2": { + "version": "1.15.5", + "resolved": "https://registry.npmjs.org/@types/ssh2/-/ssh2-1.15.5.tgz", + "integrity": "sha512-N1ASjp/nXH3ovBHddRJpli4ozpk6UdDYIX4RJWFa9L1YKnzdhTlVmiGHm4DZnj/jLbqZpes4aeR30EFGQtvhQQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/node": "^18.11.18" + } + }, + "node_modules/@types/ssh2/node_modules/@types/node": { + "version": "18.19.130", + "resolved": "https://registry.npmjs.org/@types/node/-/node-18.19.130.tgz", + "integrity": "sha512-GRaXQx6jGfL8sKfaIDD6OupbIHBr9jv7Jnaml9tB7l4v068PAOXqfcujMMo5PhbIs6ggR1XODELqahT2R8v0fg==", + "dev": true, + "license": "MIT", + "dependencies": { + "undici-types": "~5.26.4" + } + }, + "node_modules/@types/ssh2/node_modules/undici-types": { + "version": "5.26.5", + "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-5.26.5.tgz", + "integrity": "sha512-JlCMO+ehdEIKqlFxk6IfVoAUVmgz7cU7zD/h9XZ0qzeosSHmUJVOzSQvvYSYWXkFXC+IfLKSIffhv0sVZup6pA==", + "dev": true, + "license": "MIT" + }, "node_modules/@types/sshpk": { "version": "1.10.3", "resolved": "https://registry.npmjs.org/@types/sshpk/-/sshpk-1.10.3.tgz", @@ -18061,6 +18090,15 @@ "dev": true, "license": "MIT" }, + "node_modules/buildcheck": { + "version": "0.0.6", + "resolved": "https://registry.npmjs.org/buildcheck/-/buildcheck-0.0.6.tgz", + "integrity": "sha512-8f9ZJCUXyT1M35Jx7MkBgmBMo3oHTTBIPLiY9xyL0pl3T5RwcPEY8cUHr5LBNfu/fk6c2T4DJZuVM/8ZZT2D2A==", + "optional": true, + "engines": { + "node": ">=10.0.0" + } + }, "node_modules/bullmq": { "version": "5.4.2", "resolved": "https://registry.npmjs.org/bullmq/-/bullmq-5.4.2.tgz", @@ -18901,6 +18939,20 @@ "node": ">= 0.10" } }, + "node_modules/cpu-features": { + "version": "0.0.10", + "resolved": "https://registry.npmjs.org/cpu-features/-/cpu-features-0.0.10.tgz", + "integrity": "sha512-9IkYqtX3YHPCzoVg1Py+o9057a3i0fp7S530UWokCSaFVTc7CwXPRiOjRjBQQ18ZCNafx78YfnG+HALxtVmOGA==", + "hasInstallScript": true, + "optional": true, + "dependencies": { + "buildcheck": "~0.0.6", + "nan": "^2.19.0" + }, + "engines": { + "node": ">=10.0.0" + } + }, "node_modules/create-hash": { "version": "1.2.0", "resolved": "https://registry.npmjs.org/create-hash/-/create-hash-1.2.0.tgz", @@ -24996,9 +25048,9 @@ } }, "node_modules/nan": { - "version": "2.22.2", - "resolved": "https://registry.npmjs.org/nan/-/nan-2.22.2.tgz", - "integrity": "sha512-DANghxFkS1plDdRsX0X9pm0Z6SJNN6gBdtXfanwoZ8hooC5gosGFSBGRYHUVPz1asKA/kMRqDRdHrluZ61SpBQ==", + "version": "2.23.1", + "resolved": "https://registry.npmjs.org/nan/-/nan-2.23.1.tgz", + "integrity": "sha512-r7bBUGKzlqk8oPBDYxt6Z0aEdF1G1rwlMcLk8LCOMbOzf0mG+JUfUzG4fIMWwHWP0iyaLWEQZJmtB7nOHEm/qw==", "license": "MIT" }, "node_modules/nanoid": { @@ -31492,6 +31544,23 @@ "node": ">= 0.6" } }, + "node_modules/ssh2": { + "version": "1.17.0", + "resolved": "https://registry.npmjs.org/ssh2/-/ssh2-1.17.0.tgz", + "integrity": "sha512-wPldCk3asibAjQ/kziWQQt1Wh3PgDFpC0XpwclzKcdT1vql6KeYxf5LIt4nlFkUeR8WuphYMKqUA56X4rjbfgQ==", + "hasInstallScript": true, + "dependencies": { + "asn1": "^0.2.6", + "bcrypt-pbkdf": "^1.0.2" + }, + "engines": { + "node": ">=10.16.0" + }, + "optionalDependencies": { + "cpu-features": "~0.0.10", + "nan": "^2.23.0" + } + }, "node_modules/sshpk": { "version": "1.16.1", "resolved": "https://registry.npmjs.org/sshpk/-/sshpk-1.16.1.tgz", diff --git a/backend/package.json b/backend/package.json index aa97de2ed..9a0d9772d 100644 --- a/backend/package.json +++ b/backend/package.json @@ -110,6 +110,7 @@ "@types/resolve": "^1.20.6", "@types/safe-regex": "^1.1.6", "@types/sjcl": "^1.0.34", + "@types/ssh2": "^1.15.5", "@types/uuid": "^9.0.7", "@typescript-eslint/eslint-plugin": "^6.20.0", "@typescript-eslint/parser": "^6.20.0", @@ -257,6 +258,7 @@ "sjcl": "^1.0.8", "smee-client": "^2.0.0", "snowflake-sdk": "^1.14.0", + "ssh2": "^1.17.0", "tedious": "^18.2.1", "tweetnacl": "^1.0.3", "tweetnacl-util": "^0.15.1", diff --git a/backend/src/ee/routes/v1/pam-account-routers/index.ts b/backend/src/ee/routes/v1/pam-account-routers/index.ts index 60d621467..d3aadd5a4 100644 --- a/backend/src/ee/routes/v1/pam-account-routers/index.ts +++ b/backend/src/ee/routes/v1/pam-account-routers/index.ts @@ -9,6 +9,11 @@ import { SanitizedPostgresAccountWithResourceSchema, UpdatePostgresAccountSchema } from "@app/ee/services/pam-resource/postgres/postgres-resource-schemas"; +import { + CreateSSHAccountSchema, + SanitizedSSHAccountWithResourceSchema, + UpdateSSHAccountSchema +} from "@app/ee/services/pam-resource/ssh/ssh-resource-schemas"; import { registerPamResourceEndpoints } from "./pam-account-endpoints"; @@ -30,5 +35,14 @@ export const PAM_ACCOUNT_REGISTER_ROUTER_MAP: Record { + registerPamResourceEndpoints({ + server, + resourceType: PamResource.SSH, + accountResponseSchema: SanitizedSSHAccountWithResourceSchema, + createAccountSchema: CreateSSHAccountSchema, + updateAccountSchema: UpdateSSHAccountSchema + }); } }; diff --git a/backend/src/ee/routes/v1/pam-account-routers/pam-account-router.ts b/backend/src/ee/routes/v1/pam-account-routers/pam-account-router.ts index 286e0896f..c28770be4 100644 --- a/backend/src/ee/routes/v1/pam-account-routers/pam-account-router.ts +++ b/backend/src/ee/routes/v1/pam-account-routers/pam-account-router.ts @@ -5,6 +5,7 @@ import { EventType } from "@app/ee/services/audit-log/audit-log-types"; import { SanitizedMySQLAccountWithResourceSchema } from "@app/ee/services/pam-resource/mysql/mysql-resource-schemas"; import { PamResource } from "@app/ee/services/pam-resource/pam-resource-enums"; import { SanitizedPostgresAccountWithResourceSchema } from "@app/ee/services/pam-resource/postgres/postgres-resource-schemas"; +import { SanitizedSSHAccountWithResourceSchema } from "@app/ee/services/pam-resource/ssh/ssh-resource-schemas"; import { BadRequestError } from "@app/lib/errors"; import { ms } from "@app/lib/ms"; import { readLimit, writeLimit } from "@app/server/config/rateLimiter"; @@ -12,6 +13,7 @@ import { verifyAuth } from "@app/server/plugins/auth/verify-auth"; import { AuthMode } from "@app/services/auth/auth-type"; const SanitizedAccountSchema = z.union([ + SanitizedSSHAccountWithResourceSchema, // ORDER MATTERS SanitizedPostgresAccountWithResourceSchema, SanitizedMySQLAccountWithResourceSchema ]); @@ -93,7 +95,7 @@ export const registerPamAccountRouter = async (server: FastifyZodProvider) => { gatewayClientPrivateKey: z.string(), gatewayServerCertificateChain: z.string(), relayHost: z.string(), - metadata: z.record(z.string(), z.string()).optional() + metadata: z.record(z.string(), z.string().optional()).optional() }) } }, diff --git a/backend/src/ee/routes/v1/pam-resource-routers/index.ts b/backend/src/ee/routes/v1/pam-resource-routers/index.ts index 821532598..5dae317da 100644 --- a/backend/src/ee/routes/v1/pam-resource-routers/index.ts +++ b/backend/src/ee/routes/v1/pam-resource-routers/index.ts @@ -9,6 +9,11 @@ import { SanitizedPostgresResourceSchema, UpdatePostgresResourceSchema } from "@app/ee/services/pam-resource/postgres/postgres-resource-schemas"; +import { + CreateSSHResourceSchema, + SanitizedSSHResourceSchema, + UpdateSSHResourceSchema +} from "@app/ee/services/pam-resource/ssh/ssh-resource-schemas"; import { registerPamResourceEndpoints } from "./pam-resource-endpoints"; @@ -30,5 +35,14 @@ export const PAM_RESOURCE_REGISTER_ROUTER_MAP: Record { + registerPamResourceEndpoints({ + server, + resourceType: PamResource.SSH, + resourceResponseSchema: SanitizedSSHResourceSchema, + createResourceSchema: CreateSSHResourceSchema, + updateResourceSchema: UpdateSSHResourceSchema + }); } }; diff --git a/backend/src/ee/routes/v1/pam-resource-routers/pam-resource-router.ts b/backend/src/ee/routes/v1/pam-resource-routers/pam-resource-router.ts index 6563c86c7..8cac0525b 100644 --- a/backend/src/ee/routes/v1/pam-resource-routers/pam-resource-router.ts +++ b/backend/src/ee/routes/v1/pam-resource-routers/pam-resource-router.ts @@ -9,15 +9,24 @@ import { PostgresResourceListItemSchema, SanitizedPostgresResourceSchema } from "@app/ee/services/pam-resource/postgres/postgres-resource-schemas"; +import { + SanitizedSSHResourceSchema, + SSHResourceListItemSchema +} from "@app/ee/services/pam-resource/ssh/ssh-resource-schemas"; import { readLimit } from "@app/server/config/rateLimiter"; import { verifyAuth } from "@app/server/plugins/auth/verify-auth"; import { AuthMode } from "@app/services/auth/auth-type"; -const SanitizedResourceSchema = z.union([SanitizedPostgresResourceSchema, SanitizedMySQLResourceSchema]); +const SanitizedResourceSchema = z.union([ + SanitizedPostgresResourceSchema, + SanitizedMySQLResourceSchema, + SanitizedSSHResourceSchema +]); const ResourceOptionsSchema = z.discriminatedUnion("resource", [ PostgresResourceListItemSchema, - MySQLResourceListItemSchema + MySQLResourceListItemSchema, + SSHResourceListItemSchema ]); export const registerPamResourceRouter = async (server: FastifyZodProvider) => { diff --git a/backend/src/ee/routes/v1/pam-session-router.ts b/backend/src/ee/routes/v1/pam-session-router.ts index 5fe10e434..cc8969c92 100644 --- a/backend/src/ee/routes/v1/pam-session-router.ts +++ b/backend/src/ee/routes/v1/pam-session-router.ts @@ -4,12 +4,21 @@ import { PamSessionsSchema } from "@app/db/schemas"; import { EventType } from "@app/ee/services/audit-log/audit-log-types"; import { MySQLSessionCredentialsSchema } from "@app/ee/services/pam-resource/mysql/mysql-resource-schemas"; import { PostgresSessionCredentialsSchema } from "@app/ee/services/pam-resource/postgres/postgres-resource-schemas"; -import { PamSessionCommandLogSchema, SanitizedSessionSchema } from "@app/ee/services/pam-session/pam-session-schemas"; +import { SSHSessionCredentialsSchema } from "@app/ee/services/pam-resource/ssh/ssh-resource-schemas"; +import { + PamSessionCommandLogSchema, + SanitizedSessionSchema, + TerminalEventSchema +} from "@app/ee/services/pam-session/pam-session-schemas"; import { readLimit, writeLimit } from "@app/server/config/rateLimiter"; import { verifyAuth } from "@app/server/plugins/auth/verify-auth"; import { AuthMode } from "@app/services/auth/auth-type"; -const SessionCredentialsSchema = z.union([PostgresSessionCredentialsSchema, MySQLSessionCredentialsSchema]); +const SessionCredentialsSchema = z.union([ + SSHSessionCredentialsSchema, + PostgresSessionCredentialsSchema, + MySQLSessionCredentialsSchema +]); export const registerPamSessionRouter = async (server: FastifyZodProvider) => { // Meant to be hit solely by gateway identities @@ -50,7 +59,7 @@ export const registerPamSessionRouter = async (server: FastifyZodProvider) => { } }); - return { credentials }; + return { credentials: credentials as z.infer }; } }); @@ -67,7 +76,7 @@ export const registerPamSessionRouter = async (server: FastifyZodProvider) => { sessionId: z.string().uuid() }), body: z.object({ - logs: PamSessionCommandLogSchema.array() + logs: z.array(z.union([PamSessionCommandLogSchema, TerminalEventSchema])) }), response: { 200: z.object({ diff --git a/backend/src/ee/services/pam-account/pam-account-service.ts b/backend/src/ee/services/pam-account/pam-account-service.ts index 2f66d28d7..6f1805300 100644 --- a/backend/src/ee/services/pam-account/pam-account-service.ts +++ b/backend/src/ee/services/pam-account/pam-account-service.ts @@ -27,6 +27,7 @@ import { getFullPamFolderPath } from "../pam-folder/pam-folder-fns"; import { TPamResourceDALFactory } from "../pam-resource/pam-resource-dal"; import { PamResource } from "../pam-resource/pam-resource-enums"; import { TPamAccountCredentials } from "../pam-resource/pam-resource-types"; +import { TSqlResourceConnectionDetails } from "../pam-resource/shared/sql/sql-resource-types"; import { TPamSessionDALFactory } from "../pam-session/pam-session-dal"; import { PamSessionStatus } from "../pam-session/pam-session-enums"; import { OrgPermissionGatewayActions, OrgPermissionSubjects } from "../permission/org-permission"; @@ -251,17 +252,17 @@ export const pamAccountServiceFactory = ({ gatewayV2Service ); - // Logic to prevent overwriting unedited censored values - const finalCredentials = { ...credentials }; - if (credentials.password === "__INFISICAL_UNCHANGED__") { - const decryptedCredentials = await decryptAccountCredentials({ - encryptedCredentials: account.encryptedCredentials, - projectId: account.projectId, - kmsService - }); + const decryptedCredentials = await decryptAccountCredentials({ + encryptedCredentials: account.encryptedCredentials, + projectId: account.projectId, + kmsService + }); - finalCredentials.password = decryptedCredentials.password; - } + // Logic to prevent overwriting unedited censored values + const finalCredentials = await factory.handleOverwritePreventionForCensoredValues( + credentials, + decryptedCredentials + ); const validatedCredentials = await factory.validateAccountCredentials(finalCredentials); const encryptedCredentials = await encryptAccountCredentials({ @@ -486,11 +487,11 @@ export const pamAccountServiceFactory = ({ case PamResource.Postgres: case PamResource.MySQL: { - const connectionCredentials = await decryptResourceConnectionDetails({ + const connectionCredentials = (await decryptResourceConnectionDetails({ encryptedConnectionDetails: resource.encryptedConnectionDetails, kmsService, projectId: account.projectId - }); + })) as TSqlResourceConnectionDetails; const credentials = await decryptAccountCredentials({ encryptedCredentials: account.encryptedCredentials, @@ -506,6 +507,19 @@ export const pamAccountServiceFactory = ({ }; } break; + case PamResource.SSH: + { + const credentials = await decryptAccountCredentials({ + encryptedCredentials: account.encryptedCredentials, + kmsService, + projectId: account.projectId + }); + + metadata = { + username: credentials.username + }; + } + break; default: break; } diff --git a/backend/src/ee/services/pam-resource/pam-resource-enums.ts b/backend/src/ee/services/pam-resource/pam-resource-enums.ts index dff1cc650..e913a1a09 100644 --- a/backend/src/ee/services/pam-resource/pam-resource-enums.ts +++ b/backend/src/ee/services/pam-resource/pam-resource-enums.ts @@ -1,4 +1,5 @@ export enum PamResource { Postgres = "postgres", - MySQL = "mysql" + MySQL = "mysql", + SSH = "ssh" } diff --git a/backend/src/ee/services/pam-resource/pam-resource-factory.ts b/backend/src/ee/services/pam-resource/pam-resource-factory.ts index 151fa7ea1..e2d0a50f8 100644 --- a/backend/src/ee/services/pam-resource/pam-resource-factory.ts +++ b/backend/src/ee/services/pam-resource/pam-resource-factory.ts @@ -1,10 +1,12 @@ import { PamResource } from "./pam-resource-enums"; import { TPamAccountCredentials, TPamResourceConnectionDetails, TPamResourceFactory } from "./pam-resource-types"; import { sqlResourceFactory } from "./shared/sql/sql-resource-factory"; +import { sshResourceFactory } from "./ssh/ssh-resource-factory"; type TPamResourceFactoryImplementation = TPamResourceFactory; export const PAM_RESOURCE_FACTORY_MAP: Record = { [PamResource.Postgres]: sqlResourceFactory as TPamResourceFactoryImplementation, - [PamResource.MySQL]: sqlResourceFactory as TPamResourceFactoryImplementation + [PamResource.MySQL]: sqlResourceFactory as TPamResourceFactoryImplementation, + [PamResource.SSH]: sshResourceFactory as TPamResourceFactoryImplementation }; diff --git a/backend/src/ee/services/pam-resource/pam-resource-service.ts b/backend/src/ee/services/pam-resource/pam-resource-service.ts index d97905dbe..8d3fd8cbe 100644 --- a/backend/src/ee/services/pam-resource/pam-resource-service.ts +++ b/backend/src/ee/services/pam-resource/pam-resource-service.ts @@ -192,19 +192,18 @@ export const pamResourceServiceFactory = ({ gatewayV2Service ); - // Logic to prevent overwriting unedited censored values - const finalCredentials = { ...rotationAccountCredentials }; - if ( - resource.encryptedRotationAccountCredentials && - rotationAccountCredentials.password === "__INFISICAL_UNCHANGED__" - ) { + let finalCredentials = { ...rotationAccountCredentials }; + if (resource.encryptedRotationAccountCredentials) { const decryptedCredentials = await decryptAccountCredentials({ encryptedCredentials: resource.encryptedRotationAccountCredentials, projectId: resource.projectId, kmsService }); - finalCredentials.password = decryptedCredentials.password; + finalCredentials = await factory.handleOverwritePreventionForCensoredValues( + rotationAccountCredentials, + decryptedCredentials + ); } try { diff --git a/backend/src/ee/services/pam-resource/pam-resource-types.ts b/backend/src/ee/services/pam-resource/pam-resource-types.ts index 1ca9db3e2..2a36f17bc 100644 --- a/backend/src/ee/services/pam-resource/pam-resource-types.ts +++ b/backend/src/ee/services/pam-resource/pam-resource-types.ts @@ -12,15 +12,24 @@ import { TPostgresResource, TPostgresResourceConnectionDetails } from "./postgres/postgres-resource-types"; +import { + TSSHAccount, + TSSHAccountCredentials, + TSSHResource, + TSSHResourceConnectionDetails +} from "./ssh/ssh-resource-types"; // Resource types -export type TPamResource = TPostgresResource | TMySQLResource; -export type TPamResourceConnectionDetails = TPostgresResourceConnectionDetails | TMySQLResourceConnectionDetails; +export type TPamResource = TPostgresResource | TMySQLResource | TSSHResource; +export type TPamResourceConnectionDetails = + | TPostgresResourceConnectionDetails + | TMySQLResourceConnectionDetails + | TSSHResourceConnectionDetails; // Account types -export type TPamAccount = TPostgresAccount | TMySQLAccount; +export type TPamAccount = TPostgresAccount | TMySQLAccount | TSSHAccount; // eslint-disable-next-line @typescript-eslint/no-duplicate-type-constituents -export type TPamAccountCredentials = TPostgresAccountCredentials | TMySQLAccountCredentials; +export type TPamAccountCredentials = TPostgresAccountCredentials | TMySQLAccountCredentials | TSSHAccountCredentials; // Resource DTOs export type TCreateResourceDTO = Pick< @@ -51,4 +60,5 @@ export type TPamResourceFactory; validateAccountCredentials: TPamResourceFactoryValidateAccountCredentials; rotateAccountCredentials: TPamResourceFactoryRotateAccountCredentials; + handleOverwritePreventionForCensoredValues: (updatedAccountCredentials: C, currentCredentials: C) => Promise; }; diff --git a/backend/src/ee/services/pam-resource/shared/sql/sql-resource-factory.ts b/backend/src/ee/services/pam-resource/shared/sql/sql-resource-factory.ts index 7dd7948ef..b3128c422 100644 --- a/backend/src/ee/services/pam-resource/shared/sql/sql-resource-factory.ts +++ b/backend/src/ee/services/pam-resource/shared/sql/sql-resource-factory.ts @@ -337,9 +337,24 @@ export const sqlResourceFactory: TPamResourceFactory { + if (updatedAccountCredentials.password === "__INFISICAL_UNCHANGED__") { + return { + ...updatedAccountCredentials, + password: currentCredentials.password + }; + } + + return updatedAccountCredentials; + }; + return { validateConnection, validateAccountCredentials, - rotateAccountCredentials + rotateAccountCredentials, + handleOverwritePreventionForCensoredValues }; }; diff --git a/backend/src/ee/services/pam-resource/ssh/ssh-resource-enums.ts b/backend/src/ee/services/pam-resource/ssh/ssh-resource-enums.ts new file mode 100644 index 000000000..9b6ed1f15 --- /dev/null +++ b/backend/src/ee/services/pam-resource/ssh/ssh-resource-enums.ts @@ -0,0 +1,5 @@ +export enum SSHAuthMethod { + Password = "password", + PublicKey = "public-key", + Certificate = "certificate" +} diff --git a/backend/src/ee/services/pam-resource/ssh/ssh-resource-factory.ts b/backend/src/ee/services/pam-resource/ssh/ssh-resource-factory.ts new file mode 100644 index 000000000..b90aa00c6 --- /dev/null +++ b/backend/src/ee/services/pam-resource/ssh/ssh-resource-factory.ts @@ -0,0 +1,265 @@ +import { Client } from "ssh2"; + +import { BadRequestError } from "@app/lib/errors"; +import { GatewayProxyProtocol } from "@app/lib/gateway"; +import { withGatewayV2Proxy } from "@app/lib/gateway-v2/gateway-v2"; +import { logger } from "@app/lib/logger"; + +import { verifyHostInputValidity } from "../../dynamic-secret/dynamic-secret-fns"; +import { TGatewayV2ServiceFactory } from "../../gateway-v2/gateway-v2-service"; +import { PamResource } from "../pam-resource-enums"; +import { + TPamResourceFactory, + TPamResourceFactoryRotateAccountCredentials, + TPamResourceFactoryValidateAccountCredentials +} from "../pam-resource-types"; +import { SSHAuthMethod } from "./ssh-resource-enums"; +import { TSSHAccountCredentials, TSSHResourceConnectionDetails } from "./ssh-resource-types"; + +const EXTERNAL_REQUEST_TIMEOUT = 10 * 1000; + +export const executeWithGateway = async ( + config: { + connectionDetails: TSSHResourceConnectionDetails; + resourceType: PamResource; + gatewayId: string; + }, + gatewayV2Service: Pick, + operation: (proxyPort: number) => Promise +): Promise => { + const { connectionDetails, gatewayId } = config; + const [targetHost] = await verifyHostInputValidity(connectionDetails.host, true); + const platformConnectionDetails = await gatewayV2Service.getPlatformConnectionDetailsByGatewayId({ + gatewayId, + targetHost, + targetPort: connectionDetails.port + }); + + if (!platformConnectionDetails) { + throw new BadRequestError({ message: "Unable to connect to gateway, no platform connection details found" }); + } + + return withGatewayV2Proxy( + async (proxyPort) => { + return operation(proxyPort); + }, + { + protocol: GatewayProxyProtocol.Tcp, + relayHost: platformConnectionDetails.relayHost, + gateway: platformConnectionDetails.gateway, + relay: platformConnectionDetails.relay + } + ); +}; + +export const sshResourceFactory: TPamResourceFactory = ( + resourceType, + connectionDetails, + gatewayId, + gatewayV2Service +) => { + const validateConnection = async () => { + try { + await executeWithGateway({ connectionDetails, gatewayId, resourceType }, gatewayV2Service, async (proxyPort) => { + return new Promise((resolve, reject) => { + const client = new Client(); + let handshakeComplete = false; + + client.on("error", (err) => { + logger.info( + { error: err.message, handshakeComplete }, + "[SSH Resource Factory] SSH client error event received" + ); + // If we got an authentication error, it means we successfully reached the SSH server + // and completed the SSH handshake - that's good enough for connection validation + if (handshakeComplete || err.message.includes("authentication") || err.message.includes("publickey")) { + logger.info( + { handshakeComplete, errorMessage: err.message }, + "[SSH Resource Factory] SSH connection validation succeeded (auth error after handshake)" + ); + client.end(); + resolve(); + } else { + logger.error( + { error: err.message, handshakeComplete }, + "[SSH Resource Factory] SSH connection validation failed" + ); + reject(err); + } + }); + + client.on("handshake", () => { + // SSH handshake completed - the server is reachable and responding + logger.info("[SSH Resource Factory] SSH handshake event received - setting handshakeComplete to true"); + handshakeComplete = true; + client.end(); + resolve(); + }); + + client.on("timeout", () => { + logger.error("[SSH Resource Factory] SSH connection timeout"); + reject(new Error("Connection timeout")); + }); + + // Attempt connection with a dummy username (we don't care about auth success) + // The goal is just to verify SSH server is reachable and responding + client.connect({ + host: "localhost", + port: proxyPort, + username: "infisical-connection-test", + password: "infisical-connection-test-password", + readyTimeout: EXTERNAL_REQUEST_TIMEOUT, + tryKeyboard: false, + // We want to fail fast on auth, we're just testing reachability + authHandler: () => { + // If authHandler is called, SSH handshake succeeded + handshakeComplete = true; + return false; // Don't continue with auth + } + }); + }); + }); + return connectionDetails; + } catch (error) { + throw new BadRequestError({ + message: `Unable to validate connection to ${resourceType}: ${(error as Error).message || String(error)}` + }); + } + }; + + const validateAccountCredentials: TPamResourceFactoryValidateAccountCredentials = async ( + credentials + ) => { + try { + await executeWithGateway({ connectionDetails, gatewayId, resourceType }, gatewayV2Service, async (proxyPort) => { + return new Promise((resolve, reject) => { + const client = new Client(); + + client.on("ready", () => { + logger.info( + { username: credentials.username, authMethod: credentials.authMethod }, + "[SSH Resource Factory] SSH authentication successful" + ); + client.end(); + resolve(); + }); + + client.on("error", (err) => { + logger.error( + { error: err.message, username: credentials.username, authMethod: credentials.authMethod }, + "[SSH Resource Factory] SSH authentication failed" + ); + reject(err); + }); + + client.on("timeout", () => { + logger.error( + { username: credentials.username, authMethod: credentials.authMethod }, + "[SSH Resource Factory] SSH authentication timeout" + ); + reject(new Error("Connection timeout")); + }); + + // Build connection config based on auth method + const baseConfig = { + host: "localhost", + port: proxyPort, + username: credentials.username, + readyTimeout: EXTERNAL_REQUEST_TIMEOUT + }; + + switch (credentials.authMethod) { + case SSHAuthMethod.Password: + client.connect({ + ...baseConfig, + password: credentials.password, + tryKeyboard: false + }); + break; + case SSHAuthMethod.PublicKey: + client.connect({ + ...baseConfig, + privateKey: credentials.privateKey, + tryKeyboard: false + }); + break; + default: + reject(new Error(`Unsupported SSH auth method: ${(credentials as TSSHAccountCredentials).authMethod}`)); + } + }); + }); + return credentials; + } catch (error) { + if (error instanceof Error) { + // Check for common authentication failure messages + if ( + error.message.includes("authentication") || + error.message.includes("All configured authentication methods failed") || + error.message.includes("publickey") + ) { + throw new BadRequestError({ + message: "Account credentials invalid." + }); + } + + if (error.message === "Connection timeout") { + throw new BadRequestError({ + message: "Connection timeout. Verify that the SSH server is reachable" + }); + } + } + + throw new BadRequestError({ + message: `Unable to validate account credentials for ${resourceType}: ${(error as Error).message || String(error)}` + }); + } + }; + + const rotateAccountCredentials: TPamResourceFactoryRotateAccountCredentials = async ( + rotationAccountCredentials + ) => { + return rotationAccountCredentials; + }; + + const handleOverwritePreventionForCensoredValues = async ( + updatedAccountCredentials: TSSHAccountCredentials, + currentCredentials: TSSHAccountCredentials + ) => { + if (updatedAccountCredentials.authMethod !== currentCredentials.authMethod) { + return updatedAccountCredentials; + } + + if ( + updatedAccountCredentials.authMethod === SSHAuthMethod.Password && + currentCredentials.authMethod === SSHAuthMethod.Password + ) { + if (updatedAccountCredentials.password === "__INFISICAL_UNCHANGED__") { + return { + ...updatedAccountCredentials, + password: currentCredentials.password + }; + } + } + + if ( + updatedAccountCredentials.authMethod === SSHAuthMethod.PublicKey && + currentCredentials.authMethod === SSHAuthMethod.PublicKey + ) { + if (updatedAccountCredentials.privateKey === "__INFISICAL_UNCHANGED__") { + return { + ...updatedAccountCredentials, + privateKey: currentCredentials.privateKey + }; + } + } + + return updatedAccountCredentials; + }; + + return { + validateConnection, + validateAccountCredentials, + rotateAccountCredentials, + handleOverwritePreventionForCensoredValues + }; +}; diff --git a/backend/src/ee/services/pam-resource/ssh/ssh-resource-schemas.ts b/backend/src/ee/services/pam-resource/ssh/ssh-resource-schemas.ts new file mode 100644 index 000000000..97d462369 --- /dev/null +++ b/backend/src/ee/services/pam-resource/ssh/ssh-resource-schemas.ts @@ -0,0 +1,117 @@ +import { z } from "zod"; + +import { PamResource } from "../pam-resource-enums"; +import { + BaseCreatePamAccountSchema, + BaseCreatePamResourceSchema, + BasePamAccountSchema, + BasePamAccountSchemaWithResource, + BasePamResourceSchema, + BaseUpdatePamAccountSchema, + BaseUpdatePamResourceSchema +} from "../pam-resource-schemas"; +import { SSHAuthMethod } from "./ssh-resource-enums"; + +export const BaseSSHResourceSchema = BasePamResourceSchema.extend({ resourceType: z.literal(PamResource.SSH) }); + +export const SSHResourceListItemSchema = z.object({ + name: z.literal("SSH"), + resource: z.literal(PamResource.SSH) +}); + +export const SSHResourceConnectionDetailsSchema = z.object({ + host: z.string().trim().max(255), + port: z.number() +}); + +export const SSHPasswordCredentialsSchema = z.object({ + authMethod: z.literal(SSHAuthMethod.Password), + username: z.string().trim().max(255), + password: z.string().trim().max(255) +}); + +export const SSHPublicKeyCredentialsSchema = z.object({ + authMethod: z.literal(SSHAuthMethod.PublicKey), + username: z.string().trim().max(255), + privateKey: z.string().trim().max(5000) +}); + +export const SSHCertificateCredentialsSchema = z.object({ + authMethod: z.literal(SSHAuthMethod.Certificate), + username: z.string().trim().max(255) +}); + +export const SSHAccountCredentialsSchema = z.discriminatedUnion("authMethod", [ + SSHPasswordCredentialsSchema, + SSHPublicKeyCredentialsSchema, + SSHCertificateCredentialsSchema +]); + +export const SSHResourceSchema = BaseSSHResourceSchema.extend({ + connectionDetails: SSHResourceConnectionDetailsSchema, + rotationAccountCredentials: SSHAccountCredentialsSchema.nullable().optional() +}); + +export const SanitizedSSHResourceSchema = BaseSSHResourceSchema.extend({ + connectionDetails: SSHResourceConnectionDetailsSchema, + rotationAccountCredentials: z + .discriminatedUnion("authMethod", [ + z.object({ + authMethod: z.literal(SSHAuthMethod.Password), + username: z.string() + }), + z.object({ + authMethod: z.literal(SSHAuthMethod.PublicKey), + username: z.string() + }), + z.object({ + authMethod: z.literal(SSHAuthMethod.Certificate), + username: z.string() + }) + ]) + .nullable() + .optional() +}); + +export const CreateSSHResourceSchema = BaseCreatePamResourceSchema.extend({ + connectionDetails: SSHResourceConnectionDetailsSchema, + rotationAccountCredentials: SSHAccountCredentialsSchema.nullable().optional() +}); + +export const UpdateSSHResourceSchema = BaseUpdatePamResourceSchema.extend({ + connectionDetails: SSHResourceConnectionDetailsSchema.optional(), + rotationAccountCredentials: SSHAccountCredentialsSchema.nullable().optional() +}); + +// Accounts +export const SSHAccountSchema = BasePamAccountSchema.extend({ + credentials: SSHAccountCredentialsSchema +}); + +export const CreateSSHAccountSchema = BaseCreatePamAccountSchema.extend({ + credentials: SSHAccountCredentialsSchema +}); + +export const UpdateSSHAccountSchema = BaseUpdatePamAccountSchema.extend({ + credentials: SSHAccountCredentialsSchema.optional() +}); + +export const SanitizedSSHAccountWithResourceSchema = BasePamAccountSchemaWithResource.extend({ + credentials: z.discriminatedUnion("authMethod", [ + z.object({ + authMethod: z.literal(SSHAuthMethod.Password), + username: z.string() + }), + z.object({ + authMethod: z.literal(SSHAuthMethod.PublicKey), + username: z.string() + }), + z.object({ + authMethod: z.literal(SSHAuthMethod.Certificate), + username: z.string() + }) + ]) +}); + +// Sessions +export const SSHSessionCredentialsSchema = SSHResourceConnectionDetailsSchema.and(SSHAccountCredentialsSchema); diff --git a/backend/src/ee/services/pam-resource/ssh/ssh-resource-types.ts b/backend/src/ee/services/pam-resource/ssh/ssh-resource-types.ts new file mode 100644 index 000000000..920dc4274 --- /dev/null +++ b/backend/src/ee/services/pam-resource/ssh/ssh-resource-types.ts @@ -0,0 +1,16 @@ +import { z } from "zod"; + +import { + SSHAccountCredentialsSchema, + SSHAccountSchema, + SSHResourceConnectionDetailsSchema, + SSHResourceSchema +} from "./ssh-resource-schemas"; + +// Resources +export type TSSHResource = z.infer; +export type TSSHResourceConnectionDetails = z.infer; + +// Accounts +export type TSSHAccount = z.infer; +export type TSSHAccountCredentials = z.infer; diff --git a/backend/src/ee/services/pam-session/pam-session-fns.ts b/backend/src/ee/services/pam-session/pam-session-fns.ts index 4afe205b5..38e9f6239 100644 --- a/backend/src/ee/services/pam-session/pam-session-fns.ts +++ b/backend/src/ee/services/pam-session/pam-session-fns.ts @@ -2,7 +2,7 @@ import { TPamSessions } from "@app/db/schemas"; import { TKmsServiceFactory } from "@app/services/kms/kms-service"; import { KmsDataKey } from "@app/services/kms/kms-types"; -import { TPamSanitizedSession, TPamSessionCommandLog } from "./pam-session.types"; +import { TPamSanitizedSession, TPamSessionCommandLog, TTerminalEvent } from "./pam-session-types"; export const decryptSessionCommandLogs = async ({ projectId, @@ -22,7 +22,7 @@ export const decryptSessionCommandLogs = async ({ cipherTextBlob: encryptedLogs }); - return JSON.parse(decryptedPlainTextBlob.toString()) as TPamSessionCommandLog; + return JSON.parse(decryptedPlainTextBlob.toString()) as (TPamSessionCommandLog | TTerminalEvent)[]; }; export const decryptSession = async ( @@ -32,7 +32,7 @@ export const decryptSession = async ( ) => { return { ...session, - commandLogs: session.encryptedLogsBlob + logs: session.encryptedLogsBlob ? await decryptSessionCommandLogs({ projectId, encryptedLogs: session.encryptedLogsBlob, diff --git a/backend/src/ee/services/pam-session/pam-session-schemas.ts b/backend/src/ee/services/pam-session/pam-session-schemas.ts index 2bc1d5345..db2493196 100644 --- a/backend/src/ee/services/pam-session/pam-session-schemas.ts +++ b/backend/src/ee/services/pam-session/pam-session-schemas.ts @@ -8,8 +8,18 @@ export const PamSessionCommandLogSchema = z.object({ timestamp: z.coerce.date() }); +// SSH Terminal Event schemas +export const TerminalEventTypeSchema = z.enum(["input", "output", "resize", "error"]); + +export const TerminalEventSchema = z.object({ + timestamp: z.coerce.date(), + eventType: TerminalEventTypeSchema, + data: z.string(), // Base64 encoded binary data + elapsedTime: z.number() // Seconds since session start (for replay) +}); + export const SanitizedSessionSchema = PamSessionsSchema.omit({ encryptedLogsBlob: true }).extend({ - commandLogs: PamSessionCommandLogSchema.array() + logs: z.array(z.union([PamSessionCommandLogSchema, TerminalEventSchema])) }); diff --git a/backend/src/ee/services/pam-session/pam-session-service.ts b/backend/src/ee/services/pam-session/pam-session-service.ts index 26ff7daa6..18c185cac 100644 --- a/backend/src/ee/services/pam-session/pam-session-service.ts +++ b/backend/src/ee/services/pam-session/pam-session-service.ts @@ -12,10 +12,10 @@ import { TProjectDALFactory } from "@app/services/project/project-dal"; import { TLicenseServiceFactory } from "../license/license-service"; import { OrgPermissionGatewayActions, OrgPermissionSubjects } from "../permission/org-permission"; import { ProjectPermissionPamSessionActions, ProjectPermissionSub } from "../permission/project-permission"; -import { TUpdateSessionLogsDTO } from "./pam-session.types"; import { TPamSessionDALFactory } from "./pam-session-dal"; import { PamSessionStatus } from "./pam-session-enums"; import { decryptSession } from "./pam-session-fns"; +import { TUpdateSessionLogsDTO } from "./pam-session-types"; type TPamSessionServiceFactoryDep = { pamSessionDAL: TPamSessionDALFactory; diff --git a/backend/src/ee/services/pam-session/pam-session.types.ts b/backend/src/ee/services/pam-session/pam-session-types.ts similarity index 52% rename from backend/src/ee/services/pam-session/pam-session.types.ts rename to backend/src/ee/services/pam-session/pam-session-types.ts index 0c87a9fa4..893f930e5 100644 --- a/backend/src/ee/services/pam-session/pam-session.types.ts +++ b/backend/src/ee/services/pam-session/pam-session-types.ts @@ -1,12 +1,13 @@ import { z } from "zod"; -import { PamSessionCommandLogSchema, SanitizedSessionSchema } from "./pam-session-schemas"; +import { PamSessionCommandLogSchema, SanitizedSessionSchema, TerminalEventSchema } from "./pam-session-schemas"; export type TPamSessionCommandLog = z.infer; +export type TTerminalEvent = z.infer; export type TPamSanitizedSession = z.infer; // DTOs export type TUpdateSessionLogsDTO = { sessionId: string; - logs: TPamSessionCommandLog[]; + logs: (TPamSessionCommandLog | TTerminalEvent)[]; }; diff --git a/frontend/src/hooks/api/pam/types/index.ts b/frontend/src/hooks/api/pam/types/index.ts index 1b1890cbd..6f37c97c3 100644 --- a/frontend/src/hooks/api/pam/types/index.ts +++ b/frontend/src/hooks/api/pam/types/index.ts @@ -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; @@ -19,6 +21,22 @@ export type TPamFolder = { updatedAt: string; }; +// Session log types +export type TPamCommandLog = { + input: string; + output: string; + timestamp: string; +}; + +export type TTerminalEvent = { + timestamp: string; + eventType: "input" | "output" | "resize" | "error"; + data: string; // Base64 encoded binary data + elapsedTime: number; // Seconds since session start (for replay) +}; + +export type TPamSessionLog = TPamCommandLog | TTerminalEvent; + export type TPamSession = { id: string; projectId: string; @@ -37,11 +55,7 @@ export type TPamSession = { endedAt?: string | null; createdAt: string; updatedAt: string; - commandLogs: { - input: string; - output: string; - timestamp: string; - }[]; + logs: TPamSessionLog[]; }; // Resource DTOs diff --git a/frontend/src/hooks/api/pam/types/ssh-resource.ts b/frontend/src/hooks/api/pam/types/ssh-resource.ts new file mode 100644 index 000000000..0ac3b5a77 --- /dev/null +++ b/frontend/src/hooks/api/pam/types/ssh-resource.ts @@ -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; +}; diff --git a/frontend/src/pages/pam/PamAccountsPage/components/PamAccessAccountModal.tsx b/frontend/src/pages/pam/PamAccountsPage/components/PamAccessAccountModal.tsx index a1bf76888..704c13eba 100644 --- a/frontend/src/pages/pam/PamAccountsPage/components/PamAccessAccountModal.tsx +++ b/frontend/src/pages/pam/PamAccountsPage/components/PamAccessAccountModal.tsx @@ -58,15 +58,19 @@ 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 ""; + + switch (account.resource.resourceType) { + case PamResourceType.Postgres: + case PamResourceType.MySQL: + return `infisical pam db access-account ${account.id} --duration ${cliDuration}`; + case PamResourceType.SSH: + return `infisical pam ssh access-account ${account.id} --duration ${cliDuration}`; + default: + return ""; + } + }, [account, cliDuration]); if (!account) return null; diff --git a/frontend/src/pages/pam/PamAccountsPage/components/PamAccountForm/PamAccountForm.tsx b/frontend/src/pages/pam/PamAccountsPage/components/PamAccountForm/PamAccountForm.tsx index 346195e11..fc1662349 100644 --- a/frontend/src/pages/pam/PamAccountsPage/components/PamAccountForm/PamAccountForm.tsx +++ b/frontend/src/pages/pam/PamAccountsPage/components/PamAccountForm/PamAccountForm.tsx @@ -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 ( ); + case PamResourceType.SSH: + return ( + + ); default: throw new Error(`Unhandled resource: ${resourceType}`); } @@ -90,9 +95,11 @@ const UpdateForm = ({ account, onComplete }: UpdateFormProps) => { switch (account.resource.resourceType) { case PamResourceType.Postgres: - return ; + return ; case PamResourceType.MySQL: - return ; + return ; + case PamResourceType.SSH: + return ; default: throw new Error(`Unhandled resource: ${account.resource.resourceType}`); } diff --git a/frontend/src/pages/pam/PamAccountsPage/components/PamAccountForm/SshAccountForm.tsx b/frontend/src/pages/pam/PamAccountsPage/components/PamAccountForm/SshAccountForm.tsx new file mode 100644 index 000000000..305d8a2fd --- /dev/null +++ b/frontend/src/pages/pam/PamAccountsPage/components/PamAccountForm/SshAccountForm.tsx @@ -0,0 +1,272 @@ +import { useEffect, useState } from "react"; +import { Controller, FormProvider, useForm, useFormContext, useWatch } from "react-hook-form"; +import { zodResolver } from "@hookform/resolvers/zod"; +import { z } from "zod"; + +import { + Button, + FormControl, + Input, + ModalClose, + Select, + SelectItem, + TextArea +} 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"; + +type Props = { + account?: TSSHAccount; + resourceId?: string; + resourceType?: PamResourceType; + onSubmit: (formData: FormData) => Promise; +}; + +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") +}); + +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") +}); + +const SSHCertificateCredentialsSchema = z.object({ + authMethod: z.literal(SSHAuthMethod.Certificate), + username: z.string().trim().min(1, "Username is required") +}); + +const BaseSshAccountSchema = z.discriminatedUnion("authMethod", [ + SSHPasswordCredentialsSchema, + SSHPublicKeyCredentialsSchema, + SSHCertificateCredentialsSchema +]); + +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; + +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 ( +
+ ( + + + + )} + /> + + ( + + + + )} + /> + + {authMethod === SSHAuthMethod.Password && ( + ( + + { + if (isUpdate && field.value === UNCHANGED_PASSWORD_SENTINEL) { + field.onChange(""); + } + setShowPassword(true); + }} + onBlur={() => { + if (isUpdate && field.value === "") { + field.onChange(UNCHANGED_PASSWORD_SENTINEL); + } + setShowPassword(false); + }} + /> + + )} + /> + )} + + {authMethod === SSHAuthMethod.PublicKey && ( + ( + +