From 5641d334cd9e86e24fb830679f219265fa6ab9f7 Mon Sep 17 00:00:00 2001 From: x Date: Tue, 29 Apr 2025 19:24:00 -0400 Subject: [PATCH 01/99] checkpoint --- ...03304_certificates-ca-relation-optional.ts | 16 ++ ...certificates-update-body-and-add-secret.ts | 33 +++ .../20250429223815_certificates-project-id.ts | 16 ++ backend/src/db/schemas/certificate-bodies.ts | 3 +- backend/src/db/schemas/certificate-secrets.ts | 5 +- backend/src/db/schemas/certificates.ts | 4 +- backend/src/db/schemas/organizations.ts | 1 - backend/src/db/schemas/projects.ts | 2 +- backend/src/lib/api-docs/constants.ts | 13 + .../server/routes/v1/certificate-router.ts | 73 ++++++ .../certificate/certificate-service.ts | 198 +++++++++++++++- .../services/certificate/certificate-types.ts | 11 + frontend/src/hooks/api/certificates/index.tsx | 2 +- .../src/hooks/api/certificates/mutations.tsx | 26 +- frontend/src/hooks/api/certificates/types.ts | 20 ++ .../components/CertificateImportModal.tsx | 223 ++++++++++++++++++ .../components/CertificatesSection.tsx | 33 ++- 17 files changed, 653 insertions(+), 26 deletions(-) create mode 100644 backend/src/db/migrations/20250429203304_certificates-ca-relation-optional.ts create mode 100644 backend/src/db/migrations/20250429210424_certificates-update-body-and-add-secret.ts create mode 100644 backend/src/db/migrations/20250429223815_certificates-project-id.ts create mode 100644 frontend/src/pages/cert-manager/CertificatesPage/components/CertificateImportModal.tsx diff --git a/backend/src/db/migrations/20250429203304_certificates-ca-relation-optional.ts b/backend/src/db/migrations/20250429203304_certificates-ca-relation-optional.ts new file mode 100644 index 000000000..38f106731 --- /dev/null +++ b/backend/src/db/migrations/20250429203304_certificates-ca-relation-optional.ts @@ -0,0 +1,16 @@ +import { Knex } from "knex"; + +import { TableName } from "../schemas"; + +export async function up(knex: Knex): Promise { + if (await knex.schema.hasTable(TableName.Certificate)) { + await knex.schema.alterTable(TableName.Certificate, (t) => { + t.uuid("caId").nullable().alter(); + t.uuid("caCertId").nullable().alter(); + }); + } +} + +export async function down(): Promise { + // Altering back to nullable will fail +} diff --git a/backend/src/db/migrations/20250429210424_certificates-update-body-and-add-secret.ts b/backend/src/db/migrations/20250429210424_certificates-update-body-and-add-secret.ts new file mode 100644 index 000000000..cb5e44a03 --- /dev/null +++ b/backend/src/db/migrations/20250429210424_certificates-update-body-and-add-secret.ts @@ -0,0 +1,33 @@ +import { Knex } from "knex"; + +import { TableName } from "../schemas"; + +export async function up(knex: Knex): Promise { + if (await knex.schema.hasTable(TableName.CertificateBody)) { + await knex.schema.alterTable(TableName.CertificateBody, (t) => { + t.binary("encryptedCertificateChain").nullable(); + }); + } + + if (!(await knex.schema.hasTable(TableName.CertificateSecret))) { + await knex.schema.createTable(TableName.CertificateSecret, (t) => { + t.uuid("id", { primaryKey: true }).defaultTo(knex.fn.uuid()); + t.timestamps(true, true, true); + t.uuid("certId").notNullable().unique(); + t.foreign("certId").references("id").inTable(TableName.Certificate).onDelete("CASCADE"); + t.binary("encryptedPrivateKey").notNullable(); + }); + } +} + +export async function down(knex: Knex): Promise { + if (await knex.schema.hasTable(TableName.CertificateSecret)) { + await knex.schema.dropTable(TableName.CertificateSecret); + } + + if (await knex.schema.hasTable(TableName.CertificateBody)) { + await knex.schema.alterTable(TableName.CertificateBody, (t) => { + t.dropColumn("encryptedCertificateChain"); + }); + } +} diff --git a/backend/src/db/migrations/20250429223815_certificates-project-id.ts b/backend/src/db/migrations/20250429223815_certificates-project-id.ts new file mode 100644 index 000000000..556913a6b --- /dev/null +++ b/backend/src/db/migrations/20250429223815_certificates-project-id.ts @@ -0,0 +1,16 @@ +import { Knex } from "knex"; + +import { TableName } from "../schemas"; + +export async function up(knex: Knex): Promise { + if (await knex.schema.hasTable(TableName.Certificate)) { + await knex.schema.alterTable(TableName.Certificate, (t) => { + t.uuid("projectId").notNullable(); + t.foreign("projectId").references("id").inTable(TableName.Certificate).onDelete("CASCADE"); + }); + } +} + +export async function down(knex: Knex): Promise { + // .. tbd +} diff --git a/backend/src/db/schemas/certificate-bodies.ts b/backend/src/db/schemas/certificate-bodies.ts index 75afbddbd..10171e383 100644 --- a/backend/src/db/schemas/certificate-bodies.ts +++ b/backend/src/db/schemas/certificate-bodies.ts @@ -14,7 +14,8 @@ export const CertificateBodiesSchema = z.object({ createdAt: z.date(), updatedAt: z.date(), certId: z.string().uuid(), - encryptedCertificate: zodBuffer + encryptedCertificate: zodBuffer, + encryptedCertificateChain: zodBuffer.nullable().optional() }); export type TCertificateBodies = z.infer; diff --git a/backend/src/db/schemas/certificate-secrets.ts b/backend/src/db/schemas/certificate-secrets.ts index f8cad74f1..75e6377b2 100644 --- a/backend/src/db/schemas/certificate-secrets.ts +++ b/backend/src/db/schemas/certificate-secrets.ts @@ -5,6 +5,8 @@ import { z } from "zod"; +import { zodBuffer } from "@app/lib/zod"; + import { TImmutableDBKeys } from "./models"; export const CertificateSecretsSchema = z.object({ @@ -12,8 +14,7 @@ export const CertificateSecretsSchema = z.object({ createdAt: z.date(), updatedAt: z.date(), certId: z.string().uuid(), - pk: z.string(), - sk: z.string() + encryptedPrivateKey: zodBuffer }); export type TCertificateSecrets = z.infer; diff --git a/backend/src/db/schemas/certificates.ts b/backend/src/db/schemas/certificates.ts index 533f9b898..f46a86e0a 100644 --- a/backend/src/db/schemas/certificates.ts +++ b/backend/src/db/schemas/certificates.ts @@ -11,7 +11,7 @@ export const CertificatesSchema = z.object({ id: z.string().uuid(), createdAt: z.date(), updatedAt: z.date(), - caId: z.string().uuid(), + caId: z.string().uuid().nullable().optional(), status: z.string(), serialNumber: z.string(), friendlyName: z.string(), @@ -21,7 +21,7 @@ export const CertificatesSchema = z.object({ revokedAt: z.date().nullable().optional(), revocationReason: z.number().nullable().optional(), altNames: z.string().nullable().optional(), - caCertId: z.string().uuid(), + caCertId: z.string().uuid().nullable().optional(), certificateTemplateId: z.string().uuid().nullable().optional(), keyUsages: z.string().array().nullable().optional(), extendedKeyUsages: z.string().array().nullable().optional() diff --git a/backend/src/db/schemas/organizations.ts b/backend/src/db/schemas/organizations.ts index 902c564a7..eea1808e0 100644 --- a/backend/src/db/schemas/organizations.ts +++ b/backend/src/db/schemas/organizations.ts @@ -23,7 +23,6 @@ export const OrganizationsSchema = z.object({ defaultMembershipRole: z.string().default("member"), enforceMfa: z.boolean().default(false), selectedMfaMethod: z.string().nullable().optional(), - secretShareSendToAnyone: z.boolean().default(true).nullable().optional(), allowSecretSharingOutsideOrganization: z.boolean().default(true).nullable().optional(), shouldUseNewPrivilegeSystem: z.boolean().default(true), privilegeUpgradeInitiatedByUsername: z.string().nullable().optional(), diff --git a/backend/src/db/schemas/projects.ts b/backend/src/db/schemas/projects.ts index 2403d6cf4..297601fd0 100644 --- a/backend/src/db/schemas/projects.ts +++ b/backend/src/db/schemas/projects.ts @@ -27,7 +27,7 @@ export const ProjectsSchema = z.object({ description: z.string().nullable().optional(), type: z.string(), enforceCapitalization: z.boolean().default(false), - hasDeleteProtection: z.boolean().default(true).nullable().optional() + hasDeleteProtection: z.boolean().default(false).nullable().optional() }); export type TProjects = z.infer; diff --git a/backend/src/lib/api-docs/constants.ts b/backend/src/lib/api-docs/constants.ts index 19ee7e331..587f23892 100644 --- a/backend/src/lib/api-docs/constants.ts +++ b/backend/src/lib/api-docs/constants.ts @@ -1581,6 +1581,19 @@ export const CERTIFICATES = { certificate: "The certificate body of the certificate.", certificateChain: "The certificate chain of the certificate.", serialNumberRes: "The serial number of the certificate." + }, + IMPORT: { + projectSlug: "Slug of the project to import the certificate into.", + certificatePem: "The PEM-encoded leaf certificate.", + privateKeyPem: "The PEM-encoded private key corresponding to the certificate.", + chainPem: "The PEM-encoded chain of intermediate certificates.", + friendlyName: "A friendly name for the certificate.", + pkiCollectionId: "The ID of the PKI collection to add the certificate to.", + + certificate: "The issued certificate.", + certificateChain: "The certificate chain of the issued certificate.", + privateKey: "The private key of the issued certificate.", + serialNumber: "The serial number of the issued certificate." } }; diff --git a/backend/src/server/routes/v1/certificate-router.ts b/backend/src/server/routes/v1/certificate-router.ts index ea33e948f..1acfe0d7c 100644 --- a/backend/src/server/routes/v1/certificate-router.ts +++ b/backend/src/server/routes/v1/certificate-router.ts @@ -177,6 +177,79 @@ export const registerCertRouter = async (server: FastifyZodProvider) => { } }); + server.route({ + method: "POST", + url: "/import-certificate", + config: { + rateLimit: writeLimit + }, + onRequest: verifyAuth([AuthMode.JWT, AuthMode.IDENTITY_ACCESS_TOKEN]), + schema: { + hide: false, + tags: [ApiDocsTags.PkiCertificates], + description: "Import certificate", + body: z.object({ + projectSlug: z.string().trim().min(1).describe(CERTIFICATES.IMPORT.projectSlug), + + certificatePem: z.string().trim().min(1).describe(CERTIFICATES.IMPORT.certificatePem), + privateKeyPem: z.string().trim().optional().describe(CERTIFICATES.IMPORT.privateKeyPem), + chainPem: z.string().trim().optional().describe(CERTIFICATES.IMPORT.chainPem), + + friendlyName: z.string().trim().optional().describe(CERTIFICATES.IMPORT.friendlyName), + pkiCollectionId: z.string().trim().optional().describe(CERTIFICATES.IMPORT.pkiCollectionId) + }), + response: { + 200: z.object({ + certificate: z.string().trim().describe(CERTIFICATES.IMPORT.certificate), + certificateChain: z.string().trim().optional().describe(CERTIFICATES.IMPORT.certificateChain), + privateKey: z.string().trim().optional().describe(CERTIFICATES.IMPORT.privateKey), + serialNumber: z.string().trim().describe(CERTIFICATES.IMPORT.serialNumber) + }) + } + }, + handler: async (req) => { + const { certificate, certificateChain, privateKey, serialNumber } = await server.services.certificate.importCert({ + actor: req.permission.type, + actorId: req.permission.id, + actorAuthMethod: req.permission.authMethod, + actorOrgId: req.permission.orgId, + ...req.body + }); + + // TODO(andrey): Add logs + // await server.services.auditLog.createAuditLog({ + // ...req.auditLogInfo, + // projectId: ca.projectId, + // event: { + // type: EventType.ISSUE_CERT, + // metadata: { + // caId: ca.id, + // dn: ca.dn, + // serialNumber + // } + // } + // }); + + // await server.services.telemetry.sendPostHogEvents({ + // event: PostHogEventTypes.IssueCert, + // distinctId: getTelemetryDistinctId(req), + // properties: { + // caId: req.body.caId, + // certificateTemplateId: req.body.certificateTemplateId, + // commonName: req.body.commonName, + // ...req.auditLogInfo + // } + // }); + + return { + certificate, + certificateChain, + privateKey, + serialNumber + }; + } + }); + server.route({ method: "POST", url: "/sign-certificate", diff --git a/backend/src/services/certificate/certificate-service.ts b/backend/src/services/certificate/certificate-service.ts index 0ca0d64c6..3555b866b 100644 --- a/backend/src/services/certificate/certificate-service.ts +++ b/backend/src/services/certificate/certificate-service.ts @@ -1,31 +1,47 @@ import { ForbiddenError } from "@casl/ability"; import * as x509 from "@peculiar/x509"; +import { createPrivateKey, createPublicKey, sign, verify } from "crypto"; -import { ActionProjectType } from "@app/db/schemas"; +import { ActionProjectType, ProjectType } from "@app/db/schemas"; import { TCertificateAuthorityCrlDALFactory } from "@app/ee/services/certificate-authority-crl/certificate-authority-crl-dal"; import { TPermissionServiceFactory } from "@app/ee/services/permission/permission-service"; import { ProjectPermissionActions, ProjectPermissionSub } from "@app/ee/services/permission/project-permission"; +import { BadRequestError, NotFoundError } from "@app/lib/errors"; import { TCertificateBodyDALFactory } from "@app/services/certificate/certificate-body-dal"; import { TCertificateDALFactory } from "@app/services/certificate/certificate-dal"; import { TCertificateAuthorityCertDALFactory } from "@app/services/certificate-authority/certificate-authority-cert-dal"; import { TCertificateAuthorityDALFactory } from "@app/services/certificate-authority/certificate-authority-dal"; import { TCertificateAuthoritySecretDALFactory } from "@app/services/certificate-authority/certificate-authority-secret-dal"; import { TKmsServiceFactory } from "@app/services/kms/kms-service"; +import { TPkiCollectionDALFactory } from "@app/services/pki-collection/pki-collection-dal"; +import { TPkiCollectionItemDALFactory } from "@app/services/pki-collection/pki-collection-item-dal"; import { TProjectDALFactory } from "@app/services/project/project-dal"; import { getProjectKmsCertificateKeyId } from "@app/services/project/project-fns"; import { getCaCertChain, rebuildCaCrl } from "../certificate-authority/certificate-authority-fns"; import { revocationReasonToCrlCode } from "./certificate-fns"; -import { CertStatus, TDeleteCertDTO, TGetCertBodyDTO, TGetCertDTO, TRevokeCertDTO } from "./certificate-types"; +import { + CertStatus, + TDeleteCertDTO, + TGetCertBodyDTO, + TGetCertDTO, + TImportCertDTO, + TRevokeCertDTO +} from "./certificate-types"; type TCertificateServiceFactoryDep = { - certificateDAL: Pick; - certificateBodyDAL: Pick; + certificateDAL: Pick; + certificateBodyDAL: Pick; certificateAuthorityDAL: Pick; certificateAuthorityCertDAL: Pick; certificateAuthorityCrlDAL: Pick; certificateAuthoritySecretDAL: Pick; - projectDAL: Pick; + pkiCollectionDAL: Pick; + pkiCollectionItemDAL: Pick; + projectDAL: Pick< + TProjectDALFactory, + "findProjectBySlug" | "findOne" | "updateById" | "findById" | "transaction" | "getProjectFromSplitId" + >; kmsService: Pick; permissionService: Pick; }; @@ -39,6 +55,8 @@ export const certificateServiceFactory = ({ certificateAuthorityCertDAL, certificateAuthorityCrlDAL, certificateAuthoritySecretDAL, + pkiCollectionDAL, + pkiCollectionItemDAL, projectDAL, kmsService, permissionService @@ -48,7 +66,12 @@ export const certificateServiceFactory = ({ */ const getCert = async ({ serialNumber, actorId, actorAuthMethod, actor, actorOrgId }: TGetCertDTO) => { const cert = await certificateDAL.findOne({ serialNumber }); - const ca = await certificateAuthorityDAL.findById(cert.caId); + + let ca; + + if (cert.caId) { + ca = await certificateAuthorityDAL.findById(cert.caId); + } const { permission } = await permissionService.getProjectPermission({ actor, @@ -201,10 +224,171 @@ export const certificateServiceFactory = ({ }; }; + /** + * Import certificate + */ + const importCert = async ({ + projectSlug, + pkiCollectionId, + actorId, + actorAuthMethod, + actor, + actorOrgId, + friendlyName, + certificatePem, + chainPem, + privateKeyPem + }: TImportCertDTO) => { + const collectionId = pkiCollectionId; + + const project = await projectDAL.findProjectBySlug(projectSlug, actorOrgId); + if (!project) throw new NotFoundError({ message: `Project with slug '${projectSlug}' not found` }); + let projectId = project.id; + + const certManagerProjectFromSplit = await projectDAL.getProjectFromSplitId( + projectId, + ProjectType.CertificateManager + ); + if (certManagerProjectFromSplit) { + projectId = certManagerProjectFromSplit.id; + } + + const { permission } = await permissionService.getProjectPermission({ + actor, + actorId, + projectId, + actorAuthMethod, + actorOrgId, + actionProjectType: ActionProjectType.CertificateManager + }); + + ForbiddenError.from(permission).throwUnlessCan(ProjectPermissionActions.Create, ProjectPermissionSub.Certificates); + + // Check PKI collection + if (collectionId) { + const pkiCollection = await pkiCollectionDAL.findById(collectionId); + if (!pkiCollection) throw new NotFoundError({ message: "PKI collection not found" }); + if (pkiCollection.projectId !== projectId) throw new BadRequestError({ message: "Invalid PKI collection" }); + } + + // Parse the certificate + const certObj = new x509.X509Certificate(certificatePem); + + // Verify the certificate chain + if (chainPem) { + const chainCert = new x509.X509Certificate(chainPem); + if (!(await certObj.verify({ publicKey: chainCert.publicKey }))) { + throw new BadRequestError({ message: "Certificate chain verification failed" }); + } + } + + // If private key provided, verify it matches the certificate + if (privateKeyPem) { + try { + const message = Buffer.from("certificate-verification-test"); + + const privateKey = createPrivateKey(privateKeyPem); + const publicKey = createPublicKey(certificatePem); + + const signature = sign(null, message, privateKey); + const isValid = verify(null, message, publicKey, signature); + + if (!isValid) { + throw new BadRequestError({ message: "Private key does not match certificate" }); + } + } catch (err) { + throw new BadRequestError({ message: "Invalid private key format" }); + } + } + + // Get certificate attributes + const commonName = Array.from(certObj.subjectName.getField("CN")?.values() || [])[0] || ""; + + let altNames: undefined | string; + const sanExtension = certObj.extensions.find((ext) => ext.type === "2.5.29.17"); + if (sanExtension) { + const sanNames = new x509.GeneralNames(sanExtension.value); + altNames = sanNames.items.map((name) => name.value).join(", "); + } + + const { serialNumber, notBefore, notAfter } = certObj; + + // Encrypt certificate for storage + const certificateManagerKeyId = await getProjectKmsCertificateKeyId({ + projectId, + projectDAL, + kmsService + }); + const kmsEncryptor = await kmsService.encryptWithKmsKey({ + kmsId: certificateManagerKeyId + }); + + const { cipherTextBlob: encryptedCertificate } = await kmsEncryptor({ + plainText: Buffer.from(certificatePem) + }); + + let encryptedCertificateChain: undefined | Buffer; + if (chainPem) { + const { cipherTextBlob } = await kmsEncryptor({ + plainText: Buffer.from(chainPem) + }); + encryptedCertificateChain = cipherTextBlob; + } + + console.log(friendlyName, commonName, altNames, serialNumber, notBefore, notAfter); + + // Store in database + await certificateDAL.transaction(async (tx) => { + const cert = await certificateDAL.create( + { + status: CertStatus.ACTIVE, + friendlyName: friendlyName || commonName, + commonName, + altNames, + serialNumber, + notBefore, + notAfter + // keyUsages, + // extendedKeyUsages + }, + tx + ); + + await certificateBodyDAL.create( + { + certId: cert.id, + encryptedCertificate, + encryptedCertificateChain + }, + tx + ); + + if (collectionId) { + await pkiCollectionItemDAL.create( + { + pkiCollectionId: collectionId, + certId: cert.id + }, + tx + ); + } + + return cert; + }); + + return { + certificate: certificatePem, + certificateChain: chainPem, + privateKey: privateKeyPem, + serialNumber + }; + }; + return { getCert, deleteCert, revokeCert, - getCertBody + getCertBody, + importCert }; }; diff --git a/backend/src/services/certificate/certificate-types.ts b/backend/src/services/certificate/certificate-types.ts index ef63f142d..228597661 100644 --- a/backend/src/services/certificate/certificate-types.ts +++ b/backend/src/services/certificate/certificate-types.ts @@ -73,3 +73,14 @@ export type TRevokeCertDTO = { export type TGetCertBodyDTO = { serialNumber: string; } & Omit; + +export type TImportCertDTO = { + projectSlug: string; + + friendlyName?: string; + pkiCollectionId?: string; + + certificatePem: string; + privateKeyPem?: string; + chainPem?: string; +} & Omit; diff --git a/frontend/src/hooks/api/certificates/index.tsx b/frontend/src/hooks/api/certificates/index.tsx index dd922fd6a..2a79b647c 100644 --- a/frontend/src/hooks/api/certificates/index.tsx +++ b/frontend/src/hooks/api/certificates/index.tsx @@ -1,2 +1,2 @@ -export { useDeleteCert, useRevokeCert } from "./mutations"; +export { useDeleteCert, useRevokeCert, useImportCertificate } from "./mutations"; export { useGetCert, useGetCertBody } from "./queries"; diff --git a/frontend/src/hooks/api/certificates/mutations.tsx b/frontend/src/hooks/api/certificates/mutations.tsx index 7e9cf4f91..f231b7963 100644 --- a/frontend/src/hooks/api/certificates/mutations.tsx +++ b/frontend/src/hooks/api/certificates/mutations.tsx @@ -3,7 +3,13 @@ import { useMutation, useQueryClient } from "@tanstack/react-query"; import { apiRequest } from "@app/config/request"; import { workspaceKeys } from "../workspace"; -import { TCertificate, TDeleteCertDTO, TRevokeCertDTO } from "./types"; +import { + TCertificate, + TDeleteCertDTO, + TImportCertificateDTO, + TImportCertificateResponse, + TRevokeCertDTO +} from "./types"; export const useDeleteCert = () => { const queryClient = useQueryClient(); @@ -45,3 +51,21 @@ export const useRevokeCert = () => { } }); }; + +export const useImportCertificate = () => { + const queryClient = useQueryClient(); + return useMutation({ + mutationFn: async (body) => { + const { data } = await apiRequest.post( + "/api/v1/pki/certificates/import-certificate", + body + ); + return data; + }, + onSuccess: (_, { projectSlug }) => { + queryClient.invalidateQueries({ + queryKey: workspaceKeys.forWorkspaceCertificates(projectSlug) + }); + } + }); +}; diff --git a/frontend/src/hooks/api/certificates/types.ts b/frontend/src/hooks/api/certificates/types.ts index a9bcf5fbc..263f9d2be 100644 --- a/frontend/src/hooks/api/certificates/types.ts +++ b/frontend/src/hooks/api/certificates/types.ts @@ -25,3 +25,23 @@ export type TRevokeCertDTO = { serialNumber: string; revocationReason: string; }; + +export type TImportCertificateDTO = { + projectSlug: string; + + certificatePem: string; + privateKeyPem?: string; + chainPem?: string; + + pkiCollectionId?: string; + friendlyName?: string; +}; + +// TODO(andrey): Change this +export type TImportCertificateResponse = { + certificate: string; + issuingCertificate: string; + certificateChain: string; + privateKey: string; + serialNumber: string; +}; diff --git a/frontend/src/pages/cert-manager/CertificatesPage/components/CertificateImportModal.tsx b/frontend/src/pages/cert-manager/CertificatesPage/components/CertificateImportModal.tsx new file mode 100644 index 000000000..6f988c17f --- /dev/null +++ b/frontend/src/pages/cert-manager/CertificatesPage/components/CertificateImportModal.tsx @@ -0,0 +1,223 @@ +import { useState } from "react"; +import { Controller, useForm } from "react-hook-form"; +import { zodResolver } from "@hookform/resolvers/zod"; +import { z } from "zod"; + +import { createNotification } from "@app/components/notifications"; +import { + Button, + FormControl, + Input, + Modal, + ModalContent, + Select, + SelectItem +} from "@app/components/v2"; +import { useWorkspace } from "@app/context"; +import { useImportCertificate, useGetCert, useListWorkspacePkiCollections } from "@app/hooks/api"; +import { UsePopUpState } from "@app/hooks/usePopUp"; + +import { CertificateContent } from "./CertificateContent"; + +const schema = z.object({ + certificatePem: z.string().trim().min(1, "Certificate PEM is required"), + privateKeyPem: z.string().trim().optional(), + chainPem: z.string().trim().optional(), + + friendlyName: z.string(), + collectionId: z.string().optional() + + // Can be added as override fields in the future | Also edit /frontend/src/hooks/api/ca/types.ts + // commonName: z.string().trim().min(1), + // altNames: z.string(), + + // Can be added as override fields in the future | Also edit /frontend/src/hooks/api/ca/types.ts + // keyUsages: z.object({ + // [CertKeyUsage.DIGITAL_SIGNATURE]: z.boolean().optional(), + // [CertKeyUsage.KEY_ENCIPHERMENT]: z.boolean().optional(), + // [CertKeyUsage.NON_REPUDIATION]: z.boolean().optional(), + // [CertKeyUsage.DATA_ENCIPHERMENT]: z.boolean().optional(), + // [CertKeyUsage.KEY_AGREEMENT]: z.boolean().optional(), + // [CertKeyUsage.KEY_CERT_SIGN]: z.boolean().optional(), + // [CertKeyUsage.CRL_SIGN]: z.boolean().optional(), + // [CertKeyUsage.ENCIPHER_ONLY]: z.boolean().optional(), + // [CertKeyUsage.DECIPHER_ONLY]: z.boolean().optional() + // }), + // extendedKeyUsages: z.object({ + // [CertExtendedKeyUsage.CLIENT_AUTH]: z.boolean().optional(), + // [CertExtendedKeyUsage.CODE_SIGNING]: z.boolean().optional(), + // [CertExtendedKeyUsage.EMAIL_PROTECTION]: z.boolean().optional(), + // [CertExtendedKeyUsage.OCSP_SIGNING]: z.boolean().optional(), + // [CertExtendedKeyUsage.SERVER_AUTH]: z.boolean().optional(), + // [CertExtendedKeyUsage.TIMESTAMPING]: z.boolean().optional() + // }) +}); + +export type FormData = z.infer; + +type Props = { + popUp: UsePopUpState<["certificateImport"]>; + handlePopUpToggle: ( + popUpName: keyof UsePopUpState<["certificateImport"]>, + state?: boolean + ) => void; +}; + +type TCertificateDetails = { + serialNumber: string; + certificate: string; + certificateChain: string; + privateKey: string; +}; + +export const CertificateImportModal = ({ popUp, handlePopUpToggle }: Props) => { + const [certificateDetails, setCertificateDetails] = useState(null); + const { currentWorkspace } = useWorkspace(); + const { data: cert } = useGetCert( + (popUp?.certificateImport?.data as { serialNumber: string })?.serialNumber || "" + ); + + const { data } = useListWorkspacePkiCollections({ + workspaceId: currentWorkspace?.id || "" + }); + + const { mutateAsync: importCertificate } = useImportCertificate(); + + const { + control, + handleSubmit, + reset, + formState: { isSubmitting } + } = useForm({ + resolver: zodResolver(schema) + }); + + const onFormSubmit = async ({ + certificatePem, + privateKeyPem, + chainPem, + friendlyName, + collectionId + }: FormData) => { + try { + if (!currentWorkspace?.slug) return; + + const { serialNumber, certificate, certificateChain, privateKey } = await importCertificate({ + projectSlug: currentWorkspace.slug, + projectSlug: currentWorkspace.slug, + + certificatePem, + privateKeyPem, + chainPem, + + friendlyName, + pkiCollectionId: collectionId + }); + + reset(); + + setCertificateDetails({ + serialNumber, + certificate, + certificateChain, + privateKey + }); + + createNotification({ + text: "Successfully imported certificate", + type: "success" + }); + } catch (err) { + console.error(err); + createNotification({ + text: "Failed to import certificate", + type: "error" + }); + } + }; + + return ( + { + handlePopUpToggle("certificateImport", isOpen); + reset(); + setCertificateDetails(null); + }} + > + + {!certificateDetails ? ( +
+ ( + + + + )} + /> + ( + + + + )} + /> + {!cert && ( +
+ + +
+ )} + + ) : ( + + )} +
+
+ ); +}; diff --git a/frontend/src/pages/cert-manager/CertificatesPage/components/CertificatesSection.tsx b/frontend/src/pages/cert-manager/CertificatesPage/components/CertificatesSection.tsx index d5f94e7b7..7a4533d3a 100644 --- a/frontend/src/pages/cert-manager/CertificatesPage/components/CertificatesSection.tsx +++ b/frontend/src/pages/cert-manager/CertificatesPage/components/CertificatesSection.tsx @@ -1,4 +1,4 @@ -import { faPlus } from "@fortawesome/free-solid-svg-icons"; +import { faArrowRight, faPlus } from "@fortawesome/free-solid-svg-icons"; import { FontAwesomeIcon } from "@fortawesome/react-fontawesome"; import { createNotification } from "@app/components/notifications"; @@ -12,6 +12,7 @@ import { CertificateCertModal } from "./CertificateCertModal"; import { CertificateModal } from "./CertificateModal"; import { CertificateRevocationModal } from "./CertificateRevocationModal"; import { CertificatesTable } from "./CertificatesTable"; +import { CertificateImportModal } from "./CertificateImportModal"; export const CertificatesSection = () => { const { currentWorkspace } = useWorkspace(); @@ -19,6 +20,7 @@ export const CertificatesSection = () => { const { popUp, handlePopUpOpen, handlePopUpClose, handlePopUpToggle } = usePopUp([ "certificate", + "certificateImport", "certificateCert", "deleteCertificate", "revokeCertificate" @@ -54,20 +56,31 @@ export const CertificatesSection = () => { a={ProjectPermissionSub.Certificates} > {(isAllowed) => ( - +
+ + +
)} + Date: Tue, 29 Apr 2025 19:26:07 -0400 Subject: [PATCH 02/99] checkpoint frontend --- .../src/hooks/api/certificates/mutations.tsx | 1 + .../components/CertificateImportModal.tsx | 64 ++++++++++++++++++- 2 files changed, 62 insertions(+), 3 deletions(-) diff --git a/frontend/src/hooks/api/certificates/mutations.tsx b/frontend/src/hooks/api/certificates/mutations.tsx index f231b7963..88cdc3c38 100644 --- a/frontend/src/hooks/api/certificates/mutations.tsx +++ b/frontend/src/hooks/api/certificates/mutations.tsx @@ -56,6 +56,7 @@ export const useImportCertificate = () => { const queryClient = useQueryClient(); return useMutation({ mutationFn: async (body) => { + console.log("DBG-1"); const { data } = await apiRequest.post( "/api/v1/pki/certificates/import-certificate", body diff --git a/frontend/src/pages/cert-manager/CertificatesPage/components/CertificateImportModal.tsx b/frontend/src/pages/cert-manager/CertificatesPage/components/CertificateImportModal.tsx index 6f988c17f..0c921f8fc 100644 --- a/frontend/src/pages/cert-manager/CertificatesPage/components/CertificateImportModal.tsx +++ b/frontend/src/pages/cert-manager/CertificatesPage/components/CertificateImportModal.tsx @@ -11,7 +11,8 @@ import { Modal, ModalContent, Select, - SelectItem + SelectItem, + TextArea } from "@app/components/v2"; import { useWorkspace } from "@app/context"; import { useImportCertificate, useGetCert, useListWorkspacePkiCollections } from "@app/hooks/api"; @@ -104,7 +105,6 @@ export const CertificateImportModal = ({ popUp, handlePopUpToggle }: Props) => { const { serialNumber, certificate, certificateChain, privateKey } = await importCertificate({ projectSlug: currentWorkspace.slug, - projectSlug: currentWorkspace.slug, certificatePem, privateKeyPem, @@ -153,9 +153,10 @@ export const CertificateImportModal = ({ popUp, handlePopUpToggle }: Props) => { name="collectionId" render={({ field: { onChange, ...field }, fieldState: { error } }) => ( @@ -409,7 +410,7 @@ export const SecretApprovalRequestChanges = ({ ); })} -
+
-
+
Reviewers
{secretApprovalRequestDetails?.policy?.approvers @@ -435,10 +436,10 @@ export const SecretApprovalRequestChanges = ({ const reviewer = reviewedUsers?.[requiredApprover.userId]; return (
-
+
)} - + {getReviewedStatusSymbol(reviewer?.status)}
From e5f475e8d618c4e6448d69e76eb6d542042ef953 Mon Sep 17 00:00:00 2001 From: carlosmonastyrski Date: Mon, 26 May 2025 09:16:10 -0300 Subject: [PATCH 96/99] Fix type and lint issues --- .../SecretApprovalRequestAction.tsx | 39 +-- .../SecretApprovalRequestChangeItem.tsx | 259 +++++++++++------- .../SecretApprovalRequestChanges.tsx | 25 +- 3 files changed, 200 insertions(+), 123 deletions(-) diff --git a/frontend/src/pages/secret-manager/SecretApprovalsPage/components/SecretApprovalRequest/components/SecretApprovalRequestAction.tsx b/frontend/src/pages/secret-manager/SecretApprovalsPage/components/SecretApprovalRequest/components/SecretApprovalRequestAction.tsx index 3f5c14931..abb38b372 100644 --- a/frontend/src/pages/secret-manager/SecretApprovalsPage/components/SecretApprovalRequest/components/SecretApprovalRequestAction.tsx +++ b/frontend/src/pages/secret-manager/SecretApprovalsPage/components/SecretApprovalRequest/components/SecretApprovalRequestAction.tsx @@ -1,13 +1,9 @@ import { useState } from "react"; import { faCheck, - faCircleCheck, - faCircleXmark, faClose, faLandMineOn, faLockOpen, - faSquareCheck, - faSquareXmark, faTriangleExclamation, faUserLock, faXmark @@ -115,25 +111,34 @@ export const SecretApprovalRequestAction = ({ if (!hasMerged && status === "open") { return ( -
+
-
+
-

{isMergable ? "Good to merge" : "Merging is blocked"}

- {!isMergable && - At least {approvals} approving review{`${approvals > 1 ? "s" : ""}`} required by eligible reviewers. - {Boolean(statusChangeByEmail) && `. Reopened by ${statusChangeByEmail}`} - } +

+ {isMergable ? "Good to merge" : "Merging is blocked"} +

+ {!isMergable && ( + + At least {approvals} approving review{`${approvals > 1 ? "s" : ""}`} required by + eligible reviewers. + {Boolean(statusChangeByEmail) && `. Reopened by ${statusChangeByEmail}`} + + )}
-
+
{isSoftEnforcement && !isMergable && canBypassApprovalPermission && ( -
+
setByPassApproval(checked === true)} isChecked={byPassApproval} @@ -166,7 +171,7 @@ export const SecretApprovalRequestAction = ({
)}
-
+
{canApprove || isSoftEnforcement ? (
@@ -205,7 +210,7 @@ export const SecretApprovalRequestAction = ({ if (hasMerged && status === "close") return ( -
+
diff --git a/frontend/src/pages/secret-manager/SecretApprovalsPage/components/SecretApprovalRequest/components/SecretApprovalRequestChangeItem.tsx b/frontend/src/pages/secret-manager/SecretApprovalsPage/components/SecretApprovalRequest/components/SecretApprovalRequestChangeItem.tsx index 35f6474d5..b7a6a8149 100644 --- a/frontend/src/pages/secret-manager/SecretApprovalsPage/components/SecretApprovalRequest/components/SecretApprovalRequestChangeItem.tsx +++ b/frontend/src/pages/secret-manager/SecretApprovalsPage/components/SecretApprovalRequest/components/SecretApprovalRequestChangeItem.tsx @@ -1,12 +1,19 @@ -import { faCircleXmark, faExclamationTriangle, faEye, faEyeSlash, faInfo, faKey } from "@fortawesome/free-solid-svg-icons"; +/* eslint-disable jsx-a11y/no-static-element-interactions */ +/* eslint-disable jsx-a11y/click-events-have-key-events */ +/* eslint-disable no-nested-ternary */ +import { useState } from "react"; +import { + faCircleXmark, + faExclamationTriangle, + faEye, + faEyeSlash, + faInfo, + faKey +} from "@fortawesome/free-solid-svg-icons"; import { FontAwesomeIcon } from "@fortawesome/react-fontawesome"; -import { - Tag, - Tooltip -} from "@app/components/v2"; +import { Tag, Tooltip } from "@app/components/v2"; import { CommitType, SecretV3Raw, TSecretApprovalSecChange, WsTag } from "@app/hooks/api/types"; -import { useState } from "react"; export type Props = { op: CommitType; @@ -27,7 +34,7 @@ const generateItemTitle = (op: CommitType) => { else text = { label: "deletion", color: "#F83030" }; return ( -
+
Request for secret {text.label}
); @@ -56,7 +63,7 @@ export const SecretApprovalRequestChangeItem = ({ const [isNewSecretValueVisible, setIsNewSecretValueVisible] = useState(false); return ( -
+
{generateItemTitle(op)}
{!hasMerged && isStale && ( @@ -75,57 +82,84 @@ export const SecretApprovalRequestChangeItem = ({ )}
-
+
{op === CommitType.UPDATE || op === CommitType.DELETE ? ( -
-
+
+
Legacy Secret -
- +
+ Deprecated
-
-
Key
+
+
Key
{secretVersion?.secretKey}
-
-
Value
-
{newVersion?.isRotatedSecret ? ( - - Rotated Secret value will not be affected - - ) : ( -
setIsOldSecretValueVisible(!isOldSecretValueVisible)} className="pl-2 border border-mineshaft-500 bg-mineshaft-900 rounded-md flex flex-row justify-between items-center"> -
{isOldSecretValueVisible ? (secretVersion?.secretValue || "EMPTY") : (secretVersion?.secretValue ? secretVersion?.secretValue?.split('').map((_, index) => "•") : "EMPTY")}
- {secretVersion?.secretValue &&
} -
- )} -
-
-
-
Comment
-
{secretVersion?.secretComment || -}
-
-
-
Tags
-
- {secretVersion?.tags?.length ?? 0 ? secretVersion?.tags?.map(({ slug, id: tagId, color }) => ( - +
Value
+
+ {newVersion?.isRotatedSecret ? ( + + Rotated Secret value will not be affected + + ) : ( +
setIsOldSecretValueVisible(!isOldSecretValueVisible)} + className="flex flex-row items-center justify-between rounded-md border border-mineshaft-500 bg-mineshaft-900 pl-2" >
-
{slug}
- - )) : -} + className={`flex font-mono ${isOldSecretValueVisible || !secretVersion?.secretValue ? "text-md py-[0.55rem]" : "text-lg"}`} + > + {isOldSecretValueVisible + ? secretVersion?.secretValue || "EMPTY" + : secretVersion?.secretValue + ? secretVersion?.secretValue?.split("").map(() => "•") + : "EMPTY"}{" "} +
+ {secretVersion?.secretValue && ( +
+ +
+ )} +
+ )}
-
-
Metadata
+
+
Comment
+
+ {secretVersion?.secretComment || ( + - + )}{" "} +
+
+
+
Tags
+
+ {(secretVersion?.tags?.length ?? 0) ? ( + secretVersion?.tags?.map(({ slug, id: tagId, color }) => ( + +
+
{slug}
+ + )) + ) : ( + - + )} +
+
+
+
Metadata
{secretVersion?.secretMetadata?.length ? (
@@ -153,59 +187,91 @@ export const SecretApprovalRequestChangeItem = ({

-

)}
-
-
) - :
Secret not existent in the previous version.
} - {op === CommitType.UPDATE || op === CommitType.CREATE ? ( -
-
+
+
+ ) : ( +
+ {" "} + Secret not existent in the previous version. +
+ )} + {op === CommitType.UPDATE || op === CommitType.CREATE ? ( +
+
New Secret -
- +
+ Current
-
-
Key
+
+
Key
{newVersion?.secretKey}
-
-
Value
-
{newVersion?.isRotatedSecret ? ( - - Rotated Secret value will not be affected - - ) : ( -
setIsNewSecretValueVisible(!isNewSecretValueVisible)} className="pl-2 border border-mineshaft-500 bg-mineshaft-900 rounded-md flex flex-row justify-between items-center"> -
{isNewSecretValueVisible ? (newVersion?.secretValue || "EMPTY") : (newVersion?.secretValue ? newVersion?.secretValue?.split('').map((_, index) => "•") : "EMPTY")}
- {newVersion?.secretValue &&
} -
- )} -
-
-
-
Comment
-
{newVersion?.secretComment || -}
-
-
-
Tags
-
- {newVersion?.tags?.length ?? 0 ? newVersion?.tags?.map(({ slug, id: tagId, color }) => ( - +
Value
+
+ {newVersion?.isRotatedSecret ? ( + + Rotated Secret value will not be affected + + ) : ( +
setIsNewSecretValueVisible(!isNewSecretValueVisible)} + className="flex flex-row items-center justify-between rounded-md border border-mineshaft-500 bg-mineshaft-900 pl-2" >
-
{slug}
- - )) : -} + className={`flex font-mono ${isNewSecretValueVisible || !newVersion?.secretValue ? "text-md py-[0.55rem]" : "text-lg"}`} + > + {isNewSecretValueVisible + ? newVersion?.secretValue || "EMPTY" + : newVersion?.secretValue + ? newVersion?.secretValue?.split("").map(() => "•") + : "EMPTY"}{" "} +
+ {newVersion?.secretValue && ( +
+ +
+ )} +
+ )}
-
-
Metadata
+
+
Comment
+
+ {newVersion?.secretComment || ( + - + )}{" "} +
+
+
+
Tags
+
+ {(newVersion?.tags?.length ?? 0) ? ( + newVersion?.tags?.map(({ slug, id: tagId, color }) => ( + +
+
{slug}
+ + )) + ) : ( + - + )} +
+
+
+
Metadata
{newVersion?.secretMetadata?.length ? (
{newVersion.secretMetadata?.map((el) => ( @@ -232,9 +298,14 @@ export const SecretApprovalRequestChangeItem = ({

-

)}
-
) - :
Secret not existent in the new version.
} -
+
+ ) : ( +
+ {" "} + Secret not existent in the new version. +
+ )} +
); diff --git a/frontend/src/pages/secret-manager/SecretApprovalsPage/components/SecretApprovalRequest/components/SecretApprovalRequestChanges.tsx b/frontend/src/pages/secret-manager/SecretApprovalsPage/components/SecretApprovalRequest/components/SecretApprovalRequestChanges.tsx index 9e611d3dc..17046450e 100644 --- a/frontend/src/pages/secret-manager/SecretApprovalsPage/components/SecretApprovalRequest/components/SecretApprovalRequestChanges.tsx +++ b/frontend/src/pages/secret-manager/SecretApprovalsPage/components/SecretApprovalRequest/components/SecretApprovalRequestChanges.tsx @@ -13,6 +13,7 @@ import { import { FontAwesomeIcon } from "@fortawesome/react-fontawesome"; import { zodResolver } from "@hookform/resolvers/zod"; import { RadioGroup, RadioGroupIndicator, RadioGroupItem } from "@radix-ui/react-radio-group"; +import { format } from "date-fns"; import { twMerge } from "tailwind-merge"; import z from "zod"; @@ -40,7 +41,6 @@ import { formatReservedPaths } from "@app/lib/fn/string"; import { SecretApprovalRequestAction } from "./SecretApprovalRequestAction"; import { SecretApprovalRequestChangeItem } from "./SecretApprovalRequestChangeItem"; -import { format } from "date-fns"; export const generateCommitText = (commits: { op: CommitType }[] = []) => { const score: Record = {}; @@ -235,14 +235,17 @@ export const SecretApprovalRequestChanges = ({ {secretApprovalRequestDetails?.committerUser?.email}) wants to change{" "} {secretApprovalRequestDetails.commits.length} secret values in

-

+

{secretApprovalRequestDetails.environment}

-

+

-

+

{formatReservedPaths(secretApprovalRequestDetails.secretPath)}

@@ -256,10 +259,7 @@ export const SecretApprovalRequestChanges = ({ onOpenChange={(isOpen) => handlePopUpToggle("reviewChanges", isOpen)} > - @@ -397,7 +397,8 @@ export const SecretApprovalRequestChanges = ({ > {reviewer?.status === ApprovalStatus.APPROVED ? "approved" : "rejected"} {" "} - the request on {format(new Date(secretApprovalRequestDetails.createdAt), "PPpp zzz")}. + the request on{" "} + {format(new Date(secretApprovalRequestDetails.createdAt), "PPpp zzz")}.
{reviewer?.comment && ( @@ -410,7 +411,7 @@ export const SecretApprovalRequestChanges = ({ ); })}
-
+
-
+
Reviewers
{secretApprovalRequestDetails?.policy?.approvers @@ -436,7 +437,7 @@ export const SecretApprovalRequestChanges = ({ const reviewer = reviewedUsers?.[requiredApprover.userId]; return (
From 995e3254ba170c54df16c442d1f9a0904ee9f3ab Mon Sep 17 00:00:00 2001 From: x032205 Date: Mon, 26 May 2025 10:41:21 -0400 Subject: [PATCH 97/99] comment fix --- backend/src/services/app-connection/app-connection-fns.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/backend/src/services/app-connection/app-connection-fns.ts b/backend/src/services/app-connection/app-connection-fns.ts index 614c9accc..86e728008 100644 --- a/backend/src/services/app-connection/app-connection-fns.ts +++ b/backend/src/services/app-connection/app-connection-fns.ts @@ -19,7 +19,7 @@ import { OnePassConnectionMethod, validateOnePassConnectionCredentials } from "./1password"; -// import { AppConnection, AppConnectionPlanType } from "./app-connection-enums"; +import { AppConnection, AppConnectionPlanType } from "./app-connection-enums"; import { TAppConnectionServiceFactoryDep } from "./app-connection-service"; import { TAppConnection, From 1147f87eedbd119dc9b4a0a1da709c4268f64751 Mon Sep 17 00:00:00 2001 From: x032205 Date: Mon, 26 May 2025 10:56:53 -0400 Subject: [PATCH 98/99] lint fixes --- backend/src/services/app-connection/app-connection-maps.ts | 3 ++- backend/src/services/secret-sync/secret-sync-maps.ts | 3 ++- 2 files changed, 4 insertions(+), 2 deletions(-) diff --git a/backend/src/services/app-connection/app-connection-maps.ts b/backend/src/services/app-connection/app-connection-maps.ts index 5615623aa..ddd0b1087 100644 --- a/backend/src/services/app-connection/app-connection-maps.ts +++ b/backend/src/services/app-connection/app-connection-maps.ts @@ -42,5 +42,6 @@ export const APP_CONNECTION_PLAN_MAP: Record = { [SecretSync.Windmill]: SecretSyncPlanType.Regular, [SecretSync.HCVault]: SecretSyncPlanType.Regular, [SecretSync.TeamCity]: SecretSyncPlanType.Regular, - [SecretSync.OCIVault]: SecretSyncPlanType.Enterprise + [SecretSync.OCIVault]: SecretSyncPlanType.Enterprise, + [SecretSync.OnePass]: SecretSyncPlanType.Regular }; From f49e3788cc820d7de17008bf88f531acfe4cc9bf Mon Sep 17 00:00:00 2001 From: = Date: Mon, 26 May 2025 20:59:57 +0530 Subject: [PATCH 99/99] feat: added missing memberused, identityused in getplan --- backend/src/ee/services/license/license-service.ts | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/backend/src/ee/services/license/license-service.ts b/backend/src/ee/services/license/license-service.ts index e9a2ada8b..ceeebbf7a 100644 --- a/backend/src/ee/services/license/license-service.ts +++ b/backend/src/ee/services/license/license-service.ts @@ -192,6 +192,11 @@ export const licenseServiceFactory = ({ const workspacesUsed = await projectDAL.countOfOrgProjects(orgId); currentPlan.workspacesUsed = workspacesUsed; + const membersUsed = await licenseDAL.countOfOrgMembers(orgId); + currentPlan.membersUsed = membersUsed; + const identityUsed = await licenseDAL.countOrgUsersAndIdentities(orgId); + currentPlan.identitiesUsed = identityUsed; + await keyStore.setItemWithExpiry( FEATURE_CACHE_KEY(org.id), LICENSE_SERVER_CLOUD_PLAN_TTL,