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 e60e8458f..2226194e5 100644 --- a/backend/src/db/migrations/20251021112356_add-certificate-auto-renewal.ts +++ b/backend/src/db/migrations/20251021112356_add-certificate-auto-renewal.ts @@ -5,8 +5,7 @@ import { TableName } from "../schemas"; export async function up(knex: Knex): Promise { if (await knex.schema.hasColumn(TableName.PkiApiEnrollmentConfig, "autoRenewDays")) { await knex.schema.alterTable(TableName.PkiApiEnrollmentConfig, (t) => { - t.dropColumn("autoRenewDays"); - t.integer("renewBeforeDays"); + t.renameColumn("autoRenewDays", "renewBeforeDays"); }); } @@ -46,8 +45,7 @@ export async function down(knex: Knex): Promise { if (await knex.schema.hasColumn(TableName.PkiApiEnrollmentConfig, "renewBeforeDays")) { await knex.schema.alterTable(TableName.PkiApiEnrollmentConfig, (t) => { - t.dropColumn("renewBeforeDays"); - t.integer("autoRenewDays"); + t.renameColumn("renewBeforeDays", "autoRenewDays"); }); } } diff --git a/backend/src/server/routes/index.ts b/backend/src/server/routes/index.ts index 5df10e5e0..d7a84e87f 100644 --- a/backend/src/server/routes/index.ts +++ b/backend/src/server/routes/index.ts @@ -2147,9 +2147,6 @@ export const registerRoutes = async ( const certificateV3Queue = certificateV3QueueServiceFactory({ queueService, certificateDAL, - certificateAuthorityDAL, - certificateProfileDAL, - projectDAL, certificateV3Service, auditLogService }); diff --git a/backend/src/server/routes/v3/certificates-router.ts b/backend/src/server/routes/v3/certificates-router.ts index f4a94321c..126a3b146 100644 --- a/backend/src/server/routes/v3/certificates-router.ts +++ b/backend/src/server/routes/v3/certificates-router.ts @@ -406,10 +406,14 @@ export const registerCertificatesRouter = async (server: FastifyZodProvider) => params: z.object({ certificateId: z.string().uuid() }), - body: z.object({ - renewBeforeDays: z.number().int().min(1).max(30).optional(), - disableAutoRenewal: z.boolean().optional() - }), + body: z + .object({ + renewBeforeDays: z.number().int().min(1).max(30).optional(), + disableAutoRenewal: z.boolean().optional() + }) + .refine((data) => !(data.renewBeforeDays !== undefined && data.disableAutoRenewal === true), { + message: "Cannot specify both renewBeforeDays and disableAutoRenewal" + }), response: { 200: z.object({ message: z.string(), diff --git a/backend/src/services/certificate-common/certificate-constants.ts b/backend/src/services/certificate-common/certificate-constants.ts index 6500b0fab..a7dea2c96 100644 --- a/backend/src/services/certificate-common/certificate-constants.ts +++ b/backend/src/services/certificate-common/certificate-constants.ts @@ -175,6 +175,36 @@ export enum CertSignatureAlgorithm { ECDSA_SHA512 = "ECDSA-SHA512" } +export enum CertificateRenewalErrorType { + TEMPLATE_VALIDATION_FAILED = "TEMPLATE_VALIDATION_FAILED", + CA_NOT_FOUND = "CA_NOT_FOUND", + CA_INACTIVE = "CA_INACTIVE", + CERTIFICATE_OUTLIVES_CA = "CERTIFICATE_OUTLIVES_CA", + TTL_TOO_SHORT = "TTL_TOO_SHORT", + NOT_ELIGIBLE = "NOT_ELIGIBLE", + VALIDITY_EXCEEDS_MAXIMUM = "VALIDITY_EXCEEDS_MAXIMUM", + NOT_ALLOWED_BY_TEMPLATE = "NOT_ALLOWED_BY_TEMPLATE", + 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 b88f183db..3e1b7b5b4 100644 --- a/backend/src/services/certificate-common/certificate-utils.ts +++ b/backend/src/services/certificate-common/certificate-utils.ts @@ -1,8 +1,12 @@ 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, @@ -196,3 +200,74 @@ 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 f756f4e26..afb69f67f 100644 --- a/backend/src/services/certificate-v3/certificate-v3-queue.ts +++ b/backend/src/services/certificate-v3/certificate-v3-queue.ts @@ -5,19 +5,13 @@ import { QueueJobs, QueueName, TQueueServiceFactory } from "@app/queue"; import { ActorType } from "../auth/auth-type"; import { TCertificateDALFactory } from "../certificate/certificate-dal"; -import { CertStatus } from "../certificate/certificate-types"; -import { TCertificateAuthorityDALFactory } from "../certificate-authority/certificate-authority-dal"; import { CERTIFICATE_RENEWAL_CONFIG } from "../certificate-common/certificate-constants"; -import { TCertificateProfileDALFactory } from "../certificate-profile/certificate-profile-dal"; -import { TProjectDALFactory } from "../project/project-dal"; +import { categorizeCertificateRenewalError } from "../certificate-common/certificate-utils"; import { TCertificateV3ServiceFactory } from "./certificate-v3-service"; type TCertificateV3QueueServiceFactoryDep = { queueService: TQueueServiceFactory; - certificateDAL: TCertificateDALFactory; - certificateAuthorityDAL: Pick; - certificateProfileDAL: Pick; - projectDAL: Pick; + certificateDAL: Pick; certificateV3Service: TCertificateV3ServiceFactory; auditLogService: Pick; }; @@ -25,9 +19,6 @@ type TCertificateV3QueueServiceFactoryDep = { export const certificateV3QueueServiceFactory = ({ queueService, certificateDAL, - certificateAuthorityDAL, - certificateProfileDAL, - projectDAL, certificateV3Service, auditLogService }: TCertificateV3QueueServiceFactoryDep) => { @@ -38,190 +29,109 @@ export const certificateV3QueueServiceFactory = ({ const { QUEUE_BATCH_SIZE } = CERTIFICATE_RENEWAL_CONFIG; let offset = 0; let hasMore = true; + let totalCertificatesFound = 0; + let totalCertificatesRenewed = 0; while (hasMore) { - const certificates = await certificateDAL.find( - { - $notNull: ["profileId"], - status: CertStatus.ACTIVE, - renewedById: null, - renewalError: null, - revokedAt: null - }, - { - limit: QUEUE_BATCH_SIZE, - offset - } - ); + const certificates = await certificateDAL.findCertificatesEligibleForRenewal({ + limit: QUEUE_BATCH_SIZE, + offset + }); if (certificates.length === 0) { hasMore = false; break; } - await Promise.all( - certificates.map(async (certificate) => { - try { - if (!certificate.profileId || !certificate.notAfter) { - return; - } - - const profile = await certificateProfileDAL.findByIdWithConfigs(certificate.profileId); - if (!profile) { - logger.warn(`Profile not found for certificate ${certificate.id}`); - return; - } - - const ca = await certificateAuthorityDAL.findByIdWithAssociatedCa(profile.caId); - if (!ca) { - logger.warn(`CA not found for certificate ${certificate.id}`); - return; - } - - const profileAutoRenewEnabled = profile.apiConfig?.autoRenew === true; - const certificateHasRenewalConfig = - certificate.renewBeforeDays != null && certificate.renewBeforeDays > 0; - - if (!profileAutoRenewEnabled && !certificateHasRenewalConfig) { - return; - } - - const now = new Date(); - if (certificate.notAfter <= now) { - return; - } - - const renewBeforeDays = certificate.renewBeforeDays || profile.apiConfig?.renewBeforeDays; - if (!renewBeforeDays) { - return; - } + totalCertificatesFound += certificates.length; + logger.info( + `${QueueJobs.CertificateV3DailyAutoRenewal}: found ${certificates.length} certificates eligible for renewal (batch ${Math.floor(offset / QUEUE_BATCH_SIZE) + 1}, total found so far: ${totalCertificatesFound})` + ); + for (const certificate of certificates) { + try { + if (certificate.renewBeforeDays) { const { MIN_RENEW_BEFORE_DAYS, MAX_RENEW_BEFORE_DAYS } = CERTIFICATE_RENEWAL_CONFIG; - if (renewBeforeDays < MIN_RENEW_BEFORE_DAYS || renewBeforeDays > MAX_RENEW_BEFORE_DAYS) { - logger.warn(`Invalid renewal threshold ${renewBeforeDays} for certificate ${certificate.id}`); - return; - } - - const expiryDate = new Date(certificate.notAfter); - const renewalDate = new Date(expiryDate.getTime() - renewBeforeDays * 24 * 60 * 60 * 1000); - - const shouldRenew = renewalDate <= now; - - if (shouldRenew) { - logger.info(`Auto-renewing certificate ${certificate.id} (common name: ${certificate.commonName})`); - - const project = await projectDAL.findById(certificate.projectId); - if (!project) { - logger.error(`Project not found for certificate ${certificate.id}`); - return; - } - - await certificateV3Service.renewCertificate({ - actor: ActorType.PLATFORM, - actorId: "", - actorAuthMethod: null, - actorOrgId: project.orgId, - certificateId: certificate.id, - internal: true - }); - - await certificateDAL.updateById(certificate.id, { - renewalError: null - }); - - await auditLogService.createAuditLog({ - projectId: certificate.projectId, - actor: { - type: ActorType.PLATFORM, - metadata: {} - }, - event: { - type: EventType.AUTOMATED_RENEW_CERTIFICATE, - metadata: { - certificateId: certificate.id, - commonName: certificate.commonName || "", - profileId: certificate.profileId, - renewBeforeDays: certificate.renewBeforeDays?.toString() || "" - } - } - }); - - logger.info(`Successfully auto-renewed certificate ${certificate.id}`); - } - } catch (error) { - logger.error( - error, - `Failed to auto-renew certificate ${certificate.id} (common name: ${certificate.commonName})` - ); - - const errorMessage = error instanceof Error ? error.message : "Unknown error"; - let categorizedError = errorMessage; - - if (errorMessage.includes("Template validation failed")) { - categorizedError = - "Auto-renewal failed: certificate template policy has changed and this certificate no longer meets the requirements"; - } else if (errorMessage.includes("Certificate Authority") && errorMessage.includes("not found")) { - categorizedError = - "Auto-renewal failed: Certificate Authority for this certificate is no longer available"; - } else if (errorMessage.includes("Certificate Authority is") && errorMessage.includes("must be ACTIVE")) { - categorizedError = "Auto-renewal failed: Certificate Authority is currently inactive"; - } else if (errorMessage.includes("would expire") && errorMessage.includes("after its issuing CA")) { - categorizedError = "Auto-renewal failed: certificate would outlive the Certificate Authority"; - } else if ( - errorMessage.includes("TTL") && - errorMessage.includes("must be greater than renewal threshold") + if ( + certificate.renewBeforeDays < MIN_RENEW_BEFORE_DAYS || + certificate.renewBeforeDays > MAX_RENEW_BEFORE_DAYS ) { - categorizedError = - "Auto-renewal failed: certificate validity period is too short for the renewal threshold"; - } else if (errorMessage.includes("not eligible for renewal")) { - categorizedError = "Auto-renewal failed: certificate is not eligible for automatic renewal"; - } else if (errorMessage.includes("Requested validity period exceeds maximum allowed duration")) { - categorizedError = - "Auto-renewal failed: certificate validity period exceeds the maximum allowed by the profile template"; - } else if (errorMessage.includes("not allowed by template policy")) { - categorizedError = - "Auto-renewal failed: certificate settings are no longer allowed by the profile template"; - } else { - categorizedError = `Auto-renewal failed: ${errorMessage}`; - } - - 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 - } - } - }); - } catch (auditError) { - logger.error(auditError, `Failed to create audit log for failed certificate renewal ${certificate.id}`); + // eslint-disable-next-line no-continue + continue; } } - }) - ); + + await certificateV3Service.renewCertificate({ + actor: ActorType.PLATFORM, + actorId: "", + actorAuthMethod: null, + actorOrgId: "", + certificateId: certificate.id, + internal: true + }); + + await certificateDAL.updateById(certificate.id, { + renewalError: null + }); + totalCertificatesRenewed += 1; + + await auditLogService.createAuditLog({ + projectId: certificate.projectId, + actor: { + type: ActorType.PLATFORM, + metadata: {} + }, + event: { + type: EventType.AUTOMATED_RENEW_CERTIFICATE, + metadata: { + certificateId: certificate.id, + commonName: certificate.commonName || "", + profileId: certificate.profileId!, + renewBeforeDays: certificate.renewBeforeDays?.toString() || "" + } + } + }); + } 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 + } + } + }); + } catch (auditError) { + logger.error(auditError, `Failed to create audit log for failed certificate renewal ${certificate.id}`); + } + } + } offset += QUEUE_BATCH_SIZE; } - logger.info(`${QueueJobs.CertificateV3DailyAutoRenewal}: queue task completed`); + logger.info( + `${QueueJobs.CertificateV3DailyAutoRenewal}: queue task completed. Renewed ${totalCertificatesRenewed} certificates out of ${totalCertificatesFound}` + ); } }); 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 a4f0f0818..2e7c87fb6 100644 --- a/backend/src/services/certificate-v3/certificate-v3-service.test.ts +++ b/backend/src/services/certificate-v3/certificate-v3-service.test.ts @@ -1646,7 +1646,7 @@ describe("CertificateV3Service", () => { ...mockActor }) ).rejects.toThrow( - "Certificate renewal failed because requested validity period exceeds maximum allowed duration by the profile template" + "Certificate renewal failed because requested validity period exceeds maximum allowed duration by the profile template: Subject alternative name not allowed" ); // Should store template validation error @@ -1788,7 +1788,7 @@ describe("CertificateV3Service", () => { certificateId: "cert-123", ...mockActor }) - ).rejects.toThrow("New certificate would expire"); + ).rejects.toThrow(/New certificate would expire \(.+\) after its issuing CA \(.+\)/); }); it("should allow manual renewal outside window (manual renewal always bypasses window)", async () => { diff --git a/backend/src/services/certificate-v3/certificate-v3-service.ts b/backend/src/services/certificate-v3/certificate-v3-service.ts index c987fb77b..7b933b68f 100644 --- a/backend/src/services/certificate-v3/certificate-v3-service.ts +++ b/backend/src/services/certificate-v3/certificate-v3-service.ts @@ -733,8 +733,9 @@ export const certificateV3ServiceFactory = ({ ? originalCert.altNames.split(",").map((san) => { const trimmed = san.trim(); const isIp = - 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); + 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 diff --git a/backend/src/services/certificate/certificate-dal.ts b/backend/src/services/certificate/certificate-dal.ts index 88808bd63..99a5885b1 100644 --- a/backend/src/services/certificate/certificate-dal.ts +++ b/backend/src/services/certificate/certificate-dal.ts @@ -114,12 +114,55 @@ export const certificateDALFactory = (db: TDbClient) => { } }; + const findCertificatesEligibleForRenewal = async ({ + limit, + offset + }: { + limit: number; + offset: number; + }): Promise => { + 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}.*`) + .where(`${TableName.Certificate}.status`, CertStatus.ACTIVE) + .whereNull(`${TableName.Certificate}.renewedById`) + .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] + ); + }); + }) + .limit(limit) + .offset(offset) + .orderBy(`${TableName.Certificate}.notAfter`, "asc")) as TCertificates[]; + + return certs; + } catch (error) { + throw new DatabaseError({ error, name: "Find certificates eligible for renewal" }); + } + }; + return { ...certificateOrm, countCertificatesInProject, countCertificatesForPkiSubscriber, findLatestActiveCertForSubscriber, findAllActiveCertsForSubscriber, - findExpiredSyncedCertificates + findExpiredSyncedCertificates, + findCertificatesEligibleForRenewal }; }; diff --git a/frontend/src/hooks/api/certificates/mutations.tsx b/frontend/src/hooks/api/certificates/mutations.tsx index 699cbb8eb..75a2ca10a 100644 --- a/frontend/src/hooks/api/certificates/mutations.tsx +++ b/frontend/src/hooks/api/certificates/mutations.tsx @@ -115,7 +115,7 @@ export const useUpdateRenewalConfig = () => { return useMutation< { message: string; renewBeforeDays?: number }, object, - TUpdateRenewalConfigDTO & { disableAutoRenewal?: boolean } + TUpdateRenewalConfigDTO >({ mutationFn: async ({ certificateId, renewBeforeDays, disableAutoRenewal }) => { const { data } = await apiRequest.patch<{ message: string; renewBeforeDays?: number }>( diff --git a/frontend/src/hooks/api/certificates/types.ts b/frontend/src/hooks/api/certificates/types.ts index 80d02e3f8..73505b344 100644 --- a/frontend/src/hooks/api/certificates/types.ts +++ b/frontend/src/hooks/api/certificates/types.ts @@ -67,5 +67,6 @@ export type TRenewCertificateResponse = { export type TUpdateRenewalConfigDTO = { certificateId: string; renewBeforeDays?: number; + disableAutoRenewal?: 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 86adf948e..dd3ecb9b9 100644 --- a/frontend/src/pages/cert-manager/CertificatesPage/components/CertificateManageRenewalModal.tsx +++ b/frontend/src/pages/cert-manager/CertificatesPage/components/CertificateManageRenewalModal.tsx @@ -13,24 +13,91 @@ const DEFAULT_RENEWAL_BEFORE_DAYS = 20; const MIN_RENEWAL_BEFORE_DAYS = 1; const MAX_RENEWAL_BEFORE_DAYS = 30; -const formSchema = z - .object({ +const createFormSchema = (ttlDays: number, notAfter: string) => + z.object({ renewBeforeDays: z .number() .min(MIN_RENEWAL_BEFORE_DAYS, `Renewal days must be at least ${MIN_RENEWAL_BEFORE_DAYS}`) .max(MAX_RENEWAL_BEFORE_DAYS, `Renewal days cannot exceed ${MAX_RENEWAL_BEFORE_DAYS}`) - }) - .refine(() => { - return true; - }, "Invalid renewal configuration"); + .refine( + (value) => value < ttlDays, + (value) => ({ + message: `Renewal days (${value}) must be less than certificate TTL (${ttlDays} days)` + }) + ) + .refine( + (value) => { + const expiryDate = new Date(notAfter); + const renewalDate = new Date(expiryDate.getTime() - value * 24 * 60 * 60 * 1000); + const tomorrow = new Date(); + tomorrow.setDate(tomorrow.getDate() + 1); + tomorrow.setHours(0, 0, 0, 0); + return renewalDate >= tomorrow; + }, + () => ({ + message: "Renewals can only be scheduled from tomorrow onwards." + }) + ) + }); -type FormData = z.infer; +type FormData = z.infer>; type Props = { popUp: UsePopUpState<["manageRenewal"]>; handlePopUpToggle: (popUpName: keyof UsePopUpState<["manageRenewal"]>, state?: boolean) => void; }; +const RenewalConfigForm = ({ + control, + errors, + onSubmit, + isLoading, + buttonText, + onCancel +}: { + control: any; + errors: { renewBeforeDays?: { message?: string } }; + onSubmit: (e?: React.BaseSyntheticEvent) => Promise; + isLoading: boolean; + buttonText: string; + onCancel: () => void; +}) => ( +
+ + ( + { + const value = parseInt(e.target.value, 10); + field.onChange(value); + }} + placeholder="Enter days before expiration" + /> + )} + /> + + +
+ + +
+
+); + export const CertificateManageRenewalModal = ({ popUp, handlePopUpToggle }: Props) => { const { currentProject } = useProject(); const { mutateAsync: updateRenewalConfig, isPending: isUpdatingConfig } = @@ -41,7 +108,7 @@ export const CertificateManageRenewalModal = ({ popUp, handlePopUpToggle }: Prop commonName: string; profileId: string; renewBeforeDays?: number; - ttlDays: number; + ttlDays?: number; notAfter: string; renewalError?: string; renewedFromId?: string; @@ -54,6 +121,11 @@ export const CertificateManageRenewalModal = ({ popUp, handlePopUpToggle }: Prop const hasRenewalError = Boolean(certificateData?.renewalError); + const formSchema = createFormSchema( + certificateData?.ttlDays || 365, + certificateData?.notAfter || "" + ); + const { control, handleSubmit, @@ -84,30 +156,6 @@ export const CertificateManageRenewalModal = ({ popUp, handlePopUpToggle }: Prop return; } - if (data.renewBeforeDays >= certificateData.ttlDays) { - createNotification({ - text: `Renewal days (${data.renewBeforeDays}) must be less than certificate TTL (${certificateData.ttlDays} days)`, - type: "error" - }); - return; - } - - const expiryDate = new Date(certificateData.notAfter); - const renewalDate = new Date( - expiryDate.getTime() - data.renewBeforeDays * 24 * 60 * 60 * 1000 - ); - const tomorrow = new Date(); - tomorrow.setDate(tomorrow.getDate() + 1); - tomorrow.setHours(0, 0, 0, 0); - - if (renewalDate < tomorrow) { - createNotification({ - text: "The renewal date cannot be set to today or any past date. Renewals can only be scheduled from tomorrow onwards.", - type: "error" - }); - return; - } - await updateRenewalConfig({ certificateId: certificateData.certificateId, renewBeforeDays: data.renewBeforeDays, @@ -133,8 +181,6 @@ export const CertificateManageRenewalModal = ({ popUp, handlePopUpToggle }: Prop } }; - const isLoading = isUpdatingConfig; - const getModalTitle = () => { if (hasRenewalError) { return `Fix Auto-Renewal: ${certificateData?.commonName || ""}`; @@ -145,6 +191,10 @@ export const CertificateManageRenewalModal = ({ popUp, handlePopUpToggle }: Prop return `Enable Auto-Renewal for ${certificateData?.commonName || ""}`; }; + if (!certificateData) { + return null; + } + return ( - {/* Show renewal error if present */} {hasRenewalError && (
@@ -173,100 +222,26 @@ export const CertificateManageRenewalModal = ({ popUp, handlePopUpToggle }: Prop
)} - {/* Configuration form - shown for all cases except when enabled and no error */} {(!isAutoRenewalEnabled || hasRenewalError) && ( -
- - ( - { - const value = parseInt(e.target.value, 10); - field.onChange(value); - }} - placeholder="Enter days before expiration" - /> - )} - /> - - -
- - -
-
+ handlePopUpToggle("manageRenewal", false)} + /> )} - {/* Show edit form for enabled auto-renewal without errors */} {isAutoRenewalEnabled && !hasRenewalError && ( -
- - ( - { - const value = parseInt(e.target.value, 10); - field.onChange(value); - }} - placeholder="Enter days before expiration" - /> - )} - /> - - -
- - -
-
+ handlePopUpToggle("manageRenewal", false)} + /> )} diff --git a/frontend/src/pages/cert-manager/CertificatesPage/components/CertificateRenewalConfigModal.tsx b/frontend/src/pages/cert-manager/CertificatesPage/components/CertificateRenewalConfigModal.tsx index 1c195dd22..c952f1e54 100644 --- a/frontend/src/pages/cert-manager/CertificatesPage/components/CertificateRenewalConfigModal.tsx +++ b/frontend/src/pages/cert-manager/CertificatesPage/components/CertificateRenewalConfigModal.tsx @@ -8,14 +8,21 @@ import { useProject } from "@app/context"; import { useUpdateRenewalConfig } from "@app/hooks/api"; import { UsePopUpState } from "@app/hooks/usePopUp"; -const formSchema = z.object({ - renewBeforeDays: z - .number() - .min(1, "Renewal days must be at least 1") - .max(365, "Renewal days cannot exceed 365") -}); +const createFormSchema = (ttlDays: number) => + z.object({ + renewBeforeDays: z + .number() + .min(1, "Renewal days must be at least 1") + .max(365, "Renewal days cannot exceed 365") + .refine( + (value) => value < ttlDays, + (value) => ({ + message: `Renewal days (${value}) must be less than certificate TTL (${ttlDays} days)` + }) + ) + }); -type FormData = z.infer; +type FormData = z.infer>; type Props = { popUp: UsePopUpState<["configureRenewal"]>; @@ -37,6 +44,8 @@ export const CertificateRenewalConfigModal = ({ popUp, handlePopUpToggle }: Prop ttlDays: number; }; + const formSchema = createFormSchema(certificateData.ttlDays); + const { control, handleSubmit, @@ -45,7 +54,7 @@ export const CertificateRenewalConfigModal = ({ popUp, handlePopUpToggle }: Prop } = useForm({ resolver: zodResolver(formSchema), defaultValues: { - renewBeforeDays: certificateData?.renewBeforeDays || 7 + renewBeforeDays: certificateData?.renewBeforeDays || 1 } }); @@ -53,14 +62,6 @@ export const CertificateRenewalConfigModal = ({ popUp, handlePopUpToggle }: Prop const onSubmit = async (data: FormData) => { try { - if (data.renewBeforeDays >= certificateData.ttlDays) { - createNotification({ - text: `Renewal days (${data.renewBeforeDays}) must be less than certificate TTL (${certificateData.ttlDays} days)`, - type: "error" - }); - return; - } - if (!currentProject?.slug) { createNotification({ text: "Project not found", @@ -144,7 +145,7 @@ export const CertificateRenewalConfigModal = ({ popUp, handlePopUpToggle }: Prop {renewBeforeDays && certificateData?.ttlDays && (

- {renewBeforeDays >= (certificateData.ttlDays || 0) + {renewBeforeDays >= certificateData.ttlDays ? "⚠️ Renewal days must be less than certificate TTL" : `✓ Certificate will be renewed ${renewBeforeDays} days before expiration`}

diff --git a/frontend/src/pages/cert-manager/CertificatesPage/components/CertificatesTable.tsx b/frontend/src/pages/cert-manager/CertificatesPage/components/CertificatesTable.tsx index 4ccc662d6..72c047bec 100644 --- a/frontend/src/pages/cert-manager/CertificatesPage/components/CertificatesTable.tsx +++ b/frontend/src/pages/cert-manager/CertificatesPage/components/CertificatesTable.tsx @@ -91,10 +91,14 @@ const getAutoRenewalInfo = (certificate: TCertificate) => { return { text: "Due Now", variant: "danger" as const }; } - const daysUntilRenewal = Math.ceil( + const daysUntilRenewal = Math.floor( (renewalDate.getTime() - now.getTime()) / (24 * 60 * 60 * 1000) ); + if (daysUntilRenewal === 0) { + return { text: "Renews today", variant: "primary" as const }; + } + if (daysUntilRenewal <= 7) { return { text: `Renews in ${daysUntilRenewal}d`, variant: "primary" as const }; } @@ -204,6 +208,14 @@ export const CertificatesTable = ({ handlePopUpOpen }: Props) => { data?.certificates.map((certificate) => { const { variant, label } = getCertValidUntilBadgeDetails(certificate.notAfter); const autoRenewalInfo = getAutoRenewalInfo(certificate); + + const isRevoked = certificate.status === CertStatus.REVOKED; + const isExpired = new Date(certificate.notAfter) < new Date(); + const isExpiringWithinDay = isExpiringWithinOneDay(certificate.notAfter); + const hasFailed = Boolean(certificate.renewalError); + const isAutoRenewalEnabled = Boolean( + certificate.renewBeforeDays && certificate.renewBeforeDays > 0 + ); return ( {certificate.commonName} @@ -297,10 +309,6 @@ export const CertificatesTable = ({ handlePopUpOpen }: Props) => { {/* Manage auto renewal option - not shown for failed renewals */} {(() => { - const isRevoked = certificate.status === CertStatus.REVOKED; - const isExpired = new Date(certificate.notAfter) < new Date(); - const hasFailed = Boolean(certificate.renewalError); - const isExpiringWithinDay = isExpiringWithinOneDay(certificate.notAfter); const canManageRenewal = certificate.profileId && !certificate.renewedById && @@ -317,10 +325,6 @@ export const CertificatesTable = ({ handlePopUpOpen }: Props) => { a={ProjectPermissionSub.Certificates} > {(isAllowed) => { - const isAutoRenewalEnabled = Boolean( - certificate.renewBeforeDays && certificate.renewBeforeDays > 0 - ); - return ( { )} onClick={async () => { const notAfterDate = new Date(certificate.notAfter); - const notBeforeDate = new Date(certificate.notBefore); - const ttlDays = Math.ceil( - (notAfterDate.getTime() - notBeforeDate.getTime()) / - (24 * 60 * 60 * 1000) + const notBeforeDate = certificate.notBefore + ? new Date(certificate.notBefore) + : new Date( + notAfterDate.getTime() - 365 * 24 * 60 * 60 * 1000 + ); + const ttlDays = Math.max( + 1, + Math.ceil( + (notAfterDate.getTime() - notBeforeDate.getTime()) / + (24 * 60 * 60 * 1000) + ) ); handlePopUpOpen("manageRenewal", { certificateId: certificate.id, @@ -360,12 +371,6 @@ export const CertificatesTable = ({ handlePopUpOpen }: Props) => { })()} {/* Disable auto renewal option - only shown when auto renewal is active */} {(() => { - const isRevoked = certificate.status === CertStatus.REVOKED; - const isExpired = new Date(certificate.notAfter) < new Date(); - const isExpiringWithinDay = isExpiringWithinOneDay(certificate.notAfter); - const isAutoRenewalEnabled = Boolean( - certificate.renewBeforeDays && certificate.renewBeforeDays > 0 - ); const canDisableRenewal = certificate.profileId && !certificate.renewedById && @@ -404,8 +409,6 @@ export const CertificatesTable = ({ handlePopUpOpen }: Props) => { })()} {/* Manual renewal action for profile-issued certificates that are not revoked/expired (including failed ones) */} {(() => { - const isRevoked = certificate.status === CertStatus.REVOKED; - const isExpired = new Date(certificate.notAfter) < new Date(); const canRenew = certificate.profileId && !certificate.renewedById &&