From 07d9398aad621726e42773fe034ffdc1866cdbcc Mon Sep 17 00:00:00 2001 From: Tuan Dang Date: Tue, 3 Dec 2024 17:38:23 -0800 Subject: [PATCH] Add permissioning to SSH, add publicKey return for SSH CA, polish --- .../v1/ssh-certificate-authority-router.ts | 15 ++- .../v1/ssh-certificate-template-router.ts | 20 +++- backend/src/ee/routes/v1/ssh-router.ts | 53 +++++---- .../ee/services/permission/org-permission.ts | 30 ++++-- .../ssh-certificate-template-dal.ts | 27 ++++- .../ssh-certificate-template-service.ts | 35 ++++-- .../ssh/ssh-certificate-authority-fns.ts | 32 +++++- .../ssh/ssh-certificate-authority-service.ts | 101 +++++++++++++----- .../ssh/ssh-certificate-authority-types.ts | 7 +- backend/src/lib/api-docs/constants.ts | 23 ++++ .../context/OrgPermissionContext/index.tsx | 5 +- .../src/context/OrgPermissionContext/types.ts | 11 +- frontend/src/context/index.tsx | 1 + .../src/hooks/api/organization/queries.tsx | 2 +- frontend/src/hooks/api/ssh-ca/types.ts | 2 + .../components/OrgRoleModifySection.utils.ts | 15 ++- .../Org/RolePage/components/RoleModal.tsx | 6 -- .../RolePermissionRow.tsx | 13 ++- .../RolePermissionsSection.tsx | 10 +- .../src/views/Org/SshCaPage/SshCaPage.tsx | 2 + .../components/SshCaDetailsSection.tsx | 36 ++++++- .../SshCertificateTemplateModal.tsx | 2 +- .../SshCertificateTemplatesSection.tsx | 4 +- .../SshCertificateTemplatesTable.tsx | 29 ++--- .../Org/SshPage/components/SshCaModal.tsx | 22 +++- 25 files changed, 397 insertions(+), 106 deletions(-) diff --git a/backend/src/ee/routes/v1/ssh-certificate-authority-router.ts b/backend/src/ee/routes/v1/ssh-certificate-authority-router.ts index 0bee089ee..4f4e166d0 100644 --- a/backend/src/ee/routes/v1/ssh-certificate-authority-router.ts +++ b/backend/src/ee/routes/v1/ssh-certificate-authority-router.ts @@ -29,7 +29,9 @@ export const registerSshCaRouter = async (server: FastifyZodProvider) => { }), response: { 200: z.object({ - ca: sanitizedSshCa + ca: sanitizedSshCa.extend({ + publicKey: z.string() + }) }) } }, @@ -74,7 +76,9 @@ export const registerSshCaRouter = async (server: FastifyZodProvider) => { }), response: { 200: z.object({ - ca: sanitizedSshCa + ca: sanitizedSshCa.extend({ + publicKey: z.string() + }) }) } }, @@ -109,7 +113,7 @@ export const registerSshCaRouter = async (server: FastifyZodProvider) => { method: "PATCH", url: "/:sshCaId", config: { - rateLimit: readLimit + rateLimit: writeLimit }, onRequest: verifyAuth([AuthMode.JWT, AuthMode.IDENTITY_ACCESS_TOKEN]), schema: { @@ -118,6 +122,7 @@ export const registerSshCaRouter = async (server: FastifyZodProvider) => { sshCaId: z.string().trim().describe(SSH_CERTIFICATE_AUTHORITIES.UPDATE.sshCaId) }), body: z.object({ + friendlyName: z.string().optional().describe(SSH_CERTIFICATE_AUTHORITIES.UPDATE.friendlyName), status: z .enum([SshCaStatus.ACTIVE, SshCaStatus.DISABLED]) .optional() @@ -125,7 +130,9 @@ export const registerSshCaRouter = async (server: FastifyZodProvider) => { }), response: { 200: z.object({ - ca: sanitizedSshCa + ca: sanitizedSshCa.extend({ + publicKey: z.string() + }) }) } }, diff --git a/backend/src/ee/routes/v1/ssh-certificate-template-router.ts b/backend/src/ee/routes/v1/ssh-certificate-template-router.ts index 90619d3b7..8ab0b57d9 100644 --- a/backend/src/ee/routes/v1/ssh-certificate-template-router.ts +++ b/backend/src/ee/routes/v1/ssh-certificate-template-router.ts @@ -1,3 +1,4 @@ +import slugify from "@sindresorhus/slugify"; import ms from "ms"; import { z } from "zod"; @@ -61,7 +62,14 @@ export const registerSshCertificateTemplateRouter = async (server: FastifyZodPro schema: { body: z.object({ sshCaId: z.string().describe(SSH_CERTIFICATE_TEMPLATES.CREATE.sshCaId), - name: z.string().min(1).describe(SSH_CERTIFICATE_TEMPLATES.CREATE.name), + name: z + .string() + .min(1) + .max(36) + .refine((v) => slugify(v) === v, { + message: "Name must be a valid slug" + }) + .describe(SSH_CERTIFICATE_TEMPLATES.CREATE.name), ttl: z .string() .refine((val) => ms(val) > 0, "TTL must be a positive number") @@ -128,7 +136,15 @@ export const registerSshCertificateTemplateRouter = async (server: FastifyZodPro }, schema: { body: z.object({ - name: z.string().min(1).optional().describe(SSH_CERTIFICATE_TEMPLATES.UPDATE.name), + name: z + .string() + .min(1) + .max(36) + .refine((v) => slugify(v) === v, { + message: "Slug must be a valid slug" + }) + .optional() + .describe(SSH_CERTIFICATE_TEMPLATES.UPDATE.name), ttl: z .string() .refine((val) => ms(val) > 0, "TTL must be a positive number") diff --git a/backend/src/ee/routes/v1/ssh-router.ts b/backend/src/ee/routes/v1/ssh-router.ts index f3661dc04..566cbb423 100644 --- a/backend/src/ee/routes/v1/ssh-router.ts +++ b/backend/src/ee/routes/v1/ssh-router.ts @@ -3,7 +3,7 @@ import { z } from "zod"; import { EventType } from "@app/ee/services/audit-log/audit-log-types"; import { SshCertType } from "@app/ee/services/ssh/ssh-certificate-authority-types"; -import { CERTIFICATE_AUTHORITIES, CERTIFICATE_TEMPLATES } from "@app/lib/api-docs"; // TODO: update to SSH CA +import { SSH_CERTIFICATE_AUTHORITIES } from "@app/lib/api-docs"; import { writeLimit } from "@app/server/config/rateLimiter"; import { verifyAuth } from "@app/server/plugins/auth/verify-auth"; import { AuthMode } from "@app/services/auth/auth-type"; @@ -20,21 +20,27 @@ export const registerSshRouter = async (server: FastifyZodProvider) => { schema: { description: "Sign SSH public key", body: z.object({ - name: z.string(), // name of SSH certificate template - publicKey: z.string(), - certType: z.nativeEnum(SshCertType).default(SshCertType.USER), - principals: z.array(z.string().transform((val) => val.trim())).nonempty("Principals array must not be empty"), + templateName: z.string().trim().describe(SSH_CERTIFICATE_AUTHORITIES.SIGN_SSH_KEY.templateName), + publicKey: z.string().trim().describe(SSH_CERTIFICATE_AUTHORITIES.SIGN_SSH_KEY.publicKey), + certType: z + .nativeEnum(SshCertType) + .default(SshCertType.USER) + .describe(SSH_CERTIFICATE_AUTHORITIES.SIGN_SSH_KEY.certType), + principals: z + .array(z.string().transform((val) => val.trim())) + .nonempty("Principals array must not be empty") + .describe(SSH_CERTIFICATE_AUTHORITIES.SIGN_SSH_KEY.principals), ttl: z .string() .refine((val) => ms(val) > 0, "TTL must be a positive number") .optional() - .describe(CERTIFICATE_TEMPLATES.CREATE.ttl), - keyId: z.string().optional() + .describe(SSH_CERTIFICATE_AUTHORITIES.SIGN_SSH_KEY.ttl), + keyId: z.string().trim().optional().describe(SSH_CERTIFICATE_AUTHORITIES.SIGN_SSH_KEY.keyId) }), response: { 200: z.object({ - serialNumber: z.string(), - signedKey: z.string() + serialNumber: z.string().describe(SSH_CERTIFICATE_AUTHORITIES.SIGN_SSH_KEY.serialNumber), + signedKey: z.string().describe(SSH_CERTIFICATE_AUTHORITIES.SIGN_SSH_KEY.signedKey) }) } }, @@ -80,26 +86,35 @@ export const registerSshRouter = async (server: FastifyZodProvider) => { schema: { description: "Issue SSH credentials (certificate + key)", body: z.object({ - name: z.string(), // name of SSH certificate template + templateName: z.string().trim().describe(SSH_CERTIFICATE_AUTHORITIES.ISSUE_SSH_CREDENTIALS.templateName), keyAlgorithm: z .nativeEnum(CertKeyAlgorithm) .default(CertKeyAlgorithm.RSA_2048) - .describe(CERTIFICATE_AUTHORITIES.CREATE.keyAlgorithm), - certType: z.nativeEnum(SshCertType).default(SshCertType.USER), - principals: z.array(z.string().transform((val) => val.trim())).nonempty("Principals array must not be empty"), + .describe(SSH_CERTIFICATE_AUTHORITIES.ISSUE_SSH_CREDENTIALS.keyAlgorithm), + certType: z + .nativeEnum(SshCertType) + .default(SshCertType.USER) + .describe(SSH_CERTIFICATE_AUTHORITIES.ISSUE_SSH_CREDENTIALS.certType), + principals: z + .array(z.string().transform((val) => val.trim())) + .nonempty("Principals array must not be empty") + .describe(SSH_CERTIFICATE_AUTHORITIES.ISSUE_SSH_CREDENTIALS.principals), ttl: z .string() .refine((val) => ms(val) > 0, "TTL must be a positive number") .optional() - .describe(CERTIFICATE_TEMPLATES.CREATE.ttl), - keyId: z.string().optional() + .describe(SSH_CERTIFICATE_AUTHORITIES.ISSUE_SSH_CREDENTIALS.ttl), + keyId: z.string().trim().optional() }), response: { 200: z.object({ - serialNumber: z.string(), - signedKey: z.string(), - privateKey: z.string(), - keyAlgorithm: z.nativeEnum(CertKeyAlgorithm) + serialNumber: z.string().describe(SSH_CERTIFICATE_AUTHORITIES.ISSUE_SSH_CREDENTIALS.serialNumber), + signedKey: z.string().describe(SSH_CERTIFICATE_AUTHORITIES.ISSUE_SSH_CREDENTIALS.signedKey), + privateKey: z.string().describe(SSH_CERTIFICATE_AUTHORITIES.ISSUE_SSH_CREDENTIALS.privateKey), + publicKey: z.string().describe(SSH_CERTIFICATE_AUTHORITIES.ISSUE_SSH_CREDENTIALS.publicKey), + keyAlgorithm: z + .nativeEnum(CertKeyAlgorithm) + .describe(SSH_CERTIFICATE_AUTHORITIES.ISSUE_SSH_CREDENTIALS.keyAlgorithm) }) } }, diff --git a/backend/src/ee/services/permission/org-permission.ts b/backend/src/ee/services/permission/org-permission.ts index 8f549f750..dffdce7bf 100644 --- a/backend/src/ee/services/permission/org-permission.ts +++ b/backend/src/ee/services/permission/org-permission.ts @@ -7,6 +7,15 @@ export enum OrgPermissionActions { Delete = "delete" } +export enum OrgPermissionSshCertificateTemplateActions { + Read = "read", + Create = "create", + Edit = "edit", + Delete = "delete", + SignSshKey = "sign-ssh-key", + IssueSshCredentials = "issue-ssh-credentials" +} + export enum OrgPermissionAdminConsoleAction { AccessAllProjects = "access-all-projects" } @@ -50,7 +59,7 @@ export type OrgPermissionSet = | [OrgPermissionActions, OrgPermissionSubjects.ProjectTemplates] | [OrgPermissionAdminConsoleAction, OrgPermissionSubjects.AdminConsole] | [OrgPermissionActions, OrgPermissionSubjects.SshCertificateAuthorities] - | [OrgPermissionActions, OrgPermissionSubjects.SshCertificateTemplates]; + | [OrgPermissionSshCertificateTemplateActions, OrgPermissionSubjects.SshCertificateTemplates]; const buildAdminPermission = () => { const { can, rules } = new AbilityBuilder>(createMongoAbility); @@ -132,10 +141,17 @@ const buildAdminPermission = () => { can(OrgPermissionActions.Edit, OrgPermissionSubjects.SshCertificateAuthorities); can(OrgPermissionActions.Delete, OrgPermissionSubjects.SshCertificateAuthorities); - can(OrgPermissionActions.Read, OrgPermissionSubjects.SshCertificateTemplates); - can(OrgPermissionActions.Create, OrgPermissionSubjects.SshCertificateTemplates); - can(OrgPermissionActions.Edit, OrgPermissionSubjects.SshCertificateTemplates); - can(OrgPermissionActions.Delete, OrgPermissionSubjects.SshCertificateTemplates); + can( + [ + OrgPermissionSshCertificateTemplateActions.Read, + OrgPermissionSshCertificateTemplateActions.Create, + OrgPermissionSshCertificateTemplateActions.Edit, + OrgPermissionSshCertificateTemplateActions.Delete, + OrgPermissionSshCertificateTemplateActions.SignSshKey, + OrgPermissionSshCertificateTemplateActions.IssueSshCredentials + ], + OrgPermissionSubjects.SshCertificateTemplates + ); can(OrgPermissionAdminConsoleAction.AccessAllProjects, OrgPermissionSubjects.AdminConsole); @@ -168,7 +184,9 @@ const buildMemberPermission = () => { can(OrgPermissionActions.Read, OrgPermissionSubjects.AuditLogs); can(OrgPermissionActions.Read, OrgPermissionSubjects.SshCertificateAuthorities); - can(OrgPermissionActions.Read, OrgPermissionSubjects.SshCertificateTemplates); + can(OrgPermissionSshCertificateTemplateActions.Read, OrgPermissionSubjects.SshCertificateTemplates); + can(OrgPermissionSshCertificateTemplateActions.SignSshKey, OrgPermissionSubjects.SshCertificateTemplates); + can(OrgPermissionSshCertificateTemplateActions.IssueSshCredentials, OrgPermissionSubjects.SshCertificateTemplates); return rules; }; diff --git a/backend/src/ee/services/ssh-certificate-template/ssh-certificate-template-dal.ts b/backend/src/ee/services/ssh-certificate-template/ssh-certificate-template-dal.ts index 09848f8ca..62b6323ca 100644 --- a/backend/src/ee/services/ssh-certificate-template/ssh-certificate-template-dal.ts +++ b/backend/src/ee/services/ssh-certificate-template/ssh-certificate-template-dal.ts @@ -34,5 +34,30 @@ export const sshCertificateTemplateDALFactory = (db: TDbClient) => { } }; - return { ...sshCertificateTemplateOrm, getById }; + const getByName = async (name: string, orgId: string, tx?: Knex) => { + try { + const certTemplate = await (tx || db.replicaNode())(TableName.SshCertificateTemplate) + .join( + TableName.SshCertificateAuthority, + `${TableName.SshCertificateAuthority}.id`, + `${TableName.SshCertificateTemplate}.sshCaId` + ) + .join(TableName.Organization, `${TableName.Organization}.id`, `${TableName.SshCertificateAuthority}.orgId`) + .where(`${TableName.SshCertificateTemplate}.name`, "=", name) + .where(`${TableName.Organization}.id`, "=", orgId) + .select(selectAllTableCols(TableName.SshCertificateTemplate)) + .select( + db.ref("orgId").withSchema(TableName.SshCertificateAuthority), + db.ref("friendlyName").as("caName").withSchema(TableName.SshCertificateAuthority), + db.ref("status").as("caStatus").withSchema(TableName.SshCertificateAuthority) + ) + .first(); + + return certTemplate; + } catch (error) { + throw new DatabaseError({ error, name: "Get SSH certificate template by name" }); + } + }; + + return { ...sshCertificateTemplateOrm, getById, getByName }; }; diff --git a/backend/src/ee/services/ssh-certificate-template/ssh-certificate-template-service.ts b/backend/src/ee/services/ssh-certificate-template/ssh-certificate-template-service.ts index d730df0ba..357a9a0db 100644 --- a/backend/src/ee/services/ssh-certificate-template/ssh-certificate-template-service.ts +++ b/backend/src/ee/services/ssh-certificate-template/ssh-certificate-template-service.ts @@ -1,7 +1,10 @@ import { ForbiddenError } from "@casl/ability"; import ms from "ms"; -import { OrgPermissionActions, OrgPermissionSubjects } from "@app/ee/services/permission/org-permission"; +import { + OrgPermissionSshCertificateTemplateActions, + OrgPermissionSubjects +} from "@app/ee/services/permission/org-permission"; import { TPermissionServiceFactory } from "@app/ee/services/permission/permission-service"; import { BadRequestError, NotFoundError } from "@app/lib/errors"; @@ -58,10 +61,17 @@ export const sshCertificateTemplateServiceFactory = ({ ); ForbiddenError.from(permission).throwUnlessCan( - OrgPermissionActions.Create, + OrgPermissionSshCertificateTemplateActions.Create, OrgPermissionSubjects.SshCertificateTemplates ); + const existingTemplate = await sshCertificateTemplateDAL.getByName(name, ca.orgId); + if (existingTemplate) { + throw new BadRequestError({ + message: `SSH certificate template with name ${name} already exists` + }); + } + if (ms(ttl) > ms(maxTTL)) { throw new BadRequestError({ message: "TTL cannot be greater than max TTL" @@ -114,10 +124,19 @@ export const sshCertificateTemplateServiceFactory = ({ ); ForbiddenError.from(permission).throwUnlessCan( - OrgPermissionActions.Edit, - OrgPermissionSubjects.SshCertificateAuthorities + OrgPermissionSshCertificateTemplateActions.Edit, + OrgPermissionSubjects.SshCertificateTemplates ); + if (name) { + const existingTemplate = await sshCertificateTemplateDAL.getByName(name, actorOrgId); + if (existingTemplate) { + throw new BadRequestError({ + message: `SSH certificate template with name ${name} already exists` + }); + } + } + if (ms(ttl || certTemplate.ttl) > ms(maxTTL || certTemplate.maxTTL)) { throw new BadRequestError({ message: "TTL cannot be greater than max TTL" @@ -164,8 +183,8 @@ export const sshCertificateTemplateServiceFactory = ({ ); ForbiddenError.from(permission).throwUnlessCan( - OrgPermissionActions.Delete, - OrgPermissionSubjects.SshCertificateAuthorities + OrgPermissionSshCertificateTemplateActions.Delete, + OrgPermissionSubjects.SshCertificateTemplates ); await sshCertificateTemplateDAL.deleteById(certificateTemplate.id); @@ -190,8 +209,8 @@ export const sshCertificateTemplateServiceFactory = ({ ); ForbiddenError.from(permission).throwUnlessCan( - OrgPermissionActions.Read, - OrgPermissionSubjects.SshCertificateAuthorities + OrgPermissionSshCertificateTemplateActions.Read, + OrgPermissionSubjects.SshCertificateTemplates ); return certTemplate; diff --git a/backend/src/ee/services/ssh/ssh-certificate-authority-fns.ts b/backend/src/ee/services/ssh/ssh-certificate-authority-fns.ts index a371d2d82..a299b8120 100644 --- a/backend/src/ee/services/ssh/ssh-certificate-authority-fns.ts +++ b/backend/src/ee/services/ssh/ssh-certificate-authority-fns.ts @@ -67,6 +67,32 @@ export const createSshKeyPair = (keyAlgorithm: CertKeyAlgorithm, comment: string return { publicKey, privateKey }; }; +/** + * Return the SSH public key for the given SSH private key. + * @param privateKey - The SSH private key to get the public key for + */ +export const getSshPublicKey = (privateKey: string) => { + const uniqueId = crypto.randomBytes(8).toString("hex"); + const privateKeyFile = `ssh_key_${uniqueId}`; + const publicKeyFile = `${privateKeyFile}.pub`; + + if (fs.existsSync(publicKeyFile)) fs.unlinkSync(publicKeyFile); + if (fs.existsSync(privateKeyFile)) fs.unlinkSync(privateKeyFile); + + fs.writeFileSync(privateKeyFile, privateKey); + fs.chmodSync(privateKeyFile, 0o600); + + const command = `ssh-keygen -y -f ${privateKeyFile} > ${publicKeyFile}`; + execSync(command); + + const publicKey = fs.readFileSync(publicKeyFile, "utf8"); + + fs.unlinkSync(privateKeyFile); + fs.unlinkSync(publicKeyFile); + + return publicKey; +}; + /** * Validate the requested SSH certificate type based on the SSH certificate template configuration. * @param template - The SSH certificate template configuration @@ -160,9 +186,11 @@ export const createSshCert = ({ caPrivateKey, userPublicKey, keyId, principals, const uniqueId = crypto.randomBytes(8).toString("hex"); const publicKeyFile = `user_key_${uniqueId}.pub`; const privateKeyFile = `ssh_ca_key_${uniqueId}`; + const signedPublicKeyFile = `user_key_${uniqueId}-cert.pub`; if (fs.existsSync(publicKeyFile)) fs.unlinkSync(publicKeyFile); if (fs.existsSync(privateKeyFile)) fs.unlinkSync(privateKeyFile); + if (fs.existsSync(signedPublicKeyFile)) fs.unlinkSync(signedPublicKeyFile); // write public and private keys to temp files fs.writeFileSync(publicKeyFile, userPublicKey); @@ -170,7 +198,6 @@ export const createSshCert = ({ caPrivateKey, userPublicKey, keyId, principals, fs.chmodSync(privateKeyFile, 0o600); const serialNumber = createSshCertSerialNumber(); - console.log("signSshKey serialNumber: ", serialNumber); const certOptions = [ `-s ${privateKeyFile}`, // path to SSH CA private key @@ -189,10 +216,11 @@ export const createSshCert = ({ caPrivateKey, userPublicKey, keyId, principals, // Execute the signing process execSync(command); - const signedPublicKey = fs.readFileSync(publicKeyFile, "utf8"); + const signedPublicKey = fs.readFileSync(signedPublicKeyFile, "utf8"); fs.unlinkSync(publicKeyFile); fs.unlinkSync(privateKeyFile); + fs.unlinkSync(signedPublicKeyFile); return { serialNumber, signedPublicKey }; }; diff --git a/backend/src/ee/services/ssh/ssh-certificate-authority-service.ts b/backend/src/ee/services/ssh/ssh-certificate-authority-service.ts index b324dda94..93b927011 100644 --- a/backend/src/ee/services/ssh/ssh-certificate-authority-service.ts +++ b/backend/src/ee/services/ssh/ssh-certificate-authority-service.ts @@ -1,6 +1,10 @@ import { ForbiddenError } from "@casl/ability"; -import { OrgPermissionActions, OrgPermissionSubjects } from "@app/ee/services/permission/org-permission"; +import { + OrgPermissionActions, + OrgPermissionSshCertificateTemplateActions, + OrgPermissionSubjects +} from "@app/ee/services/permission/org-permission"; import { TPermissionServiceFactory } from "@app/ee/services/permission/permission-service"; import { TSshCertificateAuthorityDALFactory } from "@app/ee/services/ssh/ssh-certificate-authority-dal"; import { TSshCertificateAuthoritySecretDALFactory } from "@app/ee/services/ssh/ssh-certificate-authority-secret-dal"; @@ -12,6 +16,7 @@ import { TProjectDALFactory } from "@app/services/project/project-dal"; import { createSshCert, createSshKeyPair, + getSshPublicKey, validateSshCertificatePrincipals, validateSshCertificateTtl, validateSshCertificateType @@ -33,7 +38,7 @@ type TSshCertificateAuthorityServiceFactoryDep = { "transaction" | "create" | "findById" | "updateById" | "deleteById" | "findOne" >; sshCertificateAuthoritySecretDAL: Pick; - sshCertificateTemplateDAL: Pick; + sshCertificateTemplateDAL: Pick; projectDAL: Pick; kmsService: Pick; permissionService: Pick; @@ -76,14 +81,14 @@ export const sshCertificateAuthorityServiceFactory = ({ const ca = await sshCertificateAuthorityDAL.create( { orgId: actorOrgId, - friendlyName: friendlyName || "", + friendlyName, status: SshCaStatus.ACTIVE, keyAlgorithm }, tx ); - const { privateKey } = createSshKeyPair(keyAlgorithm, ca.friendlyName); + const { publicKey, privateKey } = createSshKeyPair(keyAlgorithm, ca.friendlyName); const orgKmsKeyId = await kmsService.getOrgKmsKeyId(actorOrgId); const kmsEncryptor = await kmsService.encryptWithKmsKey({ @@ -102,7 +107,7 @@ export const sshCertificateAuthorityServiceFactory = ({ tx ); - return ca; + return { ...ca, publicKey }; }); return newCa; @@ -118,7 +123,7 @@ export const sshCertificateAuthorityServiceFactory = ({ const { permission } = await permissionService.getOrgPermission( actor, actorId, - actorOrgId, + ca.orgId, actorAuthMethod, actorOrgId ); @@ -128,21 +133,43 @@ export const sshCertificateAuthorityServiceFactory = ({ OrgPermissionSubjects.SshCertificateAuthorities ); - return ca; + const sshCaSecret = await sshCertificateAuthoritySecretDAL.findOne({ sshCaId: ca.id }); + + // decrypt secret + const orgKmsKeyId = await kmsService.getOrgKmsKeyId(actorOrgId); + const kmsDecryptor = await kmsService.decryptWithKmsKey({ + kmsId: orgKmsKeyId + }); + + const decryptedCaPrivateKey = await kmsDecryptor({ + cipherTextBlob: sshCaSecret.encryptedPrivateKey + }); + + const publicKey = getSshPublicKey(decryptedCaPrivateKey.toString("utf-8")); + + return { ...ca, publicKey }; }; /** * Update SSH CA with id [caId] * Note: Used to enable/disable CA */ - const updateSshCaById = async ({ caId, status, actor, actorId, actorAuthMethod, actorOrgId }: TUpdateSshCaDTO) => { + const updateSshCaById = async ({ + caId, + friendlyName, + status, + actor, + actorId, + actorAuthMethod, + actorOrgId + }: TUpdateSshCaDTO) => { const ca = await sshCertificateAuthorityDAL.findById(caId); if (!ca) throw new NotFoundError({ message: `SSH CA with ID '${caId}' not found` }); const { permission } = await permissionService.getOrgPermission( actor, actorId, - actorOrgId, + ca.orgId, actorAuthMethod, actorOrgId ); @@ -152,9 +179,23 @@ export const sshCertificateAuthorityServiceFactory = ({ OrgPermissionSubjects.SshCertificateAuthorities ); - const updatedCa = await sshCertificateAuthorityDAL.updateById(caId, { status }); + const updatedCa = await sshCertificateAuthorityDAL.updateById(caId, { friendlyName, status }); - return updatedCa; + const sshCaSecret = await sshCertificateAuthoritySecretDAL.findOne({ sshCaId: ca.id }); + + // decrypt secret + const orgKmsKeyId = await kmsService.getOrgKmsKeyId(actorOrgId); + const kmsDecryptor = await kmsService.decryptWithKmsKey({ + kmsId: orgKmsKeyId + }); + + const decryptedCaPrivateKey = await kmsDecryptor({ + cipherTextBlob: sshCaSecret.encryptedPrivateKey + }); + + const publicKey = getSshPublicKey(decryptedCaPrivateKey.toString("utf-8")); + + return { ...updatedCa, publicKey }; }; /** @@ -167,7 +208,7 @@ export const sshCertificateAuthorityServiceFactory = ({ const { permission } = await permissionService.getOrgPermission( actor, actorId, - actorOrgId, + ca.orgId, actorAuthMethod, actorOrgId ); @@ -184,10 +225,10 @@ export const sshCertificateAuthorityServiceFactory = ({ /** * Return SSH certificate and corresponding new SSH public-private key pair where - * SSH public key is signed using CA behind SSH certificate with name [name]. + * SSH public key is signed using CA behind SSH certificate with name [templateName]. */ const issueSshCreds = async ({ - name, + templateName, keyAlgorithm, certType, principals, @@ -198,7 +239,13 @@ export const sshCertificateAuthorityServiceFactory = ({ actorAuthMethod, actorOrgId }: TIssueSshCredsDTO) => { - // TODO: proper permission check + const sshCertificateTemplate = await sshCertificateTemplateDAL.getByName(templateName, actorOrgId); + if (!sshCertificateTemplate) { + throw new NotFoundError({ + message: "No SSH certificate template found with specified name" + }); + } + const { permission } = await permissionService.getOrgPermission( actor, actorId, @@ -208,13 +255,10 @@ export const sshCertificateAuthorityServiceFactory = ({ ); ForbiddenError.from(permission).throwUnlessCan( - OrgPermissionActions.Create, + OrgPermissionSshCertificateTemplateActions.IssueSshCredentials, OrgPermissionSubjects.SshCertificateTemplates ); - // TODO: adjust to find within org - const sshCertificateTemplate = await sshCertificateTemplateDAL.findOne({ name }); - // validate if the requested [certType] is allowed under the template configuration validateSshCertificateType(sshCertificateTemplate, certType); @@ -266,10 +310,10 @@ export const sshCertificateAuthorityServiceFactory = ({ /** * Return SSH certificate by signing SSH public key [publicKey] - * using CA behind SSH certificate template with name [name] + * using CA behind SSH certificate template with name [templateName] */ const signSshKey = async ({ - name, + templateName, publicKey, certType, principals, @@ -280,7 +324,13 @@ export const sshCertificateAuthorityServiceFactory = ({ actorAuthMethod, actorOrgId }: TSignSshKeyDTO) => { - // TODO: proper permission check + const sshCertificateTemplate = await sshCertificateTemplateDAL.getByName(templateName, actorOrgId); + if (!sshCertificateTemplate) { + throw new NotFoundError({ + message: "No SSH certificate template found with specified name" + }); + } + const { permission } = await permissionService.getOrgPermission( actor, actorId, @@ -290,13 +340,10 @@ export const sshCertificateAuthorityServiceFactory = ({ ); ForbiddenError.from(permission).throwUnlessCan( - OrgPermissionActions.Create, + OrgPermissionSshCertificateTemplateActions.SignSshKey, OrgPermissionSubjects.SshCertificateTemplates ); - // TODO: adjust to find within org - const sshCertificateTemplate = await sshCertificateTemplateDAL.findOne({ name }); - // validate if the requested [certType] is allowed under the template configuration validateSshCertificateType(sshCertificateTemplate, certType); @@ -354,7 +401,7 @@ export const sshCertificateAuthorityServiceFactory = ({ ); ForbiddenError.from(permission).throwUnlessCan( - OrgPermissionActions.Read, + OrgPermissionSshCertificateTemplateActions.Read, OrgPermissionSubjects.SshCertificateTemplates ); diff --git a/backend/src/ee/services/ssh/ssh-certificate-authority-types.ts b/backend/src/ee/services/ssh/ssh-certificate-authority-types.ts index d91f292e4..ba4e5aa54 100644 --- a/backend/src/ee/services/ssh/ssh-certificate-authority-types.ts +++ b/backend/src/ee/services/ssh/ssh-certificate-authority-types.ts @@ -12,7 +12,7 @@ export enum SshCertType { } export type TCreateSshCaDTO = { - friendlyName?: string; + friendlyName: string; keyAlgorithm: CertKeyAlgorithm; } & Omit; @@ -22,6 +22,7 @@ export type TGetSshCaDTO = { export type TUpdateSshCaDTO = { caId: string; + friendlyName?: string; status?: SshCaStatus; } & Omit; @@ -30,7 +31,7 @@ export type TDeleteSshCaDTO = { } & Omit; export type TIssueSshCredsDTO = { - name: string; // name of SSH certificate template + templateName: string; keyAlgorithm: CertKeyAlgorithm; certType: SshCertType; principals: string[]; @@ -39,7 +40,7 @@ export type TIssueSshCredsDTO = { } & Omit; export type TSignSshKeyDTO = { - name: string; // name of SSH certificate template + templateName: string; publicKey: string; certType: SshCertType; principals: string[]; diff --git a/backend/src/lib/api-docs/constants.ts b/backend/src/lib/api-docs/constants.ts index 87ffddbe8..f1fce544b 100644 --- a/backend/src/lib/api-docs/constants.ts +++ b/backend/src/lib/api-docs/constants.ts @@ -1148,6 +1148,7 @@ export const SSH_CERTIFICATE_AUTHORITIES = { }, UPDATE: { sshCaId: "The ID of the SSH CA to update.", + friendlyName: "A friendly name for the SSH CA to update to.", status: "The status of the SSH CA to update to. This can be one of active or disabled." }, DELETE: { @@ -1155,6 +1156,28 @@ export const SSH_CERTIFICATE_AUTHORITIES = { }, GET_CERTIFICATE_TEMPLATES: { sshCaId: "The ID of the SSH CA to get the certificate templates for." + }, + SIGN_SSH_KEY: { + templateName: "The name of the SSH certificate template to sign the SSH public key with.", + publicKey: "The SSH public key to sign.", + certType: "The type of certificate to issue. This can be one of user or host.", + principals: "The list of principals (usernames, hostnames) to include in the certificate.", + ttl: "The time to live for the certificate such as 1m, 1h, 1d, ... If not specified, the default TTL for the template will be used.", + keyId: "The key ID to include in the certificate. If not specified, a default key ID will be generated.", + serialNumber: "The serial number of the issued SSH certificate.", + signedKey: "The SSH certificate or signed SSH public key." + }, + ISSUE_SSH_CREDENTIALS: { + templateName: "The name of the SSH certificate template to issue the SSH credentials with.", + keyAlgorithm: "The type of public key algorithm and size, in bits, of the key pair for the SSH CA.", + certType: "The type of certificate to issue. This can be one of user or host.", + principals: "The list of principals (usernames, hostnames) to include in the certificate.", + ttl: "The time to live for the certificate such as 1m, 1h, 1d, ... If not specified, the default TTL for the template will be used.", + keyId: "The key ID to include in the certificate. If not specified, a default key ID will be generated.", + serialNumber: "The serial number of the issued SSH certificate.", + signedKey: "The SSH certificate or signed SSH public key.", + privateKey: "The private key corresponding to the issued SSH certificate.", + publicKey: "The public key of the issued SSH certificate." } }; diff --git a/frontend/src/context/OrgPermissionContext/index.tsx b/frontend/src/context/OrgPermissionContext/index.tsx index 730fe55b1..10d7703b8 100644 --- a/frontend/src/context/OrgPermissionContext/index.tsx +++ b/frontend/src/context/OrgPermissionContext/index.tsx @@ -1,3 +1,6 @@ export { OrgPermissionProvider, useOrgPermission } from "./OrgPermissionContext"; export type { TOrgPermission } from "./types"; -export { OrgPermissionActions, OrgPermissionSubjects } from "./types"; +export { + OrgPermissionActions, + OrgPermissionSshCertificateTemplateActions, + OrgPermissionSubjects} from "./types"; diff --git a/frontend/src/context/OrgPermissionContext/types.ts b/frontend/src/context/OrgPermissionContext/types.ts index fb65358ea..4678de7c4 100644 --- a/frontend/src/context/OrgPermissionContext/types.ts +++ b/frontend/src/context/OrgPermissionContext/types.ts @@ -7,6 +7,15 @@ export enum OrgPermissionActions { Delete = "delete" } +export enum OrgPermissionSshCertificateTemplateActions { + Read = "read", + Create = "create", + Edit = "edit", + Delete = "delete", + SignSshKey = "sign-ssh-key", + IssueSshCredentials = "issue-ssh-credentials" +} + export enum OrgPermissionSubjects { Workspace = "workspace", Role = "role", @@ -51,6 +60,6 @@ export type OrgPermissionSet = | [OrgPermissionActions, OrgPermissionSubjects.AuditLogs] | [OrgPermissionActions, OrgPermissionSubjects.ProjectTemplates] | [OrgPermissionActions, OrgPermissionSubjects.SshCertificateAuthorities] - | [OrgPermissionActions, OrgPermissionSubjects.SshCertificateTemplates]; + | [OrgPermissionSshCertificateTemplateActions, OrgPermissionSubjects.SshCertificateTemplates]; export type TOrgPermission = MongoAbility; diff --git a/frontend/src/context/index.tsx b/frontend/src/context/index.tsx index 91dae5d2d..83e771111 100644 --- a/frontend/src/context/index.tsx +++ b/frontend/src/context/index.tsx @@ -4,6 +4,7 @@ export type { TOrgPermission } from "./OrgPermissionContext"; export { OrgPermissionActions, OrgPermissionProvider, + OrgPermissionSshCertificateTemplateActions, OrgPermissionSubjects, useOrgPermission } from "./OrgPermissionContext"; diff --git a/frontend/src/hooks/api/organization/queries.tsx b/frontend/src/hooks/api/organization/queries.tsx index 3fa515a46..f34c0ead1 100644 --- a/frontend/src/hooks/api/organization/queries.tsx +++ b/frontend/src/hooks/api/organization/queries.tsx @@ -504,7 +504,7 @@ export const useListOrgSshCas = ({ orgId }: { orgId: string }) => { queryFn: async () => { const { data: { cas } - } = await apiRequest.get<{ cas: TSshCertificateAuthority[] }>( + } = await apiRequest.get<{ cas: Omit[] }>( `/api/v1/organization/${orgId}/ssh-cas` ); return cas; diff --git a/frontend/src/hooks/api/ssh-ca/types.ts b/frontend/src/hooks/api/ssh-ca/types.ts index 1ccfe485b..a1539ef31 100644 --- a/frontend/src/hooks/api/ssh-ca/types.ts +++ b/frontend/src/hooks/api/ssh-ca/types.ts @@ -9,6 +9,7 @@ export type TSshCertificateAuthority = { keyAlgorithm: CertKeyAlgorithm; createdAt: string; updatedAt: string; + publicKey: string; }; export type TCreateSshCaDTO = { @@ -18,6 +19,7 @@ export type TCreateSshCaDTO = { export type TUpdateSshCaDTO = { caId: string; + friendlyName?: string; status?: SshCaStatus; }; diff --git a/frontend/src/views/Org/RolePage/components/OrgRoleModifySection.utils.ts b/frontend/src/views/Org/RolePage/components/OrgRoleModifySection.utils.ts index aa8c4d7ec..dad753002 100644 --- a/frontend/src/views/Org/RolePage/components/OrgRoleModifySection.utils.ts +++ b/frontend/src/views/Org/RolePage/components/OrgRoleModifySection.utils.ts @@ -13,6 +13,17 @@ const generalPermissionSchema = z }) .optional(); +const sshCertificateTemplatePermissionSchmea = z + .object({ + read: z.boolean().optional(), + edit: z.boolean().optional(), + delete: z.boolean().optional(), + create: z.boolean().optional(), + "sign-ssh-key": z.boolean().optional(), + "issue-ssh-credentials": z.boolean().optional() + }) + .optional(); + const adminConsolePermissionSchmea = z .object({ "access-all-projects": z.boolean().optional() @@ -49,7 +60,9 @@ export const formSchema = z.object({ identity: generalPermissionSchema, "organization-admin-console": adminConsolePermissionSchmea, [OrgPermissionSubjects.Kms]: generalPermissionSchema, - [OrgPermissionSubjects.ProjectTemplates]: generalPermissionSchema + [OrgPermissionSubjects.ProjectTemplates]: generalPermissionSchema, + [OrgPermissionSubjects.SshCertificateAuthorities]: generalPermissionSchema, + [OrgPermissionSubjects.SshCertificateTemplates]: sshCertificateTemplatePermissionSchmea }) .optional() }); diff --git a/frontend/src/views/Org/RolePage/components/RoleModal.tsx b/frontend/src/views/Org/RolePage/components/RoleModal.tsx index ab909d931..e41e99e56 100644 --- a/frontend/src/views/Org/RolePage/components/RoleModal.tsx +++ b/frontend/src/views/Org/RolePage/components/RoleModal.tsx @@ -70,12 +70,6 @@ export const RoleModal = ({ popUp, handlePopUpToggle }: Props) => { const onFormSubmit = async ({ name, description, slug }: FormData) => { try { - console.log("onFormSubmit args: ", { - name, - description, - slug - }); - if (!orgId) return; if (role) { diff --git a/frontend/src/views/Org/RolePage/components/RolePermissionsSection/RolePermissionRow.tsx b/frontend/src/views/Org/RolePage/components/RolePermissionsSection/RolePermissionRow.tsx index bfb5678ea..400a7b6bb 100644 --- a/frontend/src/views/Org/RolePage/components/RolePermissionsSection/RolePermissionRow.tsx +++ b/frontend/src/views/Org/RolePage/components/RolePermissionsSection/RolePermissionRow.tsx @@ -51,6 +51,15 @@ const PROJECT_TEMPLATES_PERMISSIONS = [ { action: "delete", label: "Remove" } ] as const; +const SSH_CERTIFICATE_TEMPLATES_PERMISSIONS = [ + { action: "read", label: "Read" }, + { action: "create", label: "Create" }, + { action: "edit", label: "Modify" }, + { action: "delete", label: "Remove" }, + { action: "sign-ssh-key", label: "Sign SSH Key" }, + { action: "issue-ssh-credentials", label: "Issue SSH Credentials" } +] as const; + const getPermissionList = (option: string) => { switch (option) { case "secret-scanning": @@ -63,6 +72,8 @@ const getPermissionList = (option: string) => { return MEMBERS_PERMISSIONS; case OrgPermissionSubjects.ProjectTemplates: return PROJECT_TEMPLATES_PERMISSIONS; + case OrgPermissionSubjects.SshCertificateTemplates: + return SSH_CERTIFICATE_TEMPLATES_PERMISSIONS; default: return PERMISSIONS; } @@ -97,7 +108,7 @@ export const RolePermissionRow = ({ isEditable, title, formName, control, setVal const selectedPermissionCategory = useMemo(() => { const actions = Object.keys(rule || {}) as Array; - const totalActions = PERMISSIONS.length; + const totalActions = getPermissionList(formName).length; const score = actions.map((key) => (rule?.[key] ? 1 : 0)).reduce((a, b) => a + b, 0 as number); if (isCustom) return Permission.Custom; diff --git a/frontend/src/views/Org/RolePage/components/RolePermissionsSection/RolePermissionsSection.tsx b/frontend/src/views/Org/RolePage/components/RolePermissionsSection/RolePermissionsSection.tsx index 976b35636..ddf2ec097 100644 --- a/frontend/src/views/Org/RolePage/components/RolePermissionsSection/RolePermissionsSection.tsx +++ b/frontend/src/views/Org/RolePage/components/RolePermissionsSection/RolePermissionsSection.tsx @@ -69,7 +69,15 @@ const SIMPLE_PERMISSION_OPTIONS = [ title: "External KMS", formName: OrgPermissionSubjects.Kms }, - { title: "Project Templates", formName: OrgPermissionSubjects.ProjectTemplates } + { title: "Project Templates", formName: OrgPermissionSubjects.ProjectTemplates }, + { + title: "SSH Certificate Authorities", + formName: OrgPermissionSubjects.SshCertificateAuthorities + }, + { + title: "SSH Certificate Templates", + formName: OrgPermissionSubjects.SshCertificateTemplates + } ] as const; type Props = { diff --git a/frontend/src/views/Org/SshCaPage/SshCaPage.tsx b/frontend/src/views/Org/SshCaPage/SshCaPage.tsx index b81478e4f..e0619d5a4 100644 --- a/frontend/src/views/Org/SshCaPage/SshCaPage.tsx +++ b/frontend/src/views/Org/SshCaPage/SshCaPage.tsx @@ -26,6 +26,7 @@ import { withPermission } from "@app/hoc"; import { useDeleteSshCa, useGetSshCaById } from "@app/hooks/api"; import { usePopUp } from "@app/hooks/usePopUp"; +import { SshCaModal } from "../SshPage/components/SshCaModal"; import { SshCaDetailsSection, SshCertificateTemplatesSection } from "./components"; export const SshCaPage = withPermission( @@ -123,6 +124,7 @@ export const SshCaPage = withPermission( )} + { const [copyTextId, isCopyingId, setCopyTextId] = useTimedReset({ initialState: "Copy ID to clipboard" }); + const [downloadText, isDownloading, setDownloadText] = useTimedReset({ + initialState: "Save public key" + }); const { data: ca } = useGetSshCaById(caId); + const downloadTxtFile = (filename: string, content: string) => { + const blob = new Blob([content], { type: "text/plain;charset=utf-8" }); + FileSaver.saveAs(blob, filename); + }; + return ca ? (
-

CA Details

+

SSH CA Details

{

Status

{caStatusToNameMap[ca.status]}

-
+

Key Algorithm

{certKeyAlgorithmToNameMap[ca.keyAlgorithm]}

+
+

Public Key

+
+

{ca.publicKey.substring(0, 20)}...

+
+ + { + setDownloadText("Saved"); + downloadTxtFile("ssh_ca.pub", ca.publicKey); + }} + > + + + +
+
+
) : ( diff --git a/frontend/src/views/Org/SshCaPage/components/SshCertificateTemplateModal.tsx b/frontend/src/views/Org/SshCaPage/components/SshCertificateTemplateModal.tsx index acfb523fe..779ee65c2 100644 --- a/frontend/src/views/Org/SshCaPage/components/SshCertificateTemplateModal.tsx +++ b/frontend/src/views/Org/SshCaPage/components/SshCertificateTemplateModal.tsx @@ -185,7 +185,7 @@ export const SshCertificateTemplateModal = ({ popUp, handlePopUpToggle, sshCaId errorText={error?.message} isRequired > - + )} /> diff --git a/frontend/src/views/Org/SshCaPage/components/SshCertificateTemplatesSection.tsx b/frontend/src/views/Org/SshCaPage/components/SshCertificateTemplatesSection.tsx index a518e96f4..ecf19d22f 100644 --- a/frontend/src/views/Org/SshCaPage/components/SshCertificateTemplatesSection.tsx +++ b/frontend/src/views/Org/SshCaPage/components/SshCertificateTemplatesSection.tsx @@ -4,7 +4,7 @@ import { FontAwesomeIcon } from "@fortawesome/react-fontawesome"; import { createNotification } from "@app/components/notifications"; import { OrgPermissionCan } from "@app/components/permissions"; import { DeleteActionModal, IconButton } from "@app/components/v2"; -import { OrgPermissionActions, OrgPermissionSubjects } from "@app/context"; +import { OrgPermissionSshCertificateTemplateActions,OrgPermissionSubjects } from "@app/context"; import { usePopUp } from "@app/hooks"; import { useDeleteSshCertTemplate } from "@app/hooks/api"; @@ -50,7 +50,7 @@ export const SshCertificateTemplatesSection = ({ caId }: Props) => {

Certificate Templates

{(isAllowed) => ( diff --git a/frontend/src/views/Org/SshCaPage/components/SshCertificateTemplatesTable.tsx b/frontend/src/views/Org/SshCaPage/components/SshCertificateTemplatesTable.tsx index 2365990fa..a761772b3 100644 --- a/frontend/src/views/Org/SshCaPage/components/SshCertificateTemplatesTable.tsx +++ b/frontend/src/views/Org/SshCaPage/components/SshCertificateTemplatesTable.tsx @@ -19,7 +19,7 @@ import { Tooltip, Tr } from "@app/components/v2"; -import { OrgPermissionActions, OrgPermissionSubjects } from "@app/context"; +import { OrgPermissionSshCertificateTemplateActions,OrgPermissionSubjects } from "@app/context"; import { useGetSshCaCertTemplates } from "@app/hooks/api"; import { UsePopUpState } from "@app/hooks/usePopUp"; @@ -66,18 +66,23 @@ export const SshCertificateTemplatesTable = ({ handlePopUpOpen, sshCaId }: Props
- - handlePopUpOpen("sshCertificateTemplate", { - id: certificateTemplate.id - }) - } - icon={} - > - Edit Template - + + handlePopUpOpen("sshCertificateTemplate", { + id: certificateTemplate.id + }) + } + icon={} + > + Edit Template + + + {(isAllowed) => ( diff --git a/frontend/src/views/Org/SshPage/components/SshCaModal.tsx b/frontend/src/views/Org/SshPage/components/SshCaModal.tsx index ec6c17239..d57633801 100644 --- a/frontend/src/views/Org/SshPage/components/SshCaModal.tsx +++ b/frontend/src/views/Org/SshPage/components/SshCaModal.tsx @@ -1,3 +1,4 @@ +import { useEffect } from "react"; import { Controller, useForm } from "react-hook-form"; import { zodResolver } from "@hookform/resolvers/zod"; import { z } from "zod"; @@ -55,15 +56,28 @@ export const SshCaModal = ({ popUp, handlePopUpToggle }: Props) => { } }); + useEffect(() => { + if (ca) { + reset({ + friendlyName: ca.friendlyName, + keyAlgorithm: ca.keyAlgorithm + }); + } else { + reset({ + friendlyName: "", + keyAlgorithm: CertKeyAlgorithm.RSA_2048 + }); + } + }, [ca]); + const onFormSubmit = async ({ friendlyName, keyAlgorithm }: FormData) => { try { if (ca) { - // update await updateMutateAsync({ - caId: ca.id + caId: ca.id, + friendlyName }); } else { - // create await createMutateAsync({ friendlyName, keyAlgorithm @@ -112,7 +126,7 @@ export const SshCaModal = ({ popUp, handlePopUpToggle }: Props) => { errorText={error?.message} isRequired > - + )} />