diff --git a/backend/src/db/migrations/20251021112356_add-certificate-auto-renewal.ts b/backend/src/db/migrations/20251021112356_add-certificate-auto-renewal.ts index 2226194e5..583ffec90 100644 --- a/backend/src/db/migrations/20251021112356_add-certificate-auto-renewal.ts +++ b/backend/src/db/migrations/20251021112356_add-certificate-auto-renewal.ts @@ -12,15 +12,15 @@ export async function up(knex: Knex): Promise { if (!(await knex.schema.hasColumn(TableName.Certificate, "renewBeforeDays"))) { await knex.schema.alterTable(TableName.Certificate, (t) => { t.integer("renewBeforeDays").nullable(); - t.uuid("renewedFromId").nullable(); - t.uuid("renewedById").nullable(); + t.uuid("renewedFromCertificateId").nullable(); + t.uuid("renewedByCertificateId").nullable(); t.text("renewalError").nullable(); t.string("keyAlgorithm").nullable(); t.string("signatureAlgorithm").nullable(); - t.foreign("renewedFromId").references("id").inTable(TableName.Certificate).onDelete("SET NULL"); - t.foreign("renewedById").references("id").inTable(TableName.Certificate).onDelete("SET NULL"); - t.index("renewedFromId"); - t.index("renewedById"); + t.foreign("renewedFromCertificateId").references("id").inTable(TableName.Certificate).onDelete("SET NULL"); + t.foreign("renewedByCertificateId").references("id").inTable(TableName.Certificate).onDelete("SET NULL"); + t.index("renewedFromCertificateId"); + t.index("renewedByCertificateId"); t.index("renewBeforeDays"); }); } @@ -29,14 +29,14 @@ export async function up(knex: Knex): Promise { export async function down(knex: Knex): Promise { if (await knex.schema.hasColumn(TableName.Certificate, "renewBeforeDays")) { await knex.schema.alterTable(TableName.Certificate, (t) => { - t.dropForeign(["renewedFromId"]); - t.dropForeign(["renewedById"]); - t.dropIndex("renewedFromId"); - t.dropIndex("renewedById"); + t.dropForeign(["renewedFromCertificateId"]); + t.dropForeign(["renewedByCertificateId"]); + t.dropIndex("renewedFromCertificateId"); + t.dropIndex("renewedByCertificateId"); t.dropIndex("renewBeforeDays"); t.dropColumn("renewBeforeDays"); - t.dropColumn("renewedFromId"); - t.dropColumn("renewedById"); + t.dropColumn("renewedFromCertificateId"); + t.dropColumn("renewedByCertificateId"); t.dropColumn("renewalError"); t.dropColumn("keyAlgorithm"); t.dropColumn("signatureAlgorithm"); diff --git a/backend/src/db/schemas/certificates.ts b/backend/src/db/schemas/certificates.ts index 8c3a5b51f..8a3ae8f84 100644 --- a/backend/src/db/schemas/certificates.ts +++ b/backend/src/db/schemas/certificates.ts @@ -29,8 +29,8 @@ export const CertificatesSchema = z.object({ pkiSubscriberId: z.string().uuid().nullable().optional(), profileId: z.string().uuid().nullable().optional(), renewBeforeDays: z.number().nullable().optional(), - renewedFromId: z.string().uuid().nullable().optional(), - renewedById: z.string().uuid().nullable().optional(), + renewedFromCertificateId: z.string().uuid().nullable().optional(), + renewedByCertificateId: z.string().uuid().nullable().optional(), renewalError: z.string().nullable().optional(), keyAlgorithm: z.string().nullable().optional(), signatureAlgorithm: z.string().nullable().optional() diff --git a/backend/src/ee/services/audit-log/audit-log-types.ts b/backend/src/ee/services/audit-log/audit-log-types.ts index cf0f573f8..b58503110 100644 --- a/backend/src/ee/services/audit-log/audit-log-types.ts +++ b/backend/src/ee/services/audit-log/audit-log-types.ts @@ -2470,6 +2470,7 @@ interface AutomatedRenewCertificate { commonName: string; profileId: string; renewBeforeDays: string; + profileName: string; }; } @@ -2480,6 +2481,7 @@ interface AutomatedRenewCertificateFailed { commonName: string; profileId: string; renewBeforeDays: string; + profileName: string; error: string; }; } @@ -2752,6 +2754,7 @@ interface RenewCertificate { originalCertificateId: string; newCertificateId: string; profileName: string; + commonName: string; }; } @@ -4049,6 +4052,7 @@ interface UpdateCertificateRenewalConfigEvent { metadata: { certificateId: string; renewBeforeDays: string; + commonName: string; }; } @@ -4056,6 +4060,7 @@ interface DisableCertificateRenewalConfigEvent { type: EventType.DISABLE_CERTIFICATE_RENEWAL_CONFIG; metadata: { certificateId: string; + commonName: string; }; } diff --git a/backend/src/server/routes/v3/certificates-router.ts b/backend/src/server/routes/v3/certificates-router.ts index 126a3b146..52ad47772 100644 --- a/backend/src/server/routes/v3/certificates-router.ts +++ b/backend/src/server/routes/v3/certificates-router.ts @@ -84,8 +84,8 @@ export const registerCertificatesRouter = async (server: FastifyZodProvider) => }) ) .optional(), - signatureAlgorithm: z.nativeEnum(CertSignatureAlgorithm).optional(), - keyAlgorithm: z.nativeEnum(CertKeyAlgorithm).optional() + signatureAlgorithm: z.nativeEnum(CertSignatureAlgorithm), + keyAlgorithm: z.nativeEnum(CertKeyAlgorithm) }) .refine(validateTtlAndDateFields, { message: @@ -170,8 +170,8 @@ export const registerCertificatesRouter = async (server: FastifyZodProvider) => .refine((val) => ms(val) > 0, "TTL must be a positive number"), notBefore: validateCaDateField.optional(), notAfter: validateCaDateField.optional(), - signatureAlgorithm: z.nativeEnum(CertSignatureAlgorithm).optional(), - keyAlgorithm: z.nativeEnum(CertKeyAlgorithm).optional() + signatureAlgorithm: z.nativeEnum(CertSignatureAlgorithm), + keyAlgorithm: z.nativeEnum(CertKeyAlgorithm) }) .refine(validateTtlAndDateFields, { message: @@ -260,8 +260,8 @@ export const registerCertificatesRouter = async (server: FastifyZodProvider) => notBefore: validateCaDateField.optional(), notAfter: validateCaDateField.optional(), commonName: validateTemplateRegexField.optional(), - signatureAlgorithm: z.nativeEnum(CertSignatureAlgorithm).optional(), - keyAlgorithm: z.nativeEnum(CertKeyAlgorithm).optional() + signatureAlgorithm: z.nativeEnum(CertSignatureAlgorithm), + keyAlgorithm: z.nativeEnum(CertKeyAlgorithm) }) .refine(validateTtlAndDateFields, { message: @@ -385,7 +385,8 @@ export const registerCertificatesRouter = async (server: FastifyZodProvider) => metadata: { originalCertificateId: req.params.certificateId, newCertificateId: data.certificateId, - profileName: data.profileName + profileName: data.profileName, + commonName: data.commonName } } }); @@ -409,10 +410,10 @@ export const registerCertificatesRouter = async (server: FastifyZodProvider) => body: z .object({ renewBeforeDays: z.number().int().min(1).max(30).optional(), - disableAutoRenewal: z.boolean().optional() + enableAutoRenewal: z.boolean().optional() }) - .refine((data) => !(data.renewBeforeDays !== undefined && data.disableAutoRenewal === true), { - message: "Cannot specify both renewBeforeDays and disableAutoRenewal" + .refine((data) => !(data.renewBeforeDays !== undefined && data.enableAutoRenewal === false), { + message: "Cannot specify both renewBeforeDays and enableAutoRenewal=false" }), response: { 200: z.object({ @@ -423,7 +424,7 @@ export const registerCertificatesRouter = async (server: FastifyZodProvider) => }, onRequest: verifyAuth([AuthMode.JWT, AuthMode.IDENTITY_ACCESS_TOKEN]), handler: async (req) => { - if (req.body.disableAutoRenewal === true) { + if (req.body.enableAutoRenewal === false) { const data = await server.services.certificateV3.disableRenewalConfig({ actor: req.permission.type, actorId: req.permission.id, @@ -438,7 +439,8 @@ export const registerCertificatesRouter = async (server: FastifyZodProvider) => event: { type: EventType.DISABLE_CERTIFICATE_RENEWAL_CONFIG, metadata: { - certificateId: req.params.certificateId + certificateId: req.params.certificateId, + commonName: data.commonName } } }); @@ -465,7 +467,8 @@ export const registerCertificatesRouter = async (server: FastifyZodProvider) => type: EventType.UPDATE_CERTIFICATE_RENEWAL_CONFIG, metadata: { certificateId: req.params.certificateId, - renewBeforeDays: req.body.renewBeforeDays.toString() + renewBeforeDays: req.body.renewBeforeDays.toString(), + commonName: data.commonName } } }); diff --git a/backend/src/services/certificate-authority/internal/internal-certificate-authority-service.ts b/backend/src/services/certificate-authority/internal/internal-certificate-authority-service.ts index 0b12ff3dc..94c74939a 100644 --- a/backend/src/services/certificate-authority/internal/internal-certificate-authority-service.ts +++ b/backend/src/services/certificate-authority/internal/internal-certificate-authority-service.ts @@ -2,12 +2,14 @@ import { ForbiddenError, subject } from "@casl/ability"; import * as x509 from "@peculiar/x509"; import slugify from "@sindresorhus/slugify"; +import { Knex } from "knex"; import { ActionProjectType, TableName, TCertificateAuthorities, TCertificateTemplates } from "@app/db/schemas"; import { TPermissionServiceFactory } from "@app/ee/services/permission/permission-service-types"; import { ProjectPermissionActions, ProjectPermissionCertificateActions, + ProjectPermissionCertificateProfileActions, ProjectPermissionPkiTemplateActions, ProjectPermissionSub } from "@app/ee/services/permission/project-permission"; @@ -1181,7 +1183,8 @@ export const internalCertificateAuthorityServiceFactory = ({ signatureAlgorithm, keyAlgorithm, isFromProfile, - internal = false + internal = false, + tx }: TIssueCertFromCaDTO) => { let ca: TCertificateAuthorityWithAssociatedCa | undefined; let certificateTemplate: TCertificateTemplates | undefined; @@ -1221,10 +1224,17 @@ export const internalCertificateAuthorityServiceFactory = ({ actionProjectType: ActionProjectType.CertificateManager }); - ForbiddenError.from(permission).throwUnlessCan( - ProjectPermissionCertificateActions.Create, - ProjectPermissionSub.Certificates - ); + if (isFromProfile) { + ForbiddenError.from(permission).throwUnlessCan( + ProjectPermissionCertificateProfileActions.IssueCert, + ProjectPermissionSub.CertificateProfiles + ); + } else { + ForbiddenError.from(permission).throwUnlessCan( + ProjectPermissionCertificateActions.Create, + ProjectPermissionSub.Certificates + ); + } } if (ca.status !== CaStatus.ACTIVE) throw new BadRequestError({ message: "CA is not active" }); @@ -1476,7 +1486,7 @@ export const internalCertificateAuthorityServiceFactory = ({ plainText: Buffer.from(certificateChainPem) }); - await certificateDAL.transaction(async (tx) => { + const executeIssueCertOperations = async (transaction: Knex) => { const cert = await certificateDAL.create( { caId: (ca as TCertificateAuthorities).id, @@ -1495,7 +1505,7 @@ export const internalCertificateAuthorityServiceFactory = ({ keyAlgorithm: effectiveKeyAlgorithm, signatureAlgorithm: signatureAlgorithm || ca!.internalCa!.keyAlgorithm }, - tx + transaction ); await certificateBodyDAL.create( @@ -1504,7 +1514,7 @@ export const internalCertificateAuthorityServiceFactory = ({ encryptedCertificate, encryptedCertificateChain }, - tx + transaction ); await certificateSecretDAL.create( @@ -1512,7 +1522,7 @@ export const internalCertificateAuthorityServiceFactory = ({ certId: cert.id, encryptedPrivateKey }, - tx + transaction ); if (collectionId) { @@ -1521,12 +1531,18 @@ export const internalCertificateAuthorityServiceFactory = ({ pkiCollectionId: collectionId, certId: cert.id }, - tx + transaction ); } return cert; - }); + }; + + if (tx) { + await executeIssueCertOperations(tx); + } else { + await certificateDAL.transaction(executeIssueCertOperations); + } return { certificate: leafCert.toString("pem"), @@ -1598,10 +1614,17 @@ export const internalCertificateAuthorityServiceFactory = ({ actionProjectType: ActionProjectType.CertificateManager }); - ForbiddenError.from(permission).throwUnlessCan( - ProjectPermissionCertificateActions.Create, - ProjectPermissionSub.Certificates - ); + if (dto.isFromProfile && dto.profileId) { + ForbiddenError.from(permission).throwUnlessCan( + ProjectPermissionCertificateProfileActions.IssueCert, + ProjectPermissionSub.CertificateProfiles + ); + } else { + ForbiddenError.from(permission).throwUnlessCan( + ProjectPermissionCertificateActions.Create, + ProjectPermissionSub.Certificates + ); + } } if (ca.status !== CaStatus.ACTIVE) throw new BadRequestError({ message: "CA is not active" }); diff --git a/backend/src/services/certificate-authority/internal/internal-certificate-authority-types.ts b/backend/src/services/certificate-authority/internal/internal-certificate-authority-types.ts index ca0d99be7..b4b037933 100644 --- a/backend/src/services/certificate-authority/internal/internal-certificate-authority-types.ts +++ b/backend/src/services/certificate-authority/internal/internal-certificate-authority-types.ts @@ -1,3 +1,4 @@ +import { Knex } from "knex"; import { z } from "zod"; import { TCertificateAuthorityCrlDALFactory } from "@app/ee/services/certificate-authority-crl/certificate-authority-crl-dal"; @@ -139,7 +140,9 @@ export type TIssueCertFromCaDTO = { signatureAlgorithm?: CertSignatureAlgorithm; keyAlgorithm?: CertKeyAlgorithm; isFromProfile?: boolean; + profileId?: string; internal?: boolean; + tx?: Knex; } & Omit; export type TSignCertFromCaDTO = @@ -160,6 +163,7 @@ export type TSignCertFromCaDTO = signatureAlgorithm?: string; keyAlgorithm?: string; isFromProfile?: boolean; + profileId?: string; } | ({ isInternal: false; @@ -178,6 +182,7 @@ export type TSignCertFromCaDTO = signatureAlgorithm?: string; keyAlgorithm?: string; isFromProfile?: boolean; + profileId?: string; } & Omit); export type TGetCaCertificateTemplatesDTO = { diff --git a/backend/src/services/certificate-common/certificate-constants.ts b/backend/src/services/certificate-common/certificate-constants.ts index a7dea2c96..4cc3afb8b 100644 --- a/backend/src/services/certificate-common/certificate-constants.ts +++ b/backend/src/services/certificate-common/certificate-constants.ts @@ -187,24 +187,6 @@ export enum CertificateRenewalErrorType { UNKNOWN_ERROR = "UNKNOWN_ERROR" } -export const CERTIFICATE_RENEWAL_ERROR_MESSAGES = { - [CertificateRenewalErrorType.TEMPLATE_VALIDATION_FAILED]: - "Auto-renewal failed: certificate template policy has changed and this certificate no longer meets the requirements", - [CertificateRenewalErrorType.CA_NOT_FOUND]: - "Auto-renewal failed: Certificate Authority for this certificate is no longer available", - [CertificateRenewalErrorType.CA_INACTIVE]: "Auto-renewal failed: Certificate Authority is currently inactive", - [CertificateRenewalErrorType.CERTIFICATE_OUTLIVES_CA]: - "Auto-renewal failed: certificate would outlive the Certificate Authority", - [CertificateRenewalErrorType.TTL_TOO_SHORT]: - "Auto-renewal failed: certificate validity period is too short for the renewal threshold", - [CertificateRenewalErrorType.NOT_ELIGIBLE]: "Auto-renewal failed: certificate is not eligible for automatic renewal", - [CertificateRenewalErrorType.VALIDITY_EXCEEDS_MAXIMUM]: - "Auto-renewal failed: certificate validity period exceeds the maximum allowed by the profile template", - [CertificateRenewalErrorType.NOT_ALLOWED_BY_TEMPLATE]: - "Auto-renewal failed: certificate settings are no longer allowed by the profile template", - [CertificateRenewalErrorType.UNKNOWN_ERROR]: "Auto-renewal failed: an unexpected error occurred" -} as const; - export const CERTIFICATE_RENEWAL_CONFIG = { MIN_RENEW_BEFORE_DAYS: 1, MAX_RENEW_BEFORE_DAYS: 30, diff --git a/backend/src/services/certificate-common/certificate-utils.ts b/backend/src/services/certificate-common/certificate-utils.ts index 3e1b7b5b4..b88f183db 100644 --- a/backend/src/services/certificate-common/certificate-utils.ts +++ b/backend/src/services/certificate-common/certificate-utils.ts @@ -1,12 +1,8 @@ import RE2 from "re2"; -import { BadRequestError, ForbiddenRequestError, NotFoundError } from "@app/lib/errors"; - import { CertExtendedKeyUsage, CertKeyUsage } from "../certificate/certificate-types"; import { CertExtendedKeyUsageType, - CERTIFICATE_RENEWAL_ERROR_MESSAGES, - CertificateRenewalErrorType, CertKeyUsageType, mapExtendedKeyUsageToLegacy, mapKeyUsageToLegacy, @@ -200,74 +196,3 @@ export const convertExtendedKeyUsageArrayToLegacy = ( ): CertExtendedKeyUsage[] | undefined => { return usages?.map(convertToLegacyExtendedKeyUsage); }; - -export const categorizeCertificateRenewalError = (error: unknown): string => { - if (!error) { - return CERTIFICATE_RENEWAL_ERROR_MESSAGES[CertificateRenewalErrorType.UNKNOWN_ERROR]; - } - - const errorMessage = error instanceof Error ? error.message : String(error); - - if (error instanceof NotFoundError) { - if (errorMessage.includes("Certificate Authority")) { - return CERTIFICATE_RENEWAL_ERROR_MESSAGES[CertificateRenewalErrorType.CA_NOT_FOUND]; - } - if (errorMessage.includes("Certificate template")) { - return CERTIFICATE_RENEWAL_ERROR_MESSAGES[CertificateRenewalErrorType.TEMPLATE_VALIDATION_FAILED]; - } - } - - if (error instanceof BadRequestError) { - if (errorMessage.includes("Certificate Authority is") && errorMessage.includes("must be ACTIVE")) { - return CERTIFICATE_RENEWAL_ERROR_MESSAGES[CertificateRenewalErrorType.CA_INACTIVE]; - } - if (errorMessage.includes("would expire") && errorMessage.includes("after its issuing CA")) { - return CERTIFICATE_RENEWAL_ERROR_MESSAGES[CertificateRenewalErrorType.CERTIFICATE_OUTLIVES_CA]; - } - if (errorMessage.includes("TTL") && errorMessage.includes("must be greater than renewal threshold")) { - return CERTIFICATE_RENEWAL_ERROR_MESSAGES[CertificateRenewalErrorType.TTL_TOO_SHORT]; - } - if (errorMessage.includes("not eligible for renewal")) { - return CERTIFICATE_RENEWAL_ERROR_MESSAGES[CertificateRenewalErrorType.NOT_ELIGIBLE]; - } - if (errorMessage.includes("Requested validity period exceeds maximum allowed duration")) { - return CERTIFICATE_RENEWAL_ERROR_MESSAGES[CertificateRenewalErrorType.VALIDITY_EXCEEDS_MAXIMUM]; - } - if (errorMessage.includes("not allowed by template policy")) { - return CERTIFICATE_RENEWAL_ERROR_MESSAGES[CertificateRenewalErrorType.NOT_ALLOWED_BY_TEMPLATE]; - } - } - - if (error instanceof ForbiddenRequestError) { - if (errorMessage.includes("Template validation failed")) { - return CERTIFICATE_RENEWAL_ERROR_MESSAGES[CertificateRenewalErrorType.TEMPLATE_VALIDATION_FAILED]; - } - } - - if (errorMessage.includes("Template validation failed")) { - return CERTIFICATE_RENEWAL_ERROR_MESSAGES[CertificateRenewalErrorType.TEMPLATE_VALIDATION_FAILED]; - } - if (errorMessage.includes("Certificate Authority") && errorMessage.includes("not found")) { - return CERTIFICATE_RENEWAL_ERROR_MESSAGES[CertificateRenewalErrorType.CA_NOT_FOUND]; - } - if (errorMessage.includes("Certificate Authority is") && errorMessage.includes("must be ACTIVE")) { - return CERTIFICATE_RENEWAL_ERROR_MESSAGES[CertificateRenewalErrorType.CA_INACTIVE]; - } - if (errorMessage.includes("would expire") && errorMessage.includes("after its issuing CA")) { - return CERTIFICATE_RENEWAL_ERROR_MESSAGES[CertificateRenewalErrorType.CERTIFICATE_OUTLIVES_CA]; - } - if (errorMessage.includes("TTL") && errorMessage.includes("must be greater than renewal threshold")) { - return CERTIFICATE_RENEWAL_ERROR_MESSAGES[CertificateRenewalErrorType.TTL_TOO_SHORT]; - } - if (errorMessage.includes("not eligible for renewal")) { - return CERTIFICATE_RENEWAL_ERROR_MESSAGES[CertificateRenewalErrorType.NOT_ELIGIBLE]; - } - if (errorMessage.includes("Requested validity period exceeds maximum allowed duration")) { - return CERTIFICATE_RENEWAL_ERROR_MESSAGES[CertificateRenewalErrorType.VALIDITY_EXCEEDS_MAXIMUM]; - } - if (errorMessage.includes("not allowed by template policy")) { - return CERTIFICATE_RENEWAL_ERROR_MESSAGES[CertificateRenewalErrorType.NOT_ALLOWED_BY_TEMPLATE]; - } - - return `${CERTIFICATE_RENEWAL_ERROR_MESSAGES[CertificateRenewalErrorType.UNKNOWN_ERROR]}: ${errorMessage}`; -}; diff --git a/backend/src/services/certificate-v3/certificate-v3-queue.ts b/backend/src/services/certificate-v3/certificate-v3-queue.ts index afb69f67f..7c1b9d004 100644 --- a/backend/src/services/certificate-v3/certificate-v3-queue.ts +++ b/backend/src/services/certificate-v3/certificate-v3-queue.ts @@ -6,7 +6,6 @@ import { QueueJobs, QueueName, TQueueServiceFactory } from "@app/queue"; import { ActorType } from "../auth/auth-type"; import { TCertificateDALFactory } from "../certificate/certificate-dal"; import { CERTIFICATE_RENEWAL_CONFIG } from "../certificate-common/certificate-constants"; -import { categorizeCertificateRenewalError } from "../certificate-common/certificate-utils"; import { TCertificateV3ServiceFactory } from "./certificate-v3-service"; type TCertificateV3QueueServiceFactoryDep = { @@ -70,9 +69,6 @@ export const certificateV3QueueServiceFactory = ({ internal: true }); - await certificateDAL.updateById(certificate.id, { - renewalError: null - }); totalCertificatesRenewed += 1; await auditLogService.createAuditLog({ @@ -87,42 +83,32 @@ export const certificateV3QueueServiceFactory = ({ certificateId: certificate.id, commonName: certificate.commonName || "", profileId: certificate.profileId!, - renewBeforeDays: certificate.renewBeforeDays?.toString() || "" + renewBeforeDays: certificate.renewBeforeDays?.toString() || "", + profileName: certificate.profileName || "" } } }); } catch (error) { - const categorizedError: string = categorizeCertificateRenewalError(error); - - try { - await certificateDAL.updateById(certificate.id, { - renewalError: categorizedError - }); - } catch (updateError) { - logger.error(updateError, `Failed to update renewal error for certificate ${certificate.id}`); - } - - try { - await auditLogService.createAuditLog({ - projectId: certificate.projectId, - actor: { - type: ActorType.PLATFORM, - metadata: {} - }, - event: { - type: EventType.AUTOMATED_RENEW_CERTIFICATE_FAILED, - metadata: { - certificateId: certificate.id, - commonName: certificate.commonName || "", - profileId: certificate.profileId || "", - renewBeforeDays: certificate.renewBeforeDays?.toString() || "", - error: categorizedError - } + const errorMessage = error instanceof Error ? error.message : String(error); + logger.error(error, `Failed to renew certificate ${certificate.id}: ${errorMessage}`); + await auditLogService.createAuditLog({ + projectId: certificate.projectId, + actor: { + type: ActorType.PLATFORM, + metadata: {} + }, + event: { + type: EventType.AUTOMATED_RENEW_CERTIFICATE_FAILED, + metadata: { + certificateId: certificate.id, + commonName: certificate.commonName || "", + profileId: certificate.profileId || "", + renewBeforeDays: certificate.renewBeforeDays?.toString() || "", + profileName: certificate.profileName || "", + error: errorMessage } - }); - } catch (auditError) { - logger.error(auditError, `Failed to create audit log for failed certificate renewal ${certificate.id}`); - } + } + }); } } diff --git a/backend/src/services/certificate-v3/certificate-v3-service.test.ts b/backend/src/services/certificate-v3/certificate-v3-service.test.ts index 2e7c87fb6..2dab87b4a 100644 --- a/backend/src/services/certificate-v3/certificate-v3-service.test.ts +++ b/backend/src/services/certificate-v3/certificate-v3-service.test.ts @@ -29,10 +29,14 @@ import { certificateV3ServiceFactory, TCertificateV3ServiceFactory } from "./cer describe("CertificateV3Service", () => { let service: TCertificateV3ServiceFactory; - const mockCertificateDAL: Pick = { + const mockCertificateDAL: Pick = { findOne: vi.fn(), findById: vi.fn(), - updateById: vi.fn() + updateById: vi.fn(), + transaction: vi.fn().mockImplementation(async (callback: (tx: any) => Promise) => { + const mockTx = {}; + return callback(mockTx); + }) }; const mockCertificateAuthorityDAL: Pick = { @@ -78,7 +82,7 @@ describe("CertificateV3Service", () => { beforeEach(() => { // Reset all mocks before each test - vi.clearAllMocks(); + vi.resetAllMocks(); // Mock ForbiddenError.from static method vi.spyOn(ForbiddenError, "from").mockReturnValue({ @@ -1473,7 +1477,7 @@ describe("CertificateV3Service", () => { notBefore: new Date("2024-01-01"), notAfter: new Date("2024-02-01"), // 31 days revokedAt: null, - renewedById: null, + renewedByCertificateId: null, profileId: "profile-123", renewBeforeDays: 7, caId: "ca-123", @@ -1487,7 +1491,7 @@ describe("CertificateV3Service", () => { certificateTemplateId: "template-123", revocationReason: null, caCertId: null, - renewedFromId: null, + renewedFromCertificateId: null, renewalError: null, keyAlgorithm: "RSA_2048", signatureAlgorithm: "RSA-SHA256" @@ -1570,8 +1574,6 @@ describe("CertificateV3Service", () => { }; beforeEach(() => { - vi.clearAllMocks(); - // Mock current date to be within renewal window vi.useFakeTimers(); vi.setSystemTime(new Date("2024-01-26")); // 6 days before cert expires, within renewal window @@ -1582,6 +1584,7 @@ describe("CertificateV3Service", () => { }); it("should successfully renew eligible certificate", async () => { + // Mock the initial findById call vi.mocked(mockCertificateDAL.findById).mockResolvedValue(mockOriginalCert); vi.mocked(mockCertificateProfileDAL.findByIdWithConfigs).mockResolvedValue(mockProfile); vi.mocked(mockCertificateAuthorityDAL.findByIdWithAssociatedCa).mockResolvedValue(mockCA); @@ -1604,6 +1607,13 @@ describe("CertificateV3Service", () => { vi.mocked(mockCertificateDAL.findOne).mockResolvedValue(newCert); vi.mocked(mockCertificateDAL.updateById).mockResolvedValue(newCert); + // Mock the transaction to return the expected structure + vi.mocked(mockCertificateDAL.transaction).mockImplementation(async (callback: (tx: any) => Promise) => { + const mockTx = {}; + const result = await callback(mockTx); + return result; + }); + const result = await service.renewCertificate({ certificateId: "cert-123", ...mockActor @@ -1611,15 +1621,23 @@ describe("CertificateV3Service", () => { expect(result).toHaveProperty("certificate", "renewed-cert"); expect(result).toHaveProperty("certificateId", "cert-456"); - expect(mockCertificateDAL.updateById).toHaveBeenCalledWith("cert-456", { - profileId: "profile-123", - renewBeforeDays: 14, - renewedFromId: "cert-123" - }); - expect(mockCertificateDAL.updateById).toHaveBeenCalledWith("cert-123", { - renewedById: "cert-456", - renewalError: null - }); + expect(mockCertificateDAL.updateById).toHaveBeenCalledWith( + "cert-456", + { + profileId: "profile-123", + renewBeforeDays: 14, + renewedFromCertificateId: "cert-123" + }, + {} + ); + expect(mockCertificateDAL.updateById).toHaveBeenCalledWith( + "cert-123", + { + renewedByCertificateId: "cert-456", + renewalError: null + }, + {} + ); }); it("should validate certificate against current template during renewal", async () => { @@ -1633,6 +1651,15 @@ describe("CertificateV3Service", () => { warnings: [] }); + // Mock updateById to handle the renewal error logging + vi.mocked(mockCertificateDAL.updateById).mockResolvedValue(mockOriginalCert); + + // Set up transaction mock to properly handle errors + vi.mocked(mockCertificateDAL.transaction).mockImplementation(async (callback: (tx: any) => Promise) => { + const mockTx = {}; + return callback(mockTx); + }); + await expect( service.renewCertificate({ certificateId: "cert-123", @@ -1645,9 +1672,7 @@ describe("CertificateV3Service", () => { certificateId: "cert-123", ...mockActor }) - ).rejects.toThrow( - "Certificate renewal failed because requested validity period exceeds maximum allowed duration by the profile template: Subject alternative name not allowed" - ); + ).rejects.toThrow("Certificate renewal failed. Errors: Subject alternative name not allowed"); // Should store template validation error expect(mockCertificateDAL.updateById).toHaveBeenCalledWith("cert-123", { @@ -1659,6 +1684,12 @@ describe("CertificateV3Service", () => { const certWithoutProfile = { ...mockOriginalCert, profileId: null }; vi.mocked(mockCertificateDAL.findById).mockResolvedValue(certWithoutProfile); + // Set up transaction mock to properly handle errors + vi.mocked(mockCertificateDAL.transaction).mockImplementation(async (callback: (tx: any) => Promise) => { + const mockTx = {}; + return callback(mockTx); + }); + await expect( service.renewCertificate({ certificateId: "cert-123", @@ -1675,11 +1706,20 @@ describe("CertificateV3Service", () => { }); it("should reject renewal if certificate is already renewed", async () => { - const alreadyRenewedCert = { ...mockOriginalCert, renewedById: "cert-456" }; + const alreadyRenewedCert = { ...mockOriginalCert, renewedByCertificateId: "cert-456" }; vi.mocked(mockCertificateDAL.findById).mockResolvedValue(alreadyRenewedCert); vi.mocked(mockCertificateProfileDAL.findByIdWithConfigs).mockResolvedValue(mockProfile); vi.mocked(mockCertificateAuthorityDAL.findByIdWithAssociatedCa).mockResolvedValue(mockCA); + // Mock updateById to handle the renewal error logging + vi.mocked(mockCertificateDAL.updateById).mockResolvedValue(alreadyRenewedCert); + + // Set up transaction mock to properly handle errors + vi.mocked(mockCertificateDAL.transaction).mockImplementation(async (callback: (tx: any) => Promise) => { + const mockTx = {}; + return callback(mockTx); + }); + await expect( service.renewCertificate({ certificateId: "cert-123", @@ -1704,6 +1744,15 @@ describe("CertificateV3Service", () => { vi.mocked(mockCertificateProfileDAL.findByIdWithConfigs).mockResolvedValue(mockProfile); vi.mocked(mockCertificateAuthorityDAL.findByIdWithAssociatedCa).mockResolvedValue(mockCA); + // Mock updateById to handle the renewal error logging + vi.mocked(mockCertificateDAL.updateById).mockResolvedValue(expiredCert); + + // Set up transaction mock to properly handle errors + vi.mocked(mockCertificateDAL.transaction).mockImplementation(async (callback: (tx: any) => Promise) => { + const mockTx = {}; + return callback(mockTx); + }); + await expect( service.renewCertificate({ certificateId: "cert-123", @@ -1728,6 +1777,15 @@ describe("CertificateV3Service", () => { vi.mocked(mockCertificateProfileDAL.findByIdWithConfigs).mockResolvedValue(mockProfile); vi.mocked(mockCertificateAuthorityDAL.findByIdWithAssociatedCa).mockResolvedValue(mockCA); + // Mock updateById to handle the renewal error logging + vi.mocked(mockCertificateDAL.updateById).mockResolvedValue(revokedCert); + + // Set up transaction mock to properly handle errors + vi.mocked(mockCertificateDAL.transaction).mockImplementation(async (callback: (tx: any) => Promise) => { + const mockTx = {}; + return callback(mockTx); + }); + await expect( service.renewCertificate({ certificateId: "cert-123", @@ -1749,6 +1807,15 @@ describe("CertificateV3Service", () => { vi.mocked(mockCertificateProfileDAL.findByIdWithConfigs).mockResolvedValue(mockProfile); vi.mocked(mockCertificateAuthorityDAL.findByIdWithAssociatedCa).mockResolvedValue(inactiveCA); + // Mock updateById to handle the renewal error logging + vi.mocked(mockCertificateDAL.updateById).mockResolvedValue(mockOriginalCert); + + // Set up transaction mock to properly handle errors + vi.mocked(mockCertificateDAL.transaction).mockImplementation(async (callback: (tx: any) => Promise) => { + const mockTx = {}; + return callback(mockTx); + }); + await expect( service.renewCertificate({ certificateId: "cert-123", @@ -1776,6 +1843,15 @@ describe("CertificateV3Service", () => { vi.mocked(mockCertificateProfileDAL.findByIdWithConfigs).mockResolvedValue(mockProfile); vi.mocked(mockCertificateAuthorityDAL.findByIdWithAssociatedCa).mockResolvedValue(shortLivedCA); + // Mock updateById to handle the renewal error logging + vi.mocked(mockCertificateDAL.updateById).mockResolvedValue(mockOriginalCert); + + // Set up transaction mock to properly handle errors + vi.mocked(mockCertificateDAL.transaction).mockImplementation(async (callback: (tx: any) => Promise) => { + const mockTx = {}; + return callback(mockTx); + }); + await expect( service.renewCertificate({ certificateId: "cert-123", @@ -1816,6 +1892,12 @@ describe("CertificateV3Service", () => { vi.mocked(mockCertificateDAL.findOne).mockResolvedValue(newCert); vi.mocked(mockCertificateDAL.updateById).mockResolvedValue(newCert); + // Set up transaction mock to properly handle the renewal process + vi.mocked(mockCertificateDAL.transaction).mockImplementation(async (callback: (tx: any) => Promise) => { + const mockTx = {}; + return callback(mockTx); + }); + const result = await service.renewCertificate({ certificateId: "cert-123", ...mockActor @@ -1830,12 +1912,13 @@ describe("CertificateV3Service", () => { const mockCert = { id: "cert-123", profileId: "profile-123", - renewedById: null, + renewedByCertificateId: null, notBefore: new Date("2026-01-01"), notAfter: new Date("2026-02-01"), projectId: "project-123", status: CertStatus.ACTIVE, - revokedAt: null + revokedAt: null, + commonName: "" }; const mockProfile = { @@ -1859,7 +1942,8 @@ describe("CertificateV3Service", () => { expect(result).toEqual({ projectId: "project-123", - renewBeforeDays: 7 + renewBeforeDays: 7, + commonName: "" }); expect(mockCertificateDAL.updateById).toHaveBeenCalledWith("cert-123", { @@ -1871,7 +1955,7 @@ describe("CertificateV3Service", () => { const mockCert = { id: "cert-123", profileId: null, - renewedById: null, + renewedByCertificateId: null, projectId: "project-123" }; @@ -1904,7 +1988,7 @@ describe("CertificateV3Service", () => { const mockCert = { id: "cert-123", profileId: "profile-123", - renewedById: "cert-456", + renewedByCertificateId: "cert-456", projectId: "project-123", status: CertStatus.ACTIVE, revokedAt: null, @@ -1948,7 +2032,7 @@ describe("CertificateV3Service", () => { const mockCert = { id: "cert-123", profileId: "profile-123", - renewedById: null, + renewedByCertificateId: null, notBefore: new Date("2026-01-01"), notAfter: new Date("2026-01-08"), projectId: "project-123", @@ -1994,7 +2078,8 @@ describe("CertificateV3Service", () => { const mockCert = { id: "cert-123", profileId: "profile-123", - projectId: "project-123" + projectId: "project-123", + commonName: "" }; const mockProfile = { @@ -2016,7 +2101,8 @@ describe("CertificateV3Service", () => { }); expect(result).toEqual({ - projectId: "project-123" + projectId: "project-123", + commonName: "" }); expect(mockCertificateDAL.updateById).toHaveBeenCalledWith("cert-123", { diff --git a/backend/src/services/certificate-v3/certificate-v3-service.ts b/backend/src/services/certificate-v3/certificate-v3-service.ts index 7b933b68f..42980f8a7 100644 --- a/backend/src/services/certificate-v3/certificate-v3-service.ts +++ b/backend/src/services/certificate-v3/certificate-v3-service.ts @@ -16,6 +16,7 @@ import { CertExtendedKeyUsage, CertificateOrderStatus, CertKeyAlgorithm, + CertKeyType, CertKeyUsage, CertSignatureAlgorithm, CertStatus @@ -56,7 +57,7 @@ import { } from "./certificate-v3-types"; type TCertificateV3ServiceFactoryDep = { - certificateDAL: Pick; + certificateDAL: Pick; certificateAuthorityDAL: Pick; certificateProfileDAL: Pick; certificateTemplateV2Service: Pick< @@ -114,7 +115,7 @@ const validateRenewalEligibility = ( notBefore: Date; notAfter: Date; revokedAt?: Date | null; - renewedById?: string | null; + renewedByCertificateId?: string | null; profileId?: string | null; caId?: string | null; pkiSubscriberId?: string | null; @@ -153,7 +154,7 @@ const validateRenewalEligibility = ( errors.push(`Certificate Authority is ${ca.status}, must be ${CaStatus.ACTIVE}`); } - if (certificate.renewedById) { + if (certificate.renewedByCertificateId) { errors.push("Certificate has already been renewed"); } @@ -212,11 +213,11 @@ const validateAlgorithmCompatibility = ( const keyType = parts[parts.length - 1]; if (caKeyAlgorithm.startsWith("RSA")) { - return keyType === "RSA"; + return keyType === CertKeyType.RSA; } if (caKeyAlgorithm.startsWith("EC")) { - return keyType === "ECDSA"; + return keyType === CertKeyType.ECDSA; } return false; @@ -338,7 +339,8 @@ export const certificateV3ServiceFactory = ({ actorId, actorAuthMethod, actorOrgId, - templateId: profile.certificateTemplateId + templateId: profile.certificateTemplateId, + internal: true }); if (!template) { throw new NotFoundError({ message: "Certificate template not found for this profile" }); @@ -362,10 +364,6 @@ export const certificateV3ServiceFactory = ({ validateCaSupport(ca, "direct certificate issuance"); - if (!actorAuthMethod) { - throw new BadRequestError({ message: "Authentication method is required for certificate issuance" }); - } - validateAlgorithmCompatibility(ca, template); const effectiveSignatureAlgorithm = certificateRequest.signatureAlgorithm as CertSignatureAlgorithm | undefined; @@ -433,7 +431,8 @@ export const certificateV3ServiceFactory = ({ serialNumber, certificateId: cert.id, projectId: profile.projectId, - profileName: profile.slug + profileName: profile.slug, + commonName: cert.commonName || "" }; }; @@ -468,16 +467,13 @@ export const certificateV3ServiceFactory = ({ validateCaSupport(ca, "CSR signing"); - if (!actorAuthMethod) { - throw new BadRequestError({ message: "Authentication method is required for certificate signing" }); - } - const template = await certificateTemplateV2Service.getTemplateV2ById({ actor, actorId, actorAuthMethod, actorOrgId, - templateId: profile.certificateTemplateId + templateId: profile.certificateTemplateId, + internal: true }); if (!template) { @@ -541,7 +537,8 @@ export const certificateV3ServiceFactory = ({ serialNumber, certificateId: cert.id, projectId: profile.projectId, - profileName: profile.slug + profileName: profile.slug, + commonName: cert.commonName || "" }; }; @@ -645,178 +642,224 @@ export const certificateV3ServiceFactory = ({ actorOrgId, internal = false }: TRenewCertificateDTO & { internal?: boolean }): Promise => { - const originalCert = await certificateDAL.findById(certificateId); - if (!originalCert) { - throw new NotFoundError({ message: "Certificate not found" }); - } - - if (!originalCert.profileId) { - throw new ForbiddenRequestError({ - message: "Only certificates issued from a profile can be renewed" - }); - } - - const originalSignatureAlgorithm = originalCert.signatureAlgorithm as CertSignatureAlgorithm; - const originalKeyAlgorithm = originalCert.keyAlgorithm as CertKeyAlgorithm; - - if (!originalSignatureAlgorithm || !originalKeyAlgorithm) { - throw new BadRequestError({ - message: - "Original certificate does not have algorithm information stored. Cannot renew certificate issued before algorithm tracking was implemented." - }); - } - - const profile = await certificateProfileDAL.findByIdWithConfigs(originalCert.profileId); - if (!profile) { - throw new NotFoundError({ message: "Certificate profile not found" }); - } - - if (profile.enrollmentType !== "api") { - throw new ForbiddenRequestError({ - message: "Certificate is not eligible for renewal: EST certificates cannot be renewed through this endpoint" - }); - } - - const ca = await certificateAuthorityDAL.findByIdWithAssociatedCa(profile.caId); - if (!ca) { - throw new NotFoundError({ message: "Certificate Authority not found" }); - } - - const eligibilityCheck = validateRenewalEligibility(originalCert, ca); - if (!eligibilityCheck.isEligible) { - throw new BadRequestError({ - message: `Certificate is not eligible for renewal: ${eligibilityCheck.errors.join(", ")}` - }); - } - - if (!internal) { - const { permission } = await permissionService.getProjectPermission({ - actor, - actorId, - projectId: profile.projectId, - actorAuthMethod, - actorOrgId, - actionProjectType: ActionProjectType.CertificateManager - }); - - ForbiddenError.from(permission).throwUnlessCan( - ProjectPermissionCertificateProfileActions.IssueCert, - ProjectPermissionSub.CertificateProfiles - ); - } - - validateCaSupport(ca, "direct certificate issuance"); - - const template = await certificateTemplateV2Service.getTemplateV2ById({ - actor, - actorId, - actorAuthMethod, - actorOrgId, - templateId: profile.certificateTemplateId, - internal - }); - - if (!template) { - throw new NotFoundError({ message: "Certificate template not found for this profile" }); - } - - const originalTtlInDays = Math.ceil( - (new Date(originalCert.notAfter).getTime() - new Date(originalCert.notBefore).getTime()) / (1000 * 60 * 60 * 24) - ); - const ttl = `${originalTtlInDays}d`; - - const certificateRequest = { - commonName: originalCert.commonName || undefined, - keyUsages: convertKeyUsageArrayFromLegacy(parseKeyUsages(originalCert.keyUsages)), - extendedKeyUsages: convertExtendedKeyUsageArrayFromLegacy(parseExtendedKeyUsages(originalCert.extendedKeyUsages)), - subjectAlternativeNames: originalCert.altNames - ? originalCert.altNames.split(",").map((san) => { - const trimmed = san.trim(); - const isIp = - trimmed.length <= 45 && - (new RE2("^\\d{1,3}\\.\\d{1,3}\\.\\d{1,3}\\.\\d{1,3}$").test(trimmed) || - new RE2("^([0-9a-fA-F]{1,4}:){7}[0-9a-fA-F]{1,4}$").test(trimmed)); - return { - type: isIp ? CertSubjectAlternativeNameType.IP_ADDRESS : CertSubjectAlternativeNameType.DNS_NAME, - value: trimmed - }; - }) - : [], - validity: { - ttl + const renewalResult = await certificateDAL.transaction(async (tx) => { + const originalCert = await certificateDAL.findById(certificateId, tx); + if (!originalCert) { + throw new NotFoundError({ message: "Certificate not found" }); } - }; - const validationResult = await certificateTemplateV2Service.validateCertificateRequest( - profile.certificateTemplateId, - certificateRequest - ); + if (!originalCert.profileId) { + throw new ForbiddenRequestError({ + message: "Only certificates issued from a profile can be renewed" + }); + } - if (!validationResult.isValid) { - await certificateDAL.updateById(originalCert.id, { - renewalError: `Template validation failed: ${validationResult.errors.join(", ")}` - }); + const originalSignatureAlgorithm = originalCert.signatureAlgorithm as CertSignatureAlgorithm; + const originalKeyAlgorithm = originalCert.keyAlgorithm as CertKeyAlgorithm; - throw new BadRequestError({ - message: `Certificate renewal failed because requested validity period exceeds maximum allowed duration by the profile template: ${validationResult.errors.join(", ")}` - }); - } + if (!originalSignatureAlgorithm || !originalKeyAlgorithm) { + throw new BadRequestError({ + message: + "Original certificate does not have algorithm information stored. Cannot renew certificate issued before algorithm tracking was implemented." + }); + } - validateAlgorithmCompatibility(ca, template); - const notBefore = new Date(); - const notAfter = new Date(Date.now() + parseTtlToDays(ttl) * 24 * 60 * 60 * 1000); + const profile = await certificateProfileDAL.findByIdWithConfigs(originalCert.profileId); + if (!profile) { + throw new NotFoundError({ message: "Certificate profile not found" }); + } - const { certificate, certificateChain, issuingCaCertificate, serialNumber } = - await internalCaService.issueCertFromCa({ - caId: ca.id, - friendlyName: originalCert.friendlyName || originalCert.commonName || "Renewed Certificate", - commonName: originalCert.commonName || "", - altNames: originalCert.altNames || "", - ttl, - notBefore: normalizeDateForApi(notBefore), - notAfter: normalizeDateForApi(notAfter), - keyUsages: parseKeyUsages(originalCert.keyUsages), - extendedKeyUsages: parseExtendedKeyUsages(originalCert.extendedKeyUsages), - signatureAlgorithm: originalSignatureAlgorithm, - keyAlgorithm: originalKeyAlgorithm, - isFromProfile: true, + if (profile.enrollmentType !== EnrollmentType.API) { + throw new ForbiddenRequestError({ + message: "Certificate is not eligible for renewal: EST certificates cannot be renewed through this endpoint" + }); + } + + if (!internal) { + const { permission } = await permissionService.getProjectPermission({ + actor, + actorId, + projectId: profile.projectId, + actorAuthMethod, + actorOrgId, + actionProjectType: ActionProjectType.CertificateManager + }); + + ForbiddenError.from(permission).throwUnlessCan( + ProjectPermissionCertificateProfileActions.IssueCert, + ProjectPermissionSub.CertificateProfiles + ); + } + + const ca = await certificateAuthorityDAL.findByIdWithAssociatedCa(profile.caId); + if (!ca) { + throw new NotFoundError({ message: "Certificate Authority not found" }); + } + + const eligibilityCheck = validateRenewalEligibility(originalCert, ca); + if (!eligibilityCheck.isEligible) { + await certificateDAL.updateById(originalCert.id, { + renewalError: `Certificate is not eligible for renewal: ${eligibilityCheck.errors.join(", ")}` + }); + throw new BadRequestError({ + message: `Certificate is not eligible for renewal: ${eligibilityCheck.errors.join(", ")}` + }); + } + + validateCaSupport(ca, "direct certificate issuance"); + + const template = await certificateTemplateV2Service.getTemplateV2ById({ actor, actorId, actorAuthMethod, actorOrgId, + templateId: profile.certificateTemplateId, internal }); - const newCert = await certificateDAL.findOne({ serialNumber, caId: ca.id }); - if (!newCert) { - throw new NotFoundError({ message: "Certificate was signed but could not be found in database" }); - } + if (!template) { + throw new NotFoundError({ message: "Certificate template not found for this profile" }); + } - const certificateTtlInDays = parseTtlToDays(ttl); - const finalRenewBeforeDays = calculateRenewalThreshold(profile.apiConfig?.renewBeforeDays, certificateTtlInDays); + const originalTtlInDays = Math.ceil( + (new Date(originalCert.notAfter).getTime() - new Date(originalCert.notBefore).getTime()) / (1000 * 60 * 60 * 24) + ); + const ttl = `${originalTtlInDays}d`; - await certificateDAL.updateById(newCert.id, { - profileId: originalCert.profileId, - renewBeforeDays: finalRenewBeforeDays, - renewedFromId: originalCert.id + const certificateRequest = { + commonName: originalCert.commonName || undefined, + keyUsages: convertKeyUsageArrayFromLegacy(parseKeyUsages(originalCert.keyUsages)), + extendedKeyUsages: convertExtendedKeyUsageArrayFromLegacy( + parseExtendedKeyUsages(originalCert.extendedKeyUsages) + ), + subjectAlternativeNames: originalCert.altNames + ? originalCert.altNames.split(",").map((san) => { + const trimmed = san.trim(); + + const isIpv4 = new RE2("^\\d{1,3}\\.\\d{1,3}\\.\\d{1,3}\\.\\d{1,3}$").test(trimmed); + const isIpv6 = new RE2("^([0-9a-fA-F]{1,4}:){7}[0-9a-fA-F]{1,4}$").test(trimmed); + if (isIpv4 || isIpv6) { + return { + type: CertSubjectAlternativeNameType.IP_ADDRESS, + value: trimmed + }; + } + + if (new RE2("^[^@]+@[^@]+\\.[^@]+$").test(trimmed)) { + return { + type: CertSubjectAlternativeNameType.EMAIL, + value: trimmed + }; + } + + if (new RE2("^[a-zA-Z][a-zA-Z0-9+.-]*:").test(trimmed)) { + return { + type: CertSubjectAlternativeNameType.URI, + value: trimmed + }; + } + + return { + type: CertSubjectAlternativeNameType.DNS_NAME, + value: trimmed + }; + }) + : [], + validity: { + ttl + }, + signatureAlgorithm: originalCert.signatureAlgorithm || undefined, + keyAlgorithm: originalCert.keyAlgorithm || undefined + }; + + const validationResult = await certificateTemplateV2Service.validateCertificateRequest( + profile.certificateTemplateId, + certificateRequest + ); + + if (!validationResult.isValid) { + await certificateDAL.updateById(originalCert.id, { + renewalError: `Template validation failed: ${validationResult.errors.join(", ")}` + }); + + throw new BadRequestError({ + message: `Certificate renewal failed. Errors: ${validationResult.errors.join(", ")}` + }); + } + + validateAlgorithmCompatibility(ca, template); + const notBefore = new Date(); + const notAfter = new Date(Date.now() + parseTtlToDays(ttl) * 24 * 60 * 60 * 1000); + + const certificateTtlInDays = parseTtlToDays(ttl); + const finalRenewBeforeDays = calculateRenewalThreshold(profile.apiConfig?.renewBeforeDays, certificateTtlInDays); + + const { certificate, certificateChain, issuingCaCertificate, serialNumber } = + await internalCaService.issueCertFromCa({ + caId: ca.id, + friendlyName: originalCert.friendlyName || originalCert.commonName || "Renewed Certificate", + commonName: originalCert.commonName || "", + altNames: originalCert.altNames || "", + ttl, + notBefore: normalizeDateForApi(notBefore), + notAfter: normalizeDateForApi(notAfter), + keyUsages: parseKeyUsages(originalCert.keyUsages), + extendedKeyUsages: parseExtendedKeyUsages(originalCert.extendedKeyUsages), + signatureAlgorithm: originalSignatureAlgorithm, + keyAlgorithm: originalKeyAlgorithm, + isFromProfile: true, + actor, + actorId, + actorAuthMethod, + actorOrgId, + internal: true, + tx + }); + + const newCert = await certificateDAL.findOne({ serialNumber, caId: ca.id }, tx); + if (!newCert) { + throw new NotFoundError({ message: "Certificate was signed but could not be found in database" }); + } + + await certificateDAL.updateById( + newCert.id, + { + profileId: originalCert.profileId, + renewBeforeDays: finalRenewBeforeDays, + renewedFromCertificateId: originalCert.id + }, + tx + ); + + await certificateDAL.updateById( + originalCert.id, + { + renewedByCertificateId: newCert.id, + renewalError: null + }, + tx + ); + + return { + certificate, + certificateChain, + issuingCaCertificate, + serialNumber, + newCert, + originalCert, + profile + }; }); - await certificateDAL.updateById(originalCert.id, { - renewedById: newCert.id, - renewalError: null - }); - - const certificateString = extractCertificateFromBuffer(certificate as unknown as Buffer); - const certificateChainString = extractCertificateFromBuffer(certificateChain as unknown as Buffer); - return { - certificate: certificateString, - issuingCaCertificate: extractCertificateFromBuffer(issuingCaCertificate as unknown as Buffer), - certificateChain: certificateChainString, - serialNumber, - certificateId: newCert.id, - projectId: profile.projectId, - profileName: profile.slug + certificate: renewalResult.certificate, + issuingCaCertificate: renewalResult.issuingCaCertificate, + certificateChain: renewalResult.certificateChain, + serialNumber: renewalResult.serialNumber, + certificateId: renewalResult.newCert.id, + projectId: renewalResult.profile.projectId, + profileName: renewalResult.profile.slug, + commonName: renewalResult.originalCert.commonName || "" }; }; @@ -858,7 +901,7 @@ export const certificateV3ServiceFactory = ({ throw new NotFoundError({ message: "Certificate profile not found" }); } - if (profile.enrollmentType !== "api") { + if (profile.enrollmentType !== EnrollmentType.API) { throw new ForbiddenRequestError({ message: "Certificate is not eligible for auto-renewal: EST certificates cannot be auto-renewed" }); @@ -883,7 +926,7 @@ export const certificateV3ServiceFactory = ({ }); } - if (certificate.renewedById) { + if (certificate.renewedByCertificateId) { throw new BadRequestError({ message: "Certificate is not eligible for auto-renewal: certificate has already been renewed" }); @@ -911,7 +954,8 @@ export const certificateV3ServiceFactory = ({ return { projectId: certificate.projectId, - renewBeforeDays + renewBeforeDays, + commonName: certificate.commonName || "" }; }; @@ -952,7 +996,7 @@ export const certificateV3ServiceFactory = ({ throw new NotFoundError({ message: "Certificate profile not found" }); } - if (profile.enrollmentType !== "api") { + if (profile.enrollmentType !== EnrollmentType.API) { throw new ForbiddenRequestError({ message: "Certificate is not eligible for auto-renewal: EST certificates cannot be auto-renewed" }); @@ -963,7 +1007,8 @@ export const certificateV3ServiceFactory = ({ }); return { - projectId: certificate.projectId + projectId: certificate.projectId, + commonName: certificate.commonName || "" }; }; diff --git a/backend/src/services/certificate-v3/certificate-v3-types.ts b/backend/src/services/certificate-v3/certificate-v3-types.ts index 705765c7c..9bbc7f743 100644 --- a/backend/src/services/certificate-v3/certificate-v3-types.ts +++ b/backend/src/services/certificate-v3/certificate-v3-types.ts @@ -68,6 +68,7 @@ export type TCertificateFromProfileResponse = { certificateId: string; projectId: string; profileName: string; + commonName: string; }; export type TCertificateOrderResponse = { @@ -114,8 +115,10 @@ export type TDisableRenewalConfigDTO = { export type TRenewalConfigResponse = { projectId: string; renewBeforeDays: number; + commonName: string; }; export type TDisableRenewalResponse = { projectId: string; + commonName: string; }; diff --git a/backend/src/services/certificate/certificate-dal.ts b/backend/src/services/certificate/certificate-dal.ts index 99a5885b1..d69f063ff 100644 --- a/backend/src/services/certificate/certificate-dal.ts +++ b/backend/src/services/certificate/certificate-dal.ts @@ -1,7 +1,7 @@ import { TDbClient } from "@app/db"; import { TableName, TCertificates } from "@app/db/schemas"; import { DatabaseError } from "@app/lib/errors"; -import { ormify } from "@app/lib/knex"; +import { ormify, selectAllTableCols } from "@app/lib/knex"; import { CertStatus } from "./certificate-types"; @@ -120,32 +120,33 @@ export const certificateDALFactory = (db: TDbClient) => { }: { limit: number; offset: number; - }): Promise => { + }): Promise<(TCertificates & { profileName?: string })[]> => { try { const now = new Date(); const endOfDay = new Date(now.getFullYear(), now.getMonth(), now.getDate(), 23, 59, 59, 999); const certs = (await db .replicaNode()(TableName.Certificate) - .select(`${TableName.Certificate}.*`) + .select(selectAllTableCols(TableName.Certificate)) + .select(db.ref("slug").withSchema(TableName.PkiCertificateProfile).as("profileName")) + .leftJoin( + TableName.PkiCertificateProfile, + `${TableName.Certificate}.profileId`, + `${TableName.PkiCertificateProfile}.id` + ) .where(`${TableName.Certificate}.status`, CertStatus.ACTIVE) - .whereNull(`${TableName.Certificate}.renewedById`) + .whereNull(`${TableName.Certificate}.renewedByCertificateId`) .whereNull(`${TableName.Certificate}.renewalError`) .whereNull(`${TableName.Certificate}.revokedAt`) .whereNotNull(`${TableName.Certificate}.profileId`) .whereNotNull(`${TableName.Certificate}.notAfter`) .where(`${TableName.Certificate}.notAfter`, ">", now) - .where((queryBuilder) => { - void queryBuilder.where((subQuery) => { - void subQuery - .whereNotNull(`${TableName.Certificate}.renewBeforeDays`) - .where(`${TableName.Certificate}.renewBeforeDays`, ">", 0) - .whereRaw( - `"${TableName.Certificate}"."notAfter" - INTERVAL '1 day' * "${TableName.Certificate}"."renewBeforeDays" <= ?`, - [endOfDay] - ); - }); - }) + .whereNotNull(`${TableName.Certificate}.renewBeforeDays`) + .where(`${TableName.Certificate}.renewBeforeDays`, ">", 0) + .whereRaw( + `"${TableName.Certificate}"."notAfter" - INTERVAL '1 day' * "${TableName.Certificate}"."renewBeforeDays" <= ?`, + [endOfDay] + ) .limit(limit) .offset(offset) .orderBy(`${TableName.Certificate}.notAfter`, "asc")) as TCertificates[]; diff --git a/backend/src/services/certificate/certificate-types.ts b/backend/src/services/certificate/certificate-types.ts index 9da331be8..d654c96ba 100644 --- a/backend/src/services/certificate/certificate-types.ts +++ b/backend/src/services/certificate/certificate-types.ts @@ -21,6 +21,11 @@ export enum CertKeyAlgorithm { ECDSA_P521 = "EC_secp521r1" } +export enum CertKeyType { + RSA = "RSA", + ECDSA = "ECDSA" +} + export enum CertSignatureAlgorithm { RSA_SHA256 = "RSA-SHA256", RSA_SHA384 = "RSA-SHA384", diff --git a/backend/src/services/project/project-service.ts b/backend/src/services/project/project-service.ts index e29f18404..f38764dd9 100644 --- a/backend/src/services/project/project-service.ts +++ b/backend/src/services/project/project-service.ts @@ -944,7 +944,7 @@ export const projectServiceFactory = ({ ...(friendlyName && { friendlyName }), ...(commonName && { commonName }) }, - { offset, limit, sort: [["updatedAt", "desc"]] } + { offset, limit, sort: [["notAfter", "desc"]] } ); const count = await certificateDAL.countCertificatesInProject({ diff --git a/frontend/src/hooks/api/certificates/mutations.tsx b/frontend/src/hooks/api/certificates/mutations.tsx index 75a2ca10a..eed1d9e5f 100644 --- a/frontend/src/hooks/api/certificates/mutations.tsx +++ b/frontend/src/hooks/api/certificates/mutations.tsx @@ -117,10 +117,10 @@ export const useUpdateRenewalConfig = () => { object, TUpdateRenewalConfigDTO >({ - mutationFn: async ({ certificateId, renewBeforeDays, disableAutoRenewal }) => { + mutationFn: async ({ certificateId, renewBeforeDays, enableAutoRenewal }) => { const { data } = await apiRequest.patch<{ message: string; renewBeforeDays?: number }>( `/api/v3/certificates/${certificateId}/config`, - { renewBeforeDays, disableAutoRenewal } + { renewBeforeDays, enableAutoRenewal } ); return data; }, diff --git a/frontend/src/hooks/api/certificates/types.ts b/frontend/src/hooks/api/certificates/types.ts index 73505b344..d38fcace0 100644 --- a/frontend/src/hooks/api/certificates/types.ts +++ b/frontend/src/hooks/api/certificates/types.ts @@ -16,8 +16,8 @@ export type TCertificate = { extendedKeyUsages: CertExtendedKeyUsage[]; renewBeforeDays?: number; renewedBy?: string; - renewedFromId?: string; - renewedById?: string; + renewedFromCertificateId?: string; + renewedByCertificateId?: string; renewalError?: string; }; @@ -67,6 +67,6 @@ export type TRenewCertificateResponse = { export type TUpdateRenewalConfigDTO = { certificateId: string; renewBeforeDays?: number; - disableAutoRenewal?: boolean; + enableAutoRenewal?: boolean; projectSlug: string; }; diff --git a/frontend/src/pages/cert-manager/CertificatesPage/components/CertificateManageRenewalModal.tsx b/frontend/src/pages/cert-manager/CertificatesPage/components/CertificateManageRenewalModal.tsx index dd3ecb9b9..d6199678a 100644 --- a/frontend/src/pages/cert-manager/CertificatesPage/components/CertificateManageRenewalModal.tsx +++ b/frontend/src/pages/cert-manager/CertificatesPage/components/CertificateManageRenewalModal.tsx @@ -1,4 +1,4 @@ -import { useEffect } from "react"; +import { useEffect, useMemo } from "react"; import { Controller, useForm } from "react-hook-form"; import { zodResolver } from "@hookform/resolvers/zod"; import { z } from "zod"; @@ -7,6 +7,7 @@ import { createNotification } from "@app/components/notifications"; import { Button, FormControl, Input, Modal, ModalContent } from "@app/components/v2"; import { useProject } from "@app/context"; import { useUpdateRenewalConfig } from "@app/hooks/api"; +import { useGetCertificateProfileById } from "@app/hooks/api/certificateProfiles"; import { UsePopUpState } from "@app/hooks/usePopUp"; const DEFAULT_RENEWAL_BEFORE_DAYS = 20; @@ -64,7 +65,8 @@ const RenewalConfigForm = ({ }) => (
@@ -111,10 +113,24 @@ export const CertificateManageRenewalModal = ({ popUp, handlePopUpToggle }: Prop ttlDays?: number; notAfter: string; renewalError?: string; - renewedFromId?: string; - renewedById?: string; + renewedFromCertificateId?: string; + renewedByCertificateId?: string; }; + const { data: profileData } = useGetCertificateProfileById({ + profileId: certificateData?.profileId || "" + }); + + const defaultRenewalDays = useMemo(() => { + if (certificateData?.renewBeforeDays) { + return certificateData.renewBeforeDays; + } + if (profileData?.apiConfig?.renewBeforeDays) { + return profileData.apiConfig.renewBeforeDays; + } + return DEFAULT_RENEWAL_BEFORE_DAYS; + }, [certificateData?.renewBeforeDays, profileData?.apiConfig?.renewBeforeDays]); + const isAutoRenewalEnabled = Boolean( certificateData?.renewBeforeDays && certificateData.renewBeforeDays > 0 ); @@ -134,17 +150,17 @@ export const CertificateManageRenewalModal = ({ popUp, handlePopUpToggle }: Prop } = useForm({ resolver: zodResolver(formSchema), defaultValues: { - renewBeforeDays: DEFAULT_RENEWAL_BEFORE_DAYS + renewBeforeDays: defaultRenewalDays } }); useEffect(() => { if (popUp.manageRenewal.isOpen) { reset({ - renewBeforeDays: certificateData?.renewBeforeDays || DEFAULT_RENEWAL_BEFORE_DAYS + renewBeforeDays: defaultRenewalDays }); } - }, [popUp.manageRenewal.isOpen, certificateData?.renewBeforeDays, reset]); + }, [popUp.manageRenewal.isOpen, defaultRenewalDays, reset]); const onUpdateRenewal = async (data: FormData) => { try { diff --git a/frontend/src/pages/cert-manager/CertificatesPage/components/CertificateRenewalDisableModal.tsx b/frontend/src/pages/cert-manager/CertificatesPage/components/CertificateRenewalDisableModal.tsx index 619272041..613080cd7 100644 --- a/frontend/src/pages/cert-manager/CertificatesPage/components/CertificateRenewalDisableModal.tsx +++ b/frontend/src/pages/cert-manager/CertificatesPage/components/CertificateRenewalDisableModal.tsx @@ -31,7 +31,7 @@ export const CertificateRenewalDisableModal = ({ popUp, handlePopUpToggle }: Pro await updateRenewalConfig({ certificateId: certificateData.certificateId, projectSlug: currentProject.slug, - disableAutoRenewal: true + enableAutoRenewal: false }); createNotification({ diff --git a/frontend/src/pages/cert-manager/CertificatesPage/components/CertificatesTable.tsx b/frontend/src/pages/cert-manager/CertificatesPage/components/CertificatesTable.tsx index 72c047bec..6114583d6 100644 --- a/frontend/src/pages/cert-manager/CertificatesPage/components/CertificatesTable.tsx +++ b/frontend/src/pages/cert-manager/CertificatesPage/components/CertificatesTable.tsx @@ -36,7 +36,8 @@ import { import { ProjectPermissionCertificateActions, ProjectPermissionSub, - useProject + useProject, + useSubscription } from "@app/context"; import { useListWorkspaceCertificates, useUpdateRenewalConfig } from "@app/hooks/api"; import { caSupportsCapability } from "@app/hooks/api/ca/constants"; @@ -56,8 +57,8 @@ const isExpiringWithinOneDay = (notAfter: string): boolean => { }; const getAutoRenewalInfo = (certificate: TCertificate) => { - if (certificate.renewedById) { - return { text: "Renewed", variant: "success" as const }; + if (certificate.renewedByCertificateId) { + return { text: "Renewed", variant: "instance" as const }; } const isRevoked = certificate.status === CertStatus.REVOKED; @@ -65,8 +66,36 @@ const getAutoRenewalInfo = (certificate: TCertificate) => { const hasNoProfile = !certificate.profileId; const isExpiringWithinDay = isExpiringWithinOneDay(certificate.notAfter); - if (isRevoked || isExpired || hasNoProfile || isExpiringWithinDay) { - return null; + if (isRevoked) { + return { + text: "Not Available", + variant: "instance" as const, + tooltip: "Auto-renewal is not available for revoked certificates" + }; + } + + if (isExpired) { + return { + text: "Not Available", + variant: "instance" as const, + tooltip: "Auto-renewal is not available for expired certificates" + }; + } + + if (hasNoProfile) { + return { + text: "Not Available", + variant: "instance" as const, + tooltip: "Auto-renewal requires a certificate profile" + }; + } + + if (isExpiringWithinDay) { + return { + text: "Not Available", + variant: "instance" as const, + tooltip: "Auto-renewal is not available for certificates expiring within 24 hours" + }; } if (certificate.renewalError) { @@ -127,8 +156,8 @@ type Props = { ttlDays?: number; notAfter?: string; renewalError?: string; - renewedFromId?: string; - renewedById?: string; + renewedFromCertificateId?: string; + renewedByCertificateId?: string; } ) => void; }; @@ -138,6 +167,7 @@ const PER_PAGE_INIT = 25; export const CertificatesTable = ({ handlePopUpOpen }: Props) => { const [page, setPage] = useState(1); const [perPage, setPerPage] = useState(PER_PAGE_INIT); + const { subscription } = useSubscription(); const { currentProject } = useProject(); const { data, isPending } = useListWorkspaceCertificates({ @@ -147,6 +177,7 @@ export const CertificatesTable = ({ handlePopUpOpen }: Props) => { }); const { mutateAsync: updateRenewalConfig } = useUpdateRenewalConfig(); + const isLegacyTemplatesEnabled = subscription.pkiLegacyTemplates; const { data: caData } = useListCasByProjectId(currentProject?.id ?? ""); @@ -173,7 +204,7 @@ export const CertificatesTable = ({ handlePopUpOpen }: Props) => { await updateRenewalConfig({ certificateId, projectSlug: currentProject.slug, - disableAutoRenewal: true + enableAutoRenewal: false }); createNotification({ @@ -198,7 +229,7 @@ export const CertificatesTable = ({ handlePopUpOpen }: Props) => { Status Not Before Not After - Auto Renewal + Renewal Status @@ -286,32 +317,34 @@ export const CertificatesTable = ({ handlePopUpOpen }: Props) => { )} - - {(isAllowed) => ( - - handlePopUpOpen("certificate", { - serialNumber: certificate.serialNumber - }) - } - disabled={!isAllowed} - icon={} - > - View Details - - )} - + {isLegacyTemplatesEnabled && ( + + {(isAllowed) => ( + + handlePopUpOpen("certificate", { + serialNumber: certificate.serialNumber + }) + } + disabled={!isAllowed} + icon={} + > + View Details + + )} + + )} {/* Manage auto renewal option - not shown for failed renewals */} {(() => { const canManageRenewal = certificate.profileId && - !certificate.renewedById && + !certificate.renewedByCertificateId && !isRevoked && !isExpired && !hasFailed && @@ -353,8 +386,9 @@ export const CertificatesTable = ({ handlePopUpOpen }: Props) => { ttlDays, notAfter: certificate.notAfter, renewalError: certificate.renewalError, - renewedFromId: certificate.renewedFromId, - renewedById: certificate.renewedById + renewedFromCertificateId: + certificate.renewedFromCertificateId, + renewedByCertificateId: certificate.renewedByCertificateId }); }} disabled={!isAllowed} @@ -373,7 +407,7 @@ export const CertificatesTable = ({ handlePopUpOpen }: Props) => { {(() => { const canDisableRenewal = certificate.profileId && - !certificate.renewedById && + !certificate.renewedByCertificateId && !isRevoked && !isExpired && !isExpiringWithinDay && @@ -411,7 +445,7 @@ export const CertificatesTable = ({ handlePopUpOpen }: Props) => { {(() => { const canRenew = certificate.profileId && - !certificate.renewedById && + !certificate.renewedByCertificateId && !isRevoked && !isExpired; diff --git a/frontend/src/pages/cert-manager/CertificatesPage/components/useCertificateTemplate.ts b/frontend/src/pages/cert-manager/CertificatesPage/components/useCertificateTemplate.ts index 871ad00a4..5499a2762 100644 --- a/frontend/src/pages/cert-manager/CertificatesPage/components/useCertificateTemplate.ts +++ b/frontend/src/pages/cert-manager/CertificatesPage/components/useCertificateTemplate.ts @@ -11,6 +11,26 @@ import { mapTemplateSignatureAlgorithmToApi } from "@app/pages/cert-manager/PoliciesPage/components/CertificateTemplatesV2Tab/shared/certificate-constants"; +const convertTemplateTtlToCertificateTtl = (templateTtl: string): string => { + const match = templateTtl.match(/^(\d+)([dmyh])$/); + if (!match) return templateTtl; + + const [, value, unit] = match; + const numValue = parseInt(value, 10); + + switch (unit) { + case "m": + return `${numValue * 30}d`; + case "y": + return `${numValue * 365}d`; + case "d": + case "h": + return templateTtl; + default: + return templateTtl; + } +}; + export type TemplateConstraints = { allowedKeyUsages: string[]; allowedExtendedKeyUsages: string[]; @@ -118,7 +138,7 @@ export const useCertificateTemplate = ( // Set TTL if available if (templateData.validity?.max) { - setValue("ttl", templateData.validity.max); + setValue("ttl", convertTemplateTtlToCertificateTtl(templateData.validity.max)); } // Handle SAN types