From ace77b516ecc52ce59e115f92fa54c4aebbfde3d Mon Sep 17 00:00:00 2001 From: Sheen Capadngan Date: Thu, 13 Nov 2025 03:39:18 +0800 Subject: [PATCH 01/29] feat: ssh pam draft --- .../ee/routes/v1/pam-account-routers/index.ts | 14 ++ .../pam-account-routers/pam-account-router.ts | 4 +- .../routes/v1/pam-resource-routers/index.ts | 14 ++ .../pam-resource-router.ts | 13 +- .../src/ee/routes/v1/pam-session-router.ts | 11 +- .../pam-account/pam-account-service.ts | 41 ++++-- .../pam-resource/pam-resource-enums.ts | 3 +- .../pam-resource/pam-resource-factory.ts | 4 +- .../pam-resource/pam-resource-service.ts | 13 +- .../pam-resource/pam-resource-types.ts | 18 ++- .../shared/sql/sql-resource-factory.ts | 17 ++- .../pam-resource/ssh/ssh-resource-enums.ts | 5 + .../pam-resource/ssh/ssh-resource-factory.ts | 73 +++++++++ .../pam-resource/ssh/ssh-resource-schemas.ts | 117 +++++++++++++++ .../pam-resource/ssh/ssh-resource-types.ts | 16 ++ frontend/src/hooks/api/pam/types/index.ts | 6 +- .../src/hooks/api/pam/types/ssh-resource.ts | 47 ++++++ .../components/PamAccessAccountModal.tsx | 25 ++-- .../PamAccountForm/PamAccountForm.tsx | 11 +- .../PamAccountForm/SSHAccountForm.tsx | 96 ++++++++++++ .../shared/SshAccountFields.tsx | 138 ++++++++++++++++++ .../shared/ssh-account-schemas.ts | 26 ++++ .../PamResourceForm/PamResourceForm.tsx | 5 + .../PamResourceForm/SSHResourceForm.tsx | 68 +++++++++ .../shared/SshResourceFields.tsx | 42 ++++++ .../shared/ssh-resource-schemas.ts | 31 ++++ .../components/ResourceTypeSelect.tsx | 1 - 27 files changed, 813 insertions(+), 46 deletions(-) create mode 100644 backend/src/ee/services/pam-resource/ssh/ssh-resource-enums.ts create mode 100644 backend/src/ee/services/pam-resource/ssh/ssh-resource-factory.ts create mode 100644 backend/src/ee/services/pam-resource/ssh/ssh-resource-schemas.ts create mode 100644 backend/src/ee/services/pam-resource/ssh/ssh-resource-types.ts create mode 100644 frontend/src/hooks/api/pam/types/ssh-resource.ts create mode 100644 frontend/src/pages/pam/PamAccountsPage/components/PamAccountForm/SSHAccountForm.tsx create mode 100644 frontend/src/pages/pam/PamAccountsPage/components/PamAccountForm/shared/SshAccountFields.tsx create mode 100644 frontend/src/pages/pam/PamAccountsPage/components/PamAccountForm/shared/ssh-account-schemas.ts create mode 100644 frontend/src/pages/pam/PamResourcesPage/components/PamResourceForm/SSHResourceForm.tsx create mode 100644 frontend/src/pages/pam/PamResourcesPage/components/PamResourceForm/shared/SshResourceFields.tsx create mode 100644 frontend/src/pages/pam/PamResourcesPage/components/PamResourceForm/shared/ssh-resource-schemas.ts 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..c31ae0d78 100644 --- a/backend/src/ee/routes/v1/pam-session-router.ts +++ b/backend/src/ee/routes/v1/pam-session-router.ts @@ -4,12 +4,17 @@ 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 { SSHSessionCredentialsSchema } from "@app/ee/services/pam-resource/ssh/ssh-resource-schemas"; import { PamSessionCommandLogSchema, SanitizedSessionSchema } 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 @@ -26,7 +31,7 @@ export const registerPamSessionRouter = async (server: FastifyZodProvider) => { }), response: { 200: z.object({ - credentials: SessionCredentialsSchema + credentials: z.any() // UNION DOES NOT WORK WITH ZOD SCHEMA }) } }, @@ -50,7 +55,7 @@ export const registerPamSessionRouter = async (server: FastifyZodProvider) => { } }); - return { credentials }; + return { credentials: credentials as z.infer }; } }); 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..b6f2bbae9 100644 --- a/backend/src/ee/services/pam-account/pam-account-service.ts +++ b/backend/src/ee/services/pam-account/pam-account-service.ts @@ -24,9 +24,11 @@ import { TGatewayV2ServiceFactory } from "../gateway-v2/gateway-v2-service"; import { TLicenseServiceFactory } from "../license/license-service"; import { TPamFolderDALFactory } from "../pam-folder/pam-folder-dal"; import { getFullPamFolderPath } from "../pam-folder/pam-folder-fns"; +import { TMySQLResourceConnectionDetails } from "../pam-resource/mysql/mysql-resource-types"; 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 { TPostgresResourceConnectionDetails } from "../pam-resource/postgres/postgres-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 +253,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 +488,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 TMySQLResourceConnectionDetails | TPostgresResourceConnectionDetails; const credentials = await decryptAccountCredentials({ encryptedCredentials: account.encryptedCredentials, @@ -506,6 +508,21 @@ export const pamAccountServiceFactory = ({ }; } break; + case PamResource.SSH: + { + const credentials = await decryptAccountCredentials({ + encryptedCredentials: account.encryptedCredentials, + kmsService, + projectId: account.projectId + }); + + metadata = { + username: credentials.username, + accountName: account.name, + accountPath + }; + } + 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..b01c87151 --- /dev/null +++ b/backend/src/ee/services/pam-resource/ssh/ssh-resource-factory.ts @@ -0,0 +1,73 @@ +import { + TPamResourceFactory, + TPamResourceFactoryRotateAccountCredentials, + TPamResourceFactoryValidateAccountCredentials +} from "../pam-resource-types"; +import { SSHAuthMethod } from "./ssh-resource-enums"; +import { TSSHAccountCredentials, TSSHResourceConnectionDetails } from "./ssh-resource-types"; + +export const sshResourceFactory: TPamResourceFactory = ( + resourceType, + connectionDetails, + gatewayId, + gatewayV2Service +) => { + const validateConnection = async () => { + return connectionDetails; + }; + + const validateAccountCredentials: TPamResourceFactoryValidateAccountCredentials = async ( + credentials + ) => { + return credentials; + }; + + const rotateAccountCredentials: TPamResourceFactoryRotateAccountCredentials = async ( + rotationAccountCredentials, + currentCredentials + ) => { + 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..779574e01 --- /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(), + port: z.number() +}); + +export const SSHPasswordCredentialsSchema = z.object({ + authMethod: z.literal(SSHAuthMethod.Password), + username: z.string().trim(), + password: z.string().trim() +}); + +export const SSHPublicKeyCredentialsSchema = z.object({ + authMethod: z.literal(SSHAuthMethod.PublicKey), + username: z.string().trim(), + privateKey: z.string().trim() +}); + +export const SSHCertificateCredentialsSchema = z.object({ + authMethod: z.literal(SSHAuthMethod.Certificate), + username: z.string().trim() +}); + +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/frontend/src/hooks/api/pam/types/index.ts b/frontend/src/hooks/api/pam/types/index.ts index 1b1890cbd..055cb8dea 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; 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..97fcb26f6 100644 --- a/frontend/src/pages/pam/PamAccountsPage/components/PamAccessAccountModal.tsx +++ b/frontend/src/pages/pam/PamAccountsPage/components/PamAccessAccountModal.tsx @@ -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; diff --git a/frontend/src/pages/pam/PamAccountsPage/components/PamAccountForm/PamAccountForm.tsx b/frontend/src/pages/pam/PamAccountsPage/components/PamAccountForm/PamAccountForm.tsx index 346195e11..0288411b1 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..c6d303c63 --- /dev/null +++ b/frontend/src/pages/pam/PamAccountsPage/components/PamAccountForm/SSHAccountForm.tsx @@ -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; +}; + +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; + +export const SSHAccountForm = ({ account, onSubmit }: Props) => { + const isUpdate = Boolean(account); + + const form = useForm({ + 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 ( + +
{ + handleSubmit(onSubmit)(e); + }} + > + + +
+ + + + +
+ +
+ ); +}; diff --git a/frontend/src/pages/pam/PamAccountsPage/components/PamAccountForm/shared/SshAccountFields.tsx b/frontend/src/pages/pam/PamAccountsPage/components/PamAccountForm/shared/SshAccountFields.tsx new file mode 100644 index 000000000..0ca294fb9 --- /dev/null +++ b/frontend/src/pages/pam/PamAccountsPage/components/PamAccountForm/shared/SshAccountFields.tsx @@ -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 ( +
+ ( + + + + )} + /> + + ( + + + + )} + /> + + {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 && ( + ( + +