From 9d09990143bc3f707a7dcef65df23e525c02bb5b Mon Sep 17 00:00:00 2001 From: Carlos Monastyrski Date: Thu, 23 Oct 2025 00:38:24 -0300 Subject: [PATCH 1/6] PKI: add support for auto-renewal option on API enrollment type --- ...1021112356_add-certificate-auto-renewal.ts | 53 ++ backend/src/db/schemas/certificates.ts | 8 +- .../db/schemas/pki-api-enrollment-configs.ts | 2 +- .../ee/services/audit-log/audit-log-types.ts | 57 +- backend/src/queue/queue-service.ts | 10 +- backend/src/server/routes/index.ts | 12 + .../routes/v1/certificate-profiles-router.ts | 8 +- .../server/routes/v3/certificates-router.ts | 134 ++++ .../internal-certificate-authority-service.ts | 37 +- .../internal-certificate-authority-types.ts | 1 + .../certificate-constants.ts | 8 + .../certificate-profile-dal.ts | 10 +- .../certificate-profile-schemas.ts | 4 +- .../certificate-profile-service.test.ts | 12 +- .../certificate-profile-service.ts | 4 +- .../certificate-profile-types.ts | 4 +- .../certificate-template-v2-service.ts | 30 +- .../certificate-v3/certificate-v3-queue.ts | 254 ++++++++ .../certificate-v3-service.test.ts | 600 +++++++++++++++++- .../certificate-v3/certificate-v3-service.ts | 504 ++++++++++++++- .../certificate-v3/certificate-v3-types.ts | 22 + .../api-enrollment-config-dal.ts | 8 +- .../enrollment-config-types.ts | 2 +- .../hooks/api/certificateProfiles/types.ts | 6 +- frontend/src/hooks/api/certificates/index.tsx | 8 +- .../src/hooks/api/certificates/mutations.tsx | 59 +- frontend/src/hooks/api/certificates/types.ts | 26 + .../CertificateManageRenewalModal.tsx | 274 ++++++++ .../CertificateRenewalConfigModal.tsx | 177 ++++++ .../CertificateRenewalDisableModal.tsx | 94 +++ .../components/CertificateRenewalModal.tsx | 80 +++ .../components/CertificatesSection.tsx | 8 +- .../components/CertificatesTable.tsx | 279 +++++++- .../CreateProfileModal.tsx | 38 +- 34 files changed, 2739 insertions(+), 94 deletions(-) create mode 100644 backend/src/db/migrations/20251021112356_add-certificate-auto-renewal.ts create mode 100644 backend/src/services/certificate-v3/certificate-v3-queue.ts create mode 100644 frontend/src/pages/cert-manager/CertificatesPage/components/CertificateManageRenewalModal.tsx create mode 100644 frontend/src/pages/cert-manager/CertificatesPage/components/CertificateRenewalConfigModal.tsx create mode 100644 frontend/src/pages/cert-manager/CertificatesPage/components/CertificateRenewalDisableModal.tsx create mode 100644 frontend/src/pages/cert-manager/CertificatesPage/components/CertificateRenewalModal.tsx diff --git a/backend/src/db/migrations/20251021112356_add-certificate-auto-renewal.ts b/backend/src/db/migrations/20251021112356_add-certificate-auto-renewal.ts new file mode 100644 index 000000000..e60e8458f --- /dev/null +++ b/backend/src/db/migrations/20251021112356_add-certificate-auto-renewal.ts @@ -0,0 +1,53 @@ +import { Knex } from "knex"; + +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"); + }); + } + + 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.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.index("renewBeforeDays"); + }); + } +} + +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.dropIndex("renewBeforeDays"); + t.dropColumn("renewBeforeDays"); + t.dropColumn("renewedFromId"); + t.dropColumn("renewedById"); + t.dropColumn("renewalError"); + t.dropColumn("keyAlgorithm"); + t.dropColumn("signatureAlgorithm"); + }); + } + + if (await knex.schema.hasColumn(TableName.PkiApiEnrollmentConfig, "renewBeforeDays")) { + await knex.schema.alterTable(TableName.PkiApiEnrollmentConfig, (t) => { + t.dropColumn("renewBeforeDays"); + t.integer("autoRenewDays"); + }); + } +} diff --git a/backend/src/db/schemas/certificates.ts b/backend/src/db/schemas/certificates.ts index 63122f662..8c3a5b51f 100644 --- a/backend/src/db/schemas/certificates.ts +++ b/backend/src/db/schemas/certificates.ts @@ -27,7 +27,13 @@ export const CertificatesSchema = z.object({ extendedKeyUsages: z.string().array().nullable().optional(), projectId: z.string(), pkiSubscriberId: z.string().uuid().nullable().optional(), - profileId: 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(), + renewalError: z.string().nullable().optional(), + keyAlgorithm: z.string().nullable().optional(), + signatureAlgorithm: z.string().nullable().optional() }); export type TCertificates = z.infer; diff --git a/backend/src/db/schemas/pki-api-enrollment-configs.ts b/backend/src/db/schemas/pki-api-enrollment-configs.ts index 710b0dee4..7a1beccdb 100644 --- a/backend/src/db/schemas/pki-api-enrollment-configs.ts +++ b/backend/src/db/schemas/pki-api-enrollment-configs.ts @@ -10,7 +10,7 @@ import { TImmutableDBKeys } from "./models"; export const PkiApiEnrollmentConfigsSchema = z.object({ id: z.string().uuid(), autoRenew: z.boolean().default(false).nullable().optional(), - autoRenewDays: z.number().nullable().optional(), + renewBeforeDays: z.number().nullable().optional(), createdAt: z.date(), updatedAt: z.date() }); 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 f3c95e434..40e574018 100644 --- a/backend/src/ee/services/audit-log/audit-log-types.ts +++ b/backend/src/ee/services/audit-log/audit-log-types.ts @@ -337,6 +337,8 @@ export enum EventType { ISSUE_PKI_SUBSCRIBER_CERT = "issue-pki-subscriber-cert", SIGN_PKI_SUBSCRIBER_CERT = "sign-pki-subscriber-cert", AUTOMATED_RENEW_SUBSCRIBER_CERT = "automated-renew-subscriber-cert", + AUTOMATED_RENEW_CERTIFICATE = "automated-renew-certificate", + AUTOMATED_RENEW_CERTIFICATE_FAILED = "automated-renew-certificate-failed", LIST_PKI_SUBSCRIBER_CERTS = "list-pki-subscriber-certs", GET_SUBSCRIBER_ACTIVE_CERT_BUNDLE = "get-subscriber-active-cert-bundle", CREATE_KMS = "create-kms", @@ -364,6 +366,9 @@ export enum EventType { ISSUE_CERTIFICATE_FROM_PROFILE = "issue-certificate-from-profile", SIGN_CERTIFICATE_FROM_PROFILE = "sign-certificate-from-profile", ORDER_CERTIFICATE_FROM_PROFILE = "order-certificate-from-profile", + RENEW_CERTIFICATE = "renew-certificate", + UPDATE_CERTIFICATE_RENEWAL_CONFIG = "update-certificate-renewal-config", + DISABLE_CERTIFICATE_RENEWAL_CONFIG = "disable-certificate-renewal-config", ATTEMPT_CREATE_SLACK_INTEGRATION = "attempt-create-slack-integration", ATTEMPT_REINSTALL_SLACK_INTEGRATION = "attempt-reinstall-slack-integration", GET_PROJECT_SLACK_CONFIG = "get-project-slack-config", @@ -2437,6 +2442,27 @@ interface AutomatedRenewPkiSubscriberCert { }; } +interface AutomatedRenewCertificate { + type: EventType.AUTOMATED_RENEW_CERTIFICATE; + metadata: { + certificateId: string; + commonName: string; + profileId: string; + renewBeforeDays: string; + }; +} + +interface AutomatedRenewCertificateFailed { + type: EventType.AUTOMATED_RENEW_CERTIFICATE_FAILED; + metadata: { + certificateId: string; + commonName: string; + profileId: string; + renewBeforeDays: string; + error: string; + }; +} + interface SignPkiSubscriberCert { type: EventType.SIGN_PKI_SUBSCRIBER_CERT; metadata: { @@ -2699,6 +2725,15 @@ interface OrderCertificateFromProfile { }; } +interface RenewCertificate { + type: EventType.RENEW_CERTIFICATE; + metadata: { + originalCertificateId: string; + newCertificateId: string; + profileName: string; + }; +} + interface AttemptCreateSlackIntegration { type: EventType.ATTEMPT_CREATE_SLACK_INTEGRATION; metadata: { @@ -3963,6 +3998,21 @@ interface PamResourceDeleteEvent { }; } +interface UpdateCertificateRenewalConfigEvent { + type: EventType.UPDATE_CERTIFICATE_RENEWAL_CONFIG; + metadata: { + certificateId: string; + renewBeforeDays: string; + }; +} + +interface DisableCertificateRenewalConfigEvent { + type: EventType.DISABLE_CERTIFICATE_RENEWAL_CONFIG; + metadata: { + certificateId: string; + }; +} + export type Event = | GetSecretsEvent | GetSecretEvent @@ -4168,6 +4218,7 @@ export type Event = | IssueCertificateFromProfile | SignCertificateFromProfile | OrderCertificateFromProfile + | RenewCertificate | GetAzureAdCsTemplatesEvent | AttemptCreateSlackIntegration | AttemptReinstallSlackIntegration @@ -4323,4 +4374,8 @@ export type Event = | PamResourceGetEvent | PamResourceCreateEvent | PamResourceUpdateEvent - | PamResourceDeleteEvent; + | PamResourceDeleteEvent + | UpdateCertificateRenewalConfigEvent + | DisableCertificateRenewalConfigEvent + | AutomatedRenewCertificate + | AutomatedRenewCertificateFailed; diff --git a/backend/src/queue/queue-service.ts b/backend/src/queue/queue-service.ts index 7f45e3821..9f5766e2a 100644 --- a/backend/src/queue/queue-service.ts +++ b/backend/src/queue/queue-service.ts @@ -77,7 +77,8 @@ export enum QueueName { DailyReminders = "daily-reminders", SecretReminderMigration = "secret-reminder-migration", UserNotification = "user-notification", - HealthAlert = "health-alert" + HealthAlert = "health-alert", + CertificateV3AutoRenewal = "certificate-v3-auto-renewal" } export enum QueueJobs { @@ -126,7 +127,8 @@ export enum QueueJobs { DailyReminders = "daily-reminders", SecretReminderMigration = "secret-reminder-migration", UserNotification = "user-notification-job", - HealthAlert = "health-alert" + HealthAlert = "health-alert", + CertificateV3DailyAutoRenewal = "certificate-v3-daily-auto-renewal" } export type TQueueJobTypes = { @@ -357,6 +359,10 @@ export type TQueueJobTypes = { name: QueueJobs.HealthAlert; payload: undefined; }; + [QueueName.CertificateV3AutoRenewal]: { + name: QueueJobs.CertificateV3DailyAutoRenewal; + payload: undefined; + }; }; const SECRET_SCANNING_JOBS = [ diff --git a/backend/src/server/routes/index.ts b/backend/src/server/routes/index.ts index a805fa1be..a6e85e176 100644 --- a/backend/src/server/routes/index.ts +++ b/backend/src/server/routes/index.ts @@ -175,6 +175,7 @@ import { certificateTemplateEstConfigDALFactory } from "@app/services/certificat import { certificateTemplateServiceFactory } from "@app/services/certificate-template/certificate-template-service"; import { certificateTemplateV2DALFactory } from "@app/services/certificate-template-v2/certificate-template-v2-dal"; import { certificateTemplateV2ServiceFactory } from "@app/services/certificate-template-v2/certificate-template-v2-service"; +import { certificateV3QueueServiceFactory } from "@app/services/certificate-v3/certificate-v3-queue"; import { certificateV3ServiceFactory } from "@app/services/certificate-v3/certificate-v3-service"; import { cmekServiceFactory } from "@app/services/cmek/cmek-service"; import { convertorServiceFactory } from "@app/services/convertor/convertor-service"; @@ -2122,6 +2123,16 @@ export const registerRoutes = async ( permissionService }); + const certificateV3Queue = certificateV3QueueServiceFactory({ + queueService, + certificateDAL, + certificateAuthorityDAL, + certificateProfileDAL, + projectDAL, + certificateV3Service, + auditLogService + }); + const certificateEstV3Service = certificateEstV3ServiceFactory({ internalCertificateAuthorityService, certificateTemplateV2Service, @@ -2281,6 +2292,7 @@ export const registerRoutes = async ( await dailyReminderQueueService.startSecretReminderMigrationJob(); await dailyExpiringPkiItemAlert.startSendingAlerts(); await pkiSubscriberQueue.startDailyAutoRenewalJob(); + await certificateV3Queue.startDailyAutoRenewalJob(); await kmsService.startService(); await microsoftTeamsService.start(); await dynamicSecretQueueService.init(); diff --git a/backend/src/server/routes/v1/certificate-profiles-router.ts b/backend/src/server/routes/v1/certificate-profiles-router.ts index 2292c3ba8..af32bd6c7 100644 --- a/backend/src/server/routes/v1/certificate-profiles-router.ts +++ b/backend/src/server/routes/v1/certificate-profiles-router.ts @@ -42,7 +42,7 @@ export const registerCertificateProfilesRouter = async (server: FastifyZodProvid apiConfig: z .object({ autoRenew: z.boolean().default(false), - autoRenewDays: z.number().min(1).max(365).optional() + renewBeforeDays: z.number().min(1).max(30).optional() }) .optional() }) @@ -150,7 +150,7 @@ export const registerCertificateProfilesRouter = async (server: FastifyZodProvid .object({ id: z.string(), autoRenew: z.boolean(), - autoRenewDays: z.number().optional() + renewBeforeDays: z.number().optional() }) .optional() }).array(), @@ -230,7 +230,7 @@ export const registerCertificateProfilesRouter = async (server: FastifyZodProvid .object({ id: z.string(), autoRenew: z.boolean(), - autoRenewDays: z.number().optional() + renewBeforeDays: z.number().optional() }) .optional(), metrics: z @@ -355,7 +355,7 @@ export const registerCertificateProfilesRouter = async (server: FastifyZodProvid apiConfig: z .object({ autoRenew: z.boolean().default(false), - autoRenewDays: z.number().min(1).max(365).optional() + renewBeforeDays: z.number().min(1).max(30).optional() }) .optional() }) diff --git a/backend/src/server/routes/v3/certificates-router.ts b/backend/src/server/routes/v3/certificates-router.ts index 549310738..f4a94321c 100644 --- a/backend/src/server/routes/v3/certificates-router.ts +++ b/backend/src/server/routes/v3/certificates-router.ts @@ -343,4 +343,138 @@ export const registerCertificatesRouter = async (server: FastifyZodProvider) => return data; } }); + + server.route({ + method: "POST", + url: "/:certificateId/renew", + config: { + rateLimit: writeLimit + }, + schema: { + hide: false, + tags: [ApiDocsTags.PkiCertificates], + params: z.object({ + certificateId: z.string().uuid() + }), + response: { + 200: z.object({ + certificate: z.string().trim(), + issuingCaCertificate: z.string().trim(), + certificateChain: z.string().trim(), + privateKey: z.string().trim().optional(), + serialNumber: z.string().trim(), + certificateId: z.string() + }) + } + }, + onRequest: verifyAuth([AuthMode.JWT, AuthMode.IDENTITY_ACCESS_TOKEN]), + handler: async (req) => { + const data = await server.services.certificateV3.renewCertificate({ + actor: req.permission.type, + actorId: req.permission.id, + actorAuthMethod: req.permission.authMethod, + actorOrgId: req.permission.orgId, + certificateId: req.params.certificateId + }); + + await server.services.auditLog.createAuditLog({ + ...req.auditLogInfo, + projectId: data.projectId, + event: { + type: EventType.RENEW_CERTIFICATE, + metadata: { + originalCertificateId: req.params.certificateId, + newCertificateId: data.certificateId, + profileName: data.profileName + } + } + }); + + return data; + } + }); + + server.route({ + method: "PATCH", + url: "/:certificateId/config", + config: { + rateLimit: writeLimit + }, + schema: { + hide: false, + tags: [ApiDocsTags.PkiCertificates], + params: z.object({ + certificateId: z.string().uuid() + }), + body: z.object({ + renewBeforeDays: z.number().int().min(1).max(30).optional(), + disableAutoRenewal: z.boolean().optional() + }), + response: { + 200: z.object({ + message: z.string(), + renewBeforeDays: z.number().optional() + }) + } + }, + onRequest: verifyAuth([AuthMode.JWT, AuthMode.IDENTITY_ACCESS_TOKEN]), + handler: async (req) => { + if (req.body.disableAutoRenewal === true) { + const data = await server.services.certificateV3.disableRenewalConfig({ + actor: req.permission.type, + actorId: req.permission.id, + actorAuthMethod: req.permission.authMethod, + actorOrgId: req.permission.orgId, + certificateId: req.params.certificateId + }); + + await server.services.auditLog.createAuditLog({ + ...req.auditLogInfo, + projectId: data.projectId, + event: { + type: EventType.DISABLE_CERTIFICATE_RENEWAL_CONFIG, + metadata: { + certificateId: req.params.certificateId + } + } + }); + + return { + message: "Auto-renewal disabled successfully" + }; + } + + if (req.body.renewBeforeDays !== undefined) { + const data = await server.services.certificateV3.updateRenewalConfig({ + actor: req.permission.type, + actorId: req.permission.id, + actorAuthMethod: req.permission.authMethod, + actorOrgId: req.permission.orgId, + certificateId: req.params.certificateId, + renewBeforeDays: req.body.renewBeforeDays + }); + + await server.services.auditLog.createAuditLog({ + ...req.auditLogInfo, + projectId: data.projectId, + event: { + type: EventType.UPDATE_CERTIFICATE_RENEWAL_CONFIG, + metadata: { + certificateId: req.params.certificateId, + renewBeforeDays: req.body.renewBeforeDays.toString() + } + } + }); + + return { + message: "Certificate configuration updated successfully", + renewBeforeDays: data.renewBeforeDays + }; + } + + return { + message: "No configuration changes requested" + }; + } + }); }; 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 5b9cd78ee..0b12ff3dc 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 @@ -1180,7 +1180,8 @@ export const internalCertificateAuthorityServiceFactory = ({ extendedKeyUsages, signatureAlgorithm, keyAlgorithm, - isFromProfile + isFromProfile, + internal = false }: TIssueCertFromCaDTO) => { let ca: TCertificateAuthorityWithAssociatedCa | undefined; let certificateTemplate: TCertificateTemplates | undefined; @@ -1210,19 +1211,21 @@ export const internalCertificateAuthorityServiceFactory = ({ throw new NotFoundError({ message: `Internal CA with ID '${caId}' not found` }); } - const { permission } = await permissionService.getProjectPermission({ - actor, - actorId, - projectId: ca.projectId, - actorAuthMethod, - actorOrgId, - actionProjectType: ActionProjectType.CertificateManager - }); + if (!internal) { + const { permission } = await permissionService.getProjectPermission({ + actor, + actorId, + projectId: ca.projectId, + actorAuthMethod, + actorOrgId, + actionProjectType: ActionProjectType.CertificateManager + }); - ForbiddenError.from(permission).throwUnlessCan( - ProjectPermissionCertificateActions.Create, - ProjectPermissionSub.Certificates - ); + ForbiddenError.from(permission).throwUnlessCan( + ProjectPermissionCertificateActions.Create, + ProjectPermissionSub.Certificates + ); + } if (ca.status !== CaStatus.ACTIVE) throw new BadRequestError({ message: "CA is not active" }); if (!ca.internalCa.activeCaCertId) @@ -1488,7 +1491,9 @@ export const internalCertificateAuthorityServiceFactory = ({ notAfter: notAfterDate, keyUsages: selectedKeyUsages, extendedKeyUsages: selectedExtendedKeyUsages, - projectId: ca!.projectId + projectId: ca!.projectId, + keyAlgorithm: effectiveKeyAlgorithm, + signatureAlgorithm: signatureAlgorithm || ca!.internalCa!.keyAlgorithm }, tx ); @@ -1917,7 +1922,9 @@ export const internalCertificateAuthorityServiceFactory = ({ notAfter: notAfterDate, keyUsages: selectedKeyUsages, extendedKeyUsages: selectedExtendedKeyUsages, - projectId: ca!.projectId + projectId: ca!.projectId, + keyAlgorithm: keyAlgorithm || ca!.internalCa!.keyAlgorithm, + signatureAlgorithm: signatureAlgorithm || ca!.internalCa!.keyAlgorithm }, tx ); 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 22cb86d28..ca0d99be7 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 @@ -139,6 +139,7 @@ export type TIssueCertFromCaDTO = { signatureAlgorithm?: CertSignatureAlgorithm; keyAlgorithm?: CertKeyAlgorithm; isFromProfile?: boolean; + internal?: boolean; } & Omit; export type TSignCertFromCaDTO = diff --git a/backend/src/services/certificate-common/certificate-constants.ts b/backend/src/services/certificate-common/certificate-constants.ts index bbd589110..6500b0fab 100644 --- a/backend/src/services/certificate-common/certificate-constants.ts +++ b/backend/src/services/certificate-common/certificate-constants.ts @@ -175,6 +175,14 @@ export enum CertSignatureAlgorithm { ECDSA_SHA512 = "ECDSA-SHA512" } +export const CERTIFICATE_RENEWAL_CONFIG = { + MIN_RENEW_BEFORE_DAYS: 1, + MAX_RENEW_BEFORE_DAYS: 30, + QUEUE_BATCH_SIZE: 100, + DAILY_CRON_SCHEDULE: "0 0 * * *", + QUEUE_START_DELAY_MS: 5000 +} as const; + export const SAN_TYPE_OPTIONS = Object.values(CertSubjectAlternativeNameType); export const KEY_USAGE_OPTIONS = Object.values(CertKeyUsageType); export const EXTENDED_KEY_USAGE_OPTIONS = Object.values(CertExtendedKeyUsageType); diff --git a/backend/src/services/certificate-profile/certificate-profile-dal.ts b/backend/src/services/certificate-profile/certificate-profile-dal.ts index 20cb9f3bc..1ffa3e295 100644 --- a/backend/src/services/certificate-profile/certificate-profile-dal.ts +++ b/backend/src/services/certificate-profile/certificate-profile-dal.ts @@ -109,7 +109,7 @@ export const certificateProfileDALFactory = (db: TDbClient) => { db.ref("encryptedCaChain").withSchema(TableName.PkiEstEnrollmentConfig).as("estConfigEncryptedCaChain"), db.ref("id").withSchema(TableName.PkiApiEnrollmentConfig).as("apiConfigId"), db.ref("autoRenew").withSchema(TableName.PkiApiEnrollmentConfig).as("apiConfigAutoRenew"), - db.ref("autoRenewDays").withSchema(TableName.PkiApiEnrollmentConfig).as("apiConfigAutoRenewDays") + db.ref("renewBeforeDays").withSchema(TableName.PkiApiEnrollmentConfig).as("apiConfigRenewBeforeDays") ) .where(`${TableName.PkiCertificateProfile}.id`, id) .first(); @@ -132,7 +132,7 @@ export const certificateProfileDALFactory = (db: TDbClient) => { ? ({ id: result.apiConfigId, autoRenew: !!result.apiConfigAutoRenew, - autoRenewDays: result.apiConfigAutoRenewDays || undefined + renewBeforeDays: result.apiConfigRenewBeforeDays || undefined } as TCertificateProfileWithConfigs["apiConfig"]) : undefined; @@ -264,7 +264,7 @@ export const certificateProfileDALFactory = (db: TDbClient) => { db.ref("encryptedCaChain").withSchema(TableName.PkiEstEnrollmentConfig).as("estEncryptedCaChain"), db.ref("id").withSchema(TableName.PkiApiEnrollmentConfig).as("apiId"), db.ref("autoRenew").withSchema(TableName.PkiApiEnrollmentConfig).as("apiAutoRenew"), - db.ref("autoRenewDays").withSchema(TableName.PkiApiEnrollmentConfig).as("apiAutoRenewDays") + db.ref("renewBeforeDays").withSchema(TableName.PkiApiEnrollmentConfig).as("apiRenewBeforeDays") ); if (includeMetrics) { @@ -290,7 +290,7 @@ export const certificateProfileDALFactory = (db: TDbClient) => { db.ref("encryptedCaChain").withSchema(TableName.PkiEstEnrollmentConfig).as("estEncryptedCaChain"), db.ref("id").withSchema(TableName.PkiApiEnrollmentConfig).as("apiId"), db.ref("autoRenew").withSchema(TableName.PkiApiEnrollmentConfig).as("apiAutoRenew"), - db.ref("autoRenewDays").withSchema(TableName.PkiApiEnrollmentConfig).as("apiAutoRenewDays"), + db.ref("renewBeforeDays").withSchema(TableName.PkiApiEnrollmentConfig).as("apiRenewBeforeDays"), db.raw("COUNT(certificates.id) as total_certificates"), db.raw( 'COUNT(CASE WHEN certificates."revokedAt" IS NULL AND certificates."notAfter" > ? THEN 1 END) as active_certificates', @@ -333,7 +333,7 @@ export const certificateProfileDALFactory = (db: TDbClient) => { ? { id: result.apiId as string, autoRenew: !!result.apiAutoRenew, - autoRenewDays: (result.apiAutoRenewDays as number) || undefined + renewBeforeDays: (result.apiRenewBeforeDays as number) || undefined } : undefined; diff --git a/backend/src/services/certificate-profile/certificate-profile-schemas.ts b/backend/src/services/certificate-profile/certificate-profile-schemas.ts index 7b3e2cc57..a2c391c2a 100644 --- a/backend/src/services/certificate-profile/certificate-profile-schemas.ts +++ b/backend/src/services/certificate-profile/certificate-profile-schemas.ts @@ -25,7 +25,7 @@ export const createCertificateProfileSchema = z apiConfig: z .object({ autoRenew: z.boolean().default(false), - autoRenewDays: z.number().min(1).max(365).optional() + renewBeforeDays: z.number().min(1).max(30).optional() }) .optional() }) @@ -75,7 +75,7 @@ export const updateCertificateProfileSchema = z apiConfig: z .object({ autoRenew: z.boolean().default(false), - autoRenewDays: z.number().min(1).max(365).optional() + renewBeforeDays: z.number().min(1).max(30).optional() }) .optional() }) diff --git a/backend/src/services/certificate-profile/certificate-profile-service.test.ts b/backend/src/services/certificate-profile/certificate-profile-service.test.ts index dd2d7d2d6..26b1e976a 100644 --- a/backend/src/services/certificate-profile/certificate-profile-service.test.ts +++ b/backend/src/services/certificate-profile/certificate-profile-service.test.ts @@ -110,7 +110,7 @@ describe("CertificateProfileService", () => { apiConfig: { id: "api-config-123", autoRenew: true, - autoRenewDays: 30 + renewBeforeDays: 30 } }; @@ -202,7 +202,7 @@ describe("CertificateProfileService", () => { certificateTemplateId: "template-123", apiConfig: { autoRenew: true, - autoRenewDays: 30 + renewBeforeDays: 30 } }; @@ -323,7 +323,7 @@ describe("CertificateProfileService", () => { certificateTemplateId: "template-123", apiConfig: { autoRenew: true, - autoRenewDays: 30 + renewBeforeDays: 30 } }; @@ -761,7 +761,7 @@ describe("CertificateProfileService", () => { certificateTemplateId: "template-123", apiConfig: { autoRenew: true, - autoRenewDays: 30 + renewBeforeDays: 30 } }; @@ -786,7 +786,7 @@ describe("CertificateProfileService", () => { certificateTemplateId: "template-123", apiConfig: { autoRenew: true, - autoRenewDays: 7 + renewBeforeDays: 7 } }; @@ -808,7 +808,7 @@ describe("CertificateProfileService", () => { expect(mockApiEnrollmentConfigDAL.create).toHaveBeenCalledWith( { autoRenew: true, - autoRenewDays: 7 + renewBeforeDays: 7 }, undefined ); diff --git a/backend/src/services/certificate-profile/certificate-profile-service.ts b/backend/src/services/certificate-profile/certificate-profile-service.ts index c43dee889..7b48af8f1 100644 --- a/backend/src/services/certificate-profile/certificate-profile-service.ts +++ b/backend/src/services/certificate-profile/certificate-profile-service.ts @@ -225,7 +225,7 @@ export const certificateProfileServiceFactory = ({ const apiConfig = await apiEnrollmentConfigDAL.create( { autoRenew: data.apiConfig.autoRenew, - autoRenewDays: data.apiConfig.autoRenewDays + renewBeforeDays: data.apiConfig.renewBeforeDays }, tx ); @@ -343,7 +343,7 @@ export const certificateProfileServiceFactory = ({ existingProfile.apiConfigId, { autoRenew: apiConfig.autoRenew, - autoRenewDays: apiConfig.autoRenewDays + renewBeforeDays: apiConfig.renewBeforeDays }, tx ); diff --git a/backend/src/services/certificate-profile/certificate-profile-types.ts b/backend/src/services/certificate-profile/certificate-profile-types.ts index a6d53a0f3..1c22a5e75 100644 --- a/backend/src/services/certificate-profile/certificate-profile-types.ts +++ b/backend/src/services/certificate-profile/certificate-profile-types.ts @@ -26,7 +26,7 @@ export type TCertificateProfileUpdate = Omit => { const template = await certificateTemplateV2DAL.findById(templateId); if (!template) { throw new NotFoundError({ message: "Certificate template not found" }); } - const { permission } = await permissionService.getProjectPermission({ - actor, - actorId, - projectId: template.projectId, - actorAuthMethod, - actorOrgId, - actionProjectType: ActionProjectType.CertificateManager - }); + if (!internal) { + const { permission } = await permissionService.getProjectPermission({ + actor, + actorId, + projectId: template.projectId, + actorAuthMethod, + actorOrgId, + actionProjectType: ActionProjectType.CertificateManager + }); - ForbiddenError.from(permission).throwUnlessCan( - ProjectPermissionPkiTemplateActions.Read, - ProjectPermissionSub.CertificateTemplates - ); + ForbiddenError.from(permission).throwUnlessCan( + ProjectPermissionPkiTemplateActions.Read, + ProjectPermissionSub.CertificateTemplates + ); + } return template; }; diff --git a/backend/src/services/certificate-v3/certificate-v3-queue.ts b/backend/src/services/certificate-v3/certificate-v3-queue.ts new file mode 100644 index 000000000..f756f4e26 --- /dev/null +++ b/backend/src/services/certificate-v3/certificate-v3-queue.ts @@ -0,0 +1,254 @@ +/* eslint-disable no-await-in-loop */ +import { EventType, TAuditLogServiceFactory } from "@app/ee/services/audit-log/audit-log-types"; +import { logger } from "@app/lib/logger"; +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 { TCertificateV3ServiceFactory } from "./certificate-v3-service"; + +type TCertificateV3QueueServiceFactoryDep = { + queueService: TQueueServiceFactory; + certificateDAL: TCertificateDALFactory; + certificateAuthorityDAL: Pick; + certificateProfileDAL: Pick; + projectDAL: Pick; + certificateV3Service: TCertificateV3ServiceFactory; + auditLogService: Pick; +}; + +export const certificateV3QueueServiceFactory = ({ + queueService, + certificateDAL, + certificateAuthorityDAL, + certificateProfileDAL, + projectDAL, + certificateV3Service, + auditLogService +}: TCertificateV3QueueServiceFactoryDep) => { + queueService.start(QueueName.CertificateV3AutoRenewal, async (job) => { + if (job.name === QueueJobs.CertificateV3DailyAutoRenewal) { + logger.info(`${QueueJobs.CertificateV3DailyAutoRenewal}: queue task started`); + + const { QUEUE_BATCH_SIZE } = CERTIFICATE_RENEWAL_CONFIG; + let offset = 0; + let hasMore = true; + + while (hasMore) { + const certificates = await certificateDAL.find( + { + $notNull: ["profileId"], + status: CertStatus.ACTIVE, + renewedById: null, + renewalError: null, + revokedAt: null + }, + { + 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; + } + + 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") + ) { + 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}`); + } + } + }) + ); + + offset += QUEUE_BATCH_SIZE; + } + + logger.info(`${QueueJobs.CertificateV3DailyAutoRenewal}: queue task completed`); + } + }); + + const startDailyAutoRenewalJob = async () => { + const { DAILY_CRON_SCHEDULE, QUEUE_START_DELAY_MS } = CERTIFICATE_RENEWAL_CONFIG; + + await queueService.stopRepeatableJob( + QueueName.CertificateV3AutoRenewal, + QueueJobs.CertificateV3DailyAutoRenewal, + { pattern: DAILY_CRON_SCHEDULE, utc: true }, + QueueName.CertificateV3AutoRenewal + ); + + await queueService.queue(QueueName.CertificateV3AutoRenewal, QueueJobs.CertificateV3DailyAutoRenewal, undefined, { + delay: QUEUE_START_DELAY_MS, + jobId: QueueName.CertificateV3AutoRenewal, + repeat: { pattern: DAILY_CRON_SCHEDULE, utc: true } + }); + }; + + queueService.listen(QueueName.CertificateV3AutoRenewal, "failed", (_, err) => { + logger.error(err, `${QueueName.CertificateV3AutoRenewal}: failed`); + }); + + return { + startDailyAutoRenewalJob + }; +}; + +export type TCertificateV3QueueFactory = ReturnType; 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 95f9a5077..a4f0f0818 100644 --- a/backend/src/services/certificate-v3/certificate-v3-service.test.ts +++ b/backend/src/services/certificate-v3/certificate-v3-service.test.ts @@ -7,10 +7,11 @@ import { ForbiddenError } from "@casl/ability"; import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; import { TPermissionServiceFactory } from "@app/ee/services/permission/permission-service-types"; -import { ForbiddenRequestError, NotFoundError } from "@app/lib/errors"; +import { BadRequestError, ForbiddenRequestError, NotFoundError } from "@app/lib/errors"; import { TCertificateDALFactory } from "@app/services/certificate/certificate-dal"; -import { ACMESANType, CertificateOrderStatus } from "@app/services/certificate/certificate-types"; +import { ACMESANType, CertificateOrderStatus, CertStatus } from "@app/services/certificate/certificate-types"; import { TCertificateAuthorityDALFactory } from "@app/services/certificate-authority/certificate-authority-dal"; +import { CaStatus } from "@app/services/certificate-authority/certificate-authority-enums"; import { TInternalCertificateAuthorityServiceFactory } from "@app/services/certificate-authority/internal/internal-certificate-authority-service"; import { CertExtendedKeyUsageType, @@ -28,8 +29,9 @@ 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() }; @@ -1460,4 +1462,596 @@ describe("CertificateV3Service", () => { ).resolves.toBeDefined(); }); }); + + describe("renewCertificate", () => { + const mockOriginalCert = { + id: "cert-123", + status: CertStatus.ACTIVE, + serialNumber: "123456", + friendlyName: "Test Certificate", + commonName: "test.example.com", + notBefore: new Date("2024-01-01"), + notAfter: new Date("2024-02-01"), // 31 days + revokedAt: null, + renewedById: null, + profileId: "profile-123", + renewBeforeDays: 7, + caId: "ca-123", + pkiSubscriberId: null, + keyUsages: ["digital_signature", "key_agreement"], + extendedKeyUsages: ["server_auth"], + altNames: "test.example.com,api.example.com", + projectId: "project-123", + createdAt: new Date(), + updatedAt: new Date(), + certificateTemplateId: "template-123", + revocationReason: null, + caCertId: null, + renewedFromId: null, + renewalError: null, + keyAlgorithm: "RSA_2048", + signatureAlgorithm: "RSA-SHA256" + }; + + const mockProfile = { + id: "profile-123", + projectId: "project-123", + enrollmentType: EnrollmentType.API, + caId: "ca-123", + certificateTemplateId: "template-123", + apiConfig: { + id: "api-config-123", + autoRenew: true, + renewBeforeDays: 14 + }, + createdAt: new Date(), + updatedAt: new Date(), + slug: "test-profile", + description: "Test profile" + }; + + const mockCA = { + id: "ca-123", + projectId: "project-123", + status: CaStatus.ACTIVE, + createdAt: new Date(), + updatedAt: new Date(), + enableDirectIssuance: true, + name: "Test CA", + requireTemplateForIssuance: false, + externalCa: undefined, + parentCaId: null, + type: "ROOT", + friendlyName: "Test CA", + organization: "Test Org", + ou: "Test OU", + country: "US", + province: "CA", + locality: "SF", + commonName: "Test CA", + keyAlgorithm: "RSA_2048", + notAfter: "2025-01-01T00:00:00Z", + notBefore: "2024-01-01T00:00:00Z", + maxPathLength: -1, + activeCaCertId: "cert-123", + dn: "CN=Test CA,O=Test Org,OU=Test OU,C=US", + serialNumber: "123456789", + internalCa: { + id: "internal-ca-123", + parentCaId: null, + type: "ROOT", + friendlyName: "Test CA", + organization: "Test Org", + ou: "Test OU", + country: "US", + province: "CA", + locality: "SF", + commonName: "Test CA", + keyAlgorithm: "RSA_2048", + notAfter: "2025-01-01T00:00:00Z", + notBefore: "2024-01-01T00:00:00Z", + maxPathLength: -1, + activeCaCertId: "cert-123", + dn: "CN=Test CA,O=Test Org,OU=Test OU,C=US", + serialNumber: "123456789" + } + }; + + const mockTemplate = { + id: "template-123", + projectId: "project-123", + name: "Test Template", + createdAt: new Date(), + updatedAt: new Date(), + algorithms: { + signature: ["SHA256-RSA", "SHA384-RSA"], + keyType: ["RSA_2048", "RSA_4096"] + } + }; + + 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 + }); + + afterEach(() => { + vi.useRealTimers(); + }); + + it("should successfully renew eligible certificate", async () => { + vi.mocked(mockCertificateDAL.findById).mockResolvedValue(mockOriginalCert); + vi.mocked(mockCertificateProfileDAL.findByIdWithConfigs).mockResolvedValue(mockProfile); + vi.mocked(mockCertificateAuthorityDAL.findByIdWithAssociatedCa).mockResolvedValue(mockCA); + vi.mocked(mockCertificateTemplateV2Service.getTemplateV2ById).mockResolvedValue(mockTemplate); + vi.mocked(mockCertificateTemplateV2Service.validateCertificateRequest).mockResolvedValue({ + isValid: true, + errors: [], + warnings: [] + }); + vi.mocked(mockInternalCaService.issueCertFromCa).mockResolvedValue({ + certificate: "renewed-cert", + certificateChain: "renewed-chain", + issuingCaCertificate: "issuing-ca", + privateKey: "private-key", + serialNumber: "789012", + ca: mockCA + }); + + const newCert = { ...mockOriginalCert, id: "cert-456", serialNumber: "789012" }; + vi.mocked(mockCertificateDAL.findOne).mockResolvedValue(newCert); + vi.mocked(mockCertificateDAL.updateById).mockResolvedValue(newCert); + + const result = await service.renewCertificate({ + certificateId: "cert-123", + ...mockActor + }); + + 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 + }); + }); + + it("should validate certificate against current template during renewal", async () => { + vi.mocked(mockCertificateDAL.findById).mockResolvedValue(mockOriginalCert); + vi.mocked(mockCertificateProfileDAL.findByIdWithConfigs).mockResolvedValue(mockProfile); + vi.mocked(mockCertificateAuthorityDAL.findByIdWithAssociatedCa).mockResolvedValue(mockCA); + vi.mocked(mockCertificateTemplateV2Service.getTemplateV2ById).mockResolvedValue(mockTemplate); + vi.mocked(mockCertificateTemplateV2Service.validateCertificateRequest).mockResolvedValue({ + isValid: false, + errors: ["Subject alternative name not allowed"], + warnings: [] + }); + + await expect( + service.renewCertificate({ + certificateId: "cert-123", + ...mockActor + }) + ).rejects.toThrow(BadRequestError); + + await expect( + service.renewCertificate({ + certificateId: "cert-123", + ...mockActor + }) + ).rejects.toThrow( + "Certificate renewal failed because requested validity period exceeds maximum allowed duration by the profile template" + ); + + // Should store template validation error + expect(mockCertificateDAL.updateById).toHaveBeenCalledWith("cert-123", { + renewalError: "Template validation failed: Subject alternative name not allowed" + }); + }); + + it("should reject renewal if certificate is not from a profile", async () => { + const certWithoutProfile = { ...mockOriginalCert, profileId: null }; + vi.mocked(mockCertificateDAL.findById).mockResolvedValue(certWithoutProfile); + + await expect( + service.renewCertificate({ + certificateId: "cert-123", + ...mockActor + }) + ).rejects.toThrow(ForbiddenRequestError); + + await expect( + service.renewCertificate({ + certificateId: "cert-123", + ...mockActor + }) + ).rejects.toThrow("Only certificates issued from a profile can be renewed"); + }); + + it("should reject renewal if certificate is already renewed", async () => { + const alreadyRenewedCert = { ...mockOriginalCert, renewedById: "cert-456" }; + vi.mocked(mockCertificateDAL.findById).mockResolvedValue(alreadyRenewedCert); + vi.mocked(mockCertificateProfileDAL.findByIdWithConfigs).mockResolvedValue(mockProfile); + vi.mocked(mockCertificateAuthorityDAL.findByIdWithAssociatedCa).mockResolvedValue(mockCA); + + await expect( + service.renewCertificate({ + certificateId: "cert-123", + ...mockActor + }) + ).rejects.toThrow(BadRequestError); + + await expect( + service.renewCertificate({ + certificateId: "cert-123", + ...mockActor + }) + ).rejects.toThrow("Certificate has already been renewed"); + }); + + it("should reject renewal if certificate is expired", async () => { + const expiredCert = { + ...mockOriginalCert, + notAfter: new Date("2024-01-20") // Expired 6 days ago + }; + vi.mocked(mockCertificateDAL.findById).mockResolvedValue(expiredCert); + vi.mocked(mockCertificateProfileDAL.findByIdWithConfigs).mockResolvedValue(mockProfile); + vi.mocked(mockCertificateAuthorityDAL.findByIdWithAssociatedCa).mockResolvedValue(mockCA); + + await expect( + service.renewCertificate({ + certificateId: "cert-123", + ...mockActor + }) + ).rejects.toThrow(BadRequestError); + + await expect( + service.renewCertificate({ + certificateId: "cert-123", + ...mockActor + }) + ).rejects.toThrow("Certificate is already expired"); + }); + + it("should reject renewal if certificate is revoked", async () => { + const revokedCert = { + ...mockOriginalCert, + revokedAt: new Date("2024-01-15") + }; + vi.mocked(mockCertificateDAL.findById).mockResolvedValue(revokedCert); + vi.mocked(mockCertificateProfileDAL.findByIdWithConfigs).mockResolvedValue(mockProfile); + vi.mocked(mockCertificateAuthorityDAL.findByIdWithAssociatedCa).mockResolvedValue(mockCA); + + await expect( + service.renewCertificate({ + certificateId: "cert-123", + ...mockActor + }) + ).rejects.toThrow(BadRequestError); + + await expect( + service.renewCertificate({ + certificateId: "cert-123", + ...mockActor + }) + ).rejects.toThrow("Certificate is revoked and cannot be renewed"); + }); + + it("should reject renewal if CA is inactive", async () => { + const inactiveCA = { ...mockCA, status: CaStatus.DISABLED }; + vi.mocked(mockCertificateDAL.findById).mockResolvedValue(mockOriginalCert); + vi.mocked(mockCertificateProfileDAL.findByIdWithConfigs).mockResolvedValue(mockProfile); + vi.mocked(mockCertificateAuthorityDAL.findByIdWithAssociatedCa).mockResolvedValue(inactiveCA); + + await expect( + service.renewCertificate({ + certificateId: "cert-123", + ...mockActor + }) + ).rejects.toThrow(BadRequestError); + + await expect( + service.renewCertificate({ + certificateId: "cert-123", + ...mockActor + }) + ).rejects.toThrow("Certificate is not eligible for renewal: Certificate Authority is disabled, must be active"); + }); + + it("should reject renewal if new certificate would outlive CA", async () => { + const shortLivedCA = { + ...mockCA, + internalCa: { + ...mockCA.internalCa, + notAfter: "2024-01-28T00:00:00Z" + } + }; + vi.mocked(mockCertificateDAL.findById).mockResolvedValue(mockOriginalCert); + vi.mocked(mockCertificateProfileDAL.findByIdWithConfigs).mockResolvedValue(mockProfile); + vi.mocked(mockCertificateAuthorityDAL.findByIdWithAssociatedCa).mockResolvedValue(shortLivedCA); + + await expect( + service.renewCertificate({ + certificateId: "cert-123", + ...mockActor + }) + ).rejects.toThrow(BadRequestError); + + await expect( + service.renewCertificate({ + certificateId: "cert-123", + ...mockActor + }) + ).rejects.toThrow("New certificate would expire"); + }); + + it("should allow manual renewal outside window (manual renewal always bypasses window)", async () => { + vi.setSystemTime(new Date("2024-01-15")); // 17 days before expiry, outside 7-day window + + vi.mocked(mockCertificateDAL.findById).mockResolvedValue(mockOriginalCert); + vi.mocked(mockCertificateProfileDAL.findByIdWithConfigs).mockResolvedValue(mockProfile); + vi.mocked(mockCertificateAuthorityDAL.findByIdWithAssociatedCa).mockResolvedValue(mockCA); + vi.mocked(mockCertificateTemplateV2Service.getTemplateV2ById).mockResolvedValue(mockTemplate); + vi.mocked(mockCertificateTemplateV2Service.validateCertificateRequest).mockResolvedValue({ + isValid: true, + errors: [], + warnings: [] + }); + vi.mocked(mockInternalCaService.issueCertFromCa).mockResolvedValue({ + certificate: "renewed-cert", + certificateChain: "renewed-chain", + issuingCaCertificate: "issuing-ca", + privateKey: "private-key", + serialNumber: "789012", + ca: mockCA + }); + + const newCert = { ...mockOriginalCert, id: "cert-456", serialNumber: "789012" }; + vi.mocked(mockCertificateDAL.findOne).mockResolvedValue(newCert); + vi.mocked(mockCertificateDAL.updateById).mockResolvedValue(newCert); + + const result = await service.renewCertificate({ + certificateId: "cert-123", + ...mockActor + }); + + expect(result).toHaveProperty("certificate", "renewed-cert"); + }); + }); + + describe("updateRenewalConfig", () => { + it("should update renewal configuration successfully", async () => { + const mockCert = { + id: "cert-123", + profileId: "profile-123", + renewedById: null, + notBefore: new Date("2026-01-01"), + notAfter: new Date("2026-02-01"), + projectId: "project-123", + status: CertStatus.ACTIVE, + revokedAt: null + }; + + const mockProfile = { + id: "profile-123", + enrollmentType: EnrollmentType.API, + projectId: "project-123" + }; + + vi.mocked(mockCertificateDAL.findById).mockResolvedValue(mockCert as any); + vi.mocked(mockCertificateProfileDAL.findByIdWithConfigs).mockResolvedValue(mockProfile as any); + vi.mocked(mockCertificateDAL.updateById).mockResolvedValue(mockCert as any); + + const result = await service.updateRenewalConfig({ + actor: ActorType.USER, + actorId: "user-123", + actorAuthMethod: AuthMethod.EMAIL, + actorOrgId: "org-123", + certificateId: "cert-123", + renewBeforeDays: 7 + }); + + expect(result).toEqual({ + projectId: "project-123", + renewBeforeDays: 7 + }); + + expect(mockCertificateDAL.updateById).toHaveBeenCalledWith("cert-123", { + renewBeforeDays: 7 + }); + }); + + it("should reject update if certificate is not from profile", async () => { + const mockCert = { + id: "cert-123", + profileId: null, + renewedById: null, + projectId: "project-123" + }; + + vi.mocked(mockCertificateDAL.findById).mockResolvedValue(mockCert as any); + + await expect( + service.updateRenewalConfig({ + actor: ActorType.USER, + actorId: "user-123", + actorAuthMethod: AuthMethod.EMAIL, + actorOrgId: "org-123", + certificateId: "cert-123", + renewBeforeDays: 7 + }) + ).rejects.toThrow(BadRequestError); + + await expect( + service.updateRenewalConfig({ + actor: ActorType.USER, + actorId: "user-123", + actorAuthMethod: AuthMethod.EMAIL, + actorOrgId: "org-123", + certificateId: "cert-123", + renewBeforeDays: 7 + }) + ).rejects.toThrow("Certificate is not eligible for auto-renewal: certificate was not issued from a profile"); + }); + + it("should reject update if certificate is already renewed", async () => { + const mockCert = { + id: "cert-123", + profileId: "profile-123", + renewedById: "cert-456", + projectId: "project-123", + status: CertStatus.ACTIVE, + revokedAt: null, + notBefore: new Date("2026-01-01"), + notAfter: new Date("2026-02-01") + }; + + const mockProfile = { + id: "profile-123", + enrollmentType: EnrollmentType.API, + projectId: "project-123" + }; + + vi.mocked(mockCertificateDAL.findById).mockResolvedValue(mockCert as any); + vi.mocked(mockCertificateProfileDAL.findByIdWithConfigs).mockResolvedValue(mockProfile as any); + + await expect( + service.updateRenewalConfig({ + actor: ActorType.USER, + actorId: "user-123", + actorAuthMethod: AuthMethod.EMAIL, + actorOrgId: "org-123", + certificateId: "cert-123", + renewBeforeDays: 7 + }) + ).rejects.toThrow(BadRequestError); + + await expect( + service.updateRenewalConfig({ + actor: ActorType.USER, + actorId: "user-123", + actorAuthMethod: AuthMethod.EMAIL, + actorOrgId: "org-123", + certificateId: "cert-123", + renewBeforeDays: 7 + }) + ).rejects.toThrow("Certificate is not eligible for auto-renewal: certificate has already been renewed"); + }); + + it("should reject update if renewBeforeDays >= certificate TTL", async () => { + const mockCert = { + id: "cert-123", + profileId: "profile-123", + renewedById: null, + notBefore: new Date("2026-01-01"), + notAfter: new Date("2026-01-08"), + projectId: "project-123", + status: CertStatus.ACTIVE, + revokedAt: null + }; + + const mockProfile = { + id: "profile-123", + enrollmentType: EnrollmentType.API, + projectId: "project-123" + }; + + vi.mocked(mockCertificateDAL.findById).mockResolvedValue(mockCert as any); + vi.mocked(mockCertificateProfileDAL.findByIdWithConfigs).mockResolvedValue(mockProfile as any); + + await expect( + service.updateRenewalConfig({ + actor: ActorType.USER, + actorId: "user-123", + actorAuthMethod: AuthMethod.EMAIL, + actorOrgId: "org-123", + certificateId: "cert-123", + renewBeforeDays: 8 // Greater than 7-day TTL + }) + ).rejects.toThrow(BadRequestError); + + await expect( + service.updateRenewalConfig({ + actor: ActorType.USER, + actorId: "user-123", + actorAuthMethod: AuthMethod.EMAIL, + actorOrgId: "org-123", + certificateId: "cert-123", + renewBeforeDays: 8 + }) + ).rejects.toThrow("Invalid renewal configuration: renewal threshold exceeds certificate validity period"); + }); + }); + + describe("disableRenewalConfig", () => { + it("should disable renewal configuration successfully", async () => { + const mockCert = { + id: "cert-123", + profileId: "profile-123", + projectId: "project-123" + }; + + const mockProfile = { + id: "profile-123", + enrollmentType: EnrollmentType.API, + projectId: "project-123" + }; + + vi.mocked(mockCertificateDAL.findById).mockResolvedValue(mockCert as any); + vi.mocked(mockCertificateProfileDAL.findByIdWithConfigs).mockResolvedValue(mockProfile as any); + vi.mocked(mockCertificateDAL.updateById).mockResolvedValue(mockCert as any); + + const result = await service.disableRenewalConfig({ + actor: ActorType.USER, + actorId: "user-123", + actorAuthMethod: AuthMethod.EMAIL, + actorOrgId: "org-123", + certificateId: "cert-123" + }); + + expect(result).toEqual({ + projectId: "project-123" + }); + + expect(mockCertificateDAL.updateById).toHaveBeenCalledWith("cert-123", { + renewBeforeDays: null + }); + }); + + it("should reject disable if certificate is not from profile", async () => { + const mockCert = { + id: "cert-123", + profileId: null, + projectId: "project-123" + }; + + vi.mocked(mockCertificateDAL.findById).mockResolvedValue(mockCert as any); + + await expect( + service.disableRenewalConfig({ + actor: ActorType.USER, + actorId: "user-123", + actorAuthMethod: AuthMethod.EMAIL, + actorOrgId: "org-123", + certificateId: "cert-123" + }) + ).rejects.toThrow(BadRequestError); + + await expect( + service.disableRenewalConfig({ + actor: ActorType.USER, + actorId: "user-123", + actorAuthMethod: AuthMethod.EMAIL, + actorOrgId: "org-123", + certificateId: "cert-123" + }) + ).rejects.toThrow("Certificate is not eligible for auto-renewal: certificate was not issued from a profile"); + }); + }); }); diff --git a/backend/src/services/certificate-v3/certificate-v3-service.ts b/backend/src/services/certificate-v3/certificate-v3-service.ts index 1c11b0a00..c987fb77b 100644 --- a/backend/src/services/certificate-v3/certificate-v3-service.ts +++ b/backend/src/services/certificate-v3/certificate-v3-service.ts @@ -1,9 +1,11 @@ import { ForbiddenError } from "@casl/ability"; import { randomUUID } from "crypto"; +import RE2 from "re2"; import { ActionProjectType } from "@app/db/schemas"; import { TPermissionServiceFactory } from "@app/ee/services/permission/permission-service-types"; import { + ProjectPermissionCertificateActions, ProjectPermissionCertificateProfileActions, ProjectPermissionSub } from "@app/ee/services/permission/project-permission"; @@ -11,15 +13,18 @@ import { BadRequestError, ForbiddenRequestError, NotFoundError } from "@app/lib/ import { ActorAuthMethod, ActorType } from "@app/services/auth/auth-type"; import { TCertificateDALFactory } from "@app/services/certificate/certificate-dal"; import { + CertExtendedKeyUsage, CertificateOrderStatus, CertKeyAlgorithm, - CertSignatureAlgorithm + CertKeyUsage, + CertSignatureAlgorithm, + CertStatus } from "@app/services/certificate/certificate-types"; import { TCertificateAuthorityDALFactory, TCertificateAuthorityWithAssociatedCa } from "@app/services/certificate-authority/certificate-authority-dal"; -import { CaType } from "@app/services/certificate-authority/certificate-authority-enums"; +import { CaStatus, CaType } from "@app/services/certificate-authority/certificate-authority-enums"; import { TInternalCertificateAuthorityServiceFactory } from "@app/services/certificate-authority/internal/internal-certificate-authority-service"; import { TCertificateProfileDALFactory } from "@app/services/certificate-profile/certificate-profile-dal"; import { EnrollmentType } from "@app/services/certificate-profile/certificate-profile-types"; @@ -30,7 +35,9 @@ import { bufferToString, buildCertificateSubjectFromTemplate, buildSubjectAlternativeNamesFromTemplate, + convertExtendedKeyUsageArrayFromLegacy, convertExtendedKeyUsageArrayToLegacy, + convertKeyUsageArrayFromLegacy, convertKeyUsageArrayToLegacy, mapEnumsForValidation, normalizeDateForApi @@ -38,13 +45,18 @@ import { import { TCertificateFromProfileResponse, TCertificateOrderResponse, + TDisableRenewalConfigDTO, + TDisableRenewalResponse, TIssueCertificateFromProfileDTO, TOrderCertificateFromProfileDTO, - TSignCertificateFromProfileDTO + TRenewalConfigResponse, + TRenewCertificateDTO, + TSignCertificateFromProfileDTO, + TUpdateRenewalConfigDTO } from "./certificate-v3-types"; type TCertificateV3ServiceFactoryDep = { - certificateDAL: Pick; + certificateDAL: Pick; certificateAuthorityDAL: Pick; certificateProfileDAL: Pick; certificateTemplateV2Service: Pick< @@ -95,6 +107,77 @@ const validateProfileAndPermissions = async ( return profile; }; +const validateRenewalEligibility = ( + certificate: { + id: string; + status: string; + notBefore: Date; + notAfter: Date; + revokedAt?: Date | null; + renewedById?: string | null; + profileId?: string | null; + caId?: string | null; + pkiSubscriberId?: string | null; + }, + ca: TCertificateAuthorityWithAssociatedCa +) => { + const errors: string[] = []; + + if (certificate.status !== CertStatus.ACTIVE) { + errors.push(`Certificate status is ${certificate.status}, must be ${CertStatus.ACTIVE}`); + } + + const now = new Date(); + if (certificate.notAfter <= now) { + errors.push("Certificate is already expired"); + } + + if (certificate.revokedAt) { + errors.push("Certificate is revoked and cannot be renewed"); + } + + const caType = (ca.externalCa?.type as CaType) ?? CaType.INTERNAL; + const isInternalCa = caType === CaType.INTERNAL; + const isConnectedExternalCa = caType === CaType.ACME || caType === CaType.AZURE_AD_CS; + const isImportedCertificate = certificate.pkiSubscriberId != null && !certificate.profileId; + + if (!isInternalCa && !isConnectedExternalCa) { + errors.push(`CA type ${String(caType)} does not support renewal`); + } + + if (isImportedCertificate) { + errors.push("Externally imported certificates cannot be renewed"); + } + + if (ca.status !== CaStatus.ACTIVE) { + errors.push(`Certificate Authority is ${ca.status}, must be ${CaStatus.ACTIVE}`); + } + + if (certificate.renewedById) { + errors.push("Certificate has already been renewed"); + } + + const certificateTtlInDays = Math.ceil( + (certificate.notAfter.getTime() - certificate.notBefore.getTime()) / (24 * 60 * 60 * 1000) + ); + + if (ca.internalCa?.notAfter) { + const caExpiryDate = new Date(ca.internalCa.notAfter); + const proposedCertExpiryDate = new Date(now.getTime() + certificateTtlInDays * 24 * 60 * 60 * 1000); + + if (proposedCertExpiryDate > caExpiryDate) { + errors.push( + `New certificate would expire (${proposedCertExpiryDate.toISOString()}) after its issuing CA (${caExpiryDate.toISOString()})` + ); + } + } + + return { + isEligible: errors.length === 0, + errors + }; +}; + const validateCaSupport = (ca: TCertificateAuthorityWithAssociatedCa, operation: string) => { const caType = (ca.externalCa?.type as CaType) ?? CaType.INTERNAL; if (caType !== CaType.INTERNAL) { @@ -155,6 +238,63 @@ const extractCertificateFromBuffer = (certData: Buffer | { rawData: Buffer } | s return bufferToString(certData as unknown as Buffer); }; +const parseKeyUsages = (keyUsages: unknown): CertKeyUsage[] => { + if (!keyUsages) return []; + if (Array.isArray(keyUsages)) return keyUsages as CertKeyUsage[]; + return (keyUsages as string).split(",").map((usage) => usage.trim() as CertKeyUsage); +}; + +const parseExtendedKeyUsages = (extendedKeyUsages: unknown): CertExtendedKeyUsage[] => { + if (!extendedKeyUsages) return []; + if (Array.isArray(extendedKeyUsages)) return extendedKeyUsages as CertExtendedKeyUsage[]; + return (extendedKeyUsages as string).split(",").map((usage) => usage.trim() as CertExtendedKeyUsage); +}; + +const isValidRenewalTiming = (renewBeforeDays: number, certificateExpiryDate: Date): boolean => { + const renewalDate = new Date(certificateExpiryDate.getTime() - renewBeforeDays * 24 * 60 * 60 * 1000); + const tomorrow = new Date(); + tomorrow.setDate(tomorrow.getDate() + 1); + tomorrow.setHours(0, 0, 0, 0); + + return renewalDate >= tomorrow; +}; + +const calculateRenewalThreshold = ( + profileRenewBeforeDays: number | undefined, + certificateTtlInDays: number +): number | undefined => { + if (!profileRenewBeforeDays) { + return undefined; + } + + if (certificateTtlInDays > profileRenewBeforeDays) { + return profileRenewBeforeDays; + } + + return Math.max(1, certificateTtlInDays - 1); +}; + +const parseTtlToDays = (ttl: string): number => { + const match = ttl.match(new RE2("^(\\d+)([dhm])$")); + if (!match) { + throw new BadRequestError({ message: `Invalid TTL format: ${ttl}` }); + } + + const [, value, unit] = match; + const numValue = parseInt(value, 10); + + switch (unit) { + case "d": + return numValue; + case "h": + return Math.ceil(numValue / 24); + case "m": + return Math.ceil(numValue / (24 * 60)); + default: + throw new BadRequestError({ message: `Unsupported TTL unit: ${unit}` }); + } +}; + export const certificateV3ServiceFactory = ({ certificateDAL, certificateAuthorityDAL, @@ -274,7 +414,16 @@ export const certificateV3ServiceFactory = ({ throw new NotFoundError({ message: "Certificate was issued but could not be found in database" }); } - await certificateDAL.updateById(cert.id, { profileId }); + const certificateTtlInDays = parseTtlToDays(certificateRequest.validity.ttl); + const renewBeforeDays = calculateRenewalThreshold(profile.apiConfig?.renewBeforeDays, certificateTtlInDays); + + const finalRenewBeforeDays = + renewBeforeDays && isValidRenewalTiming(renewBeforeDays, new Date(cert.notAfter)) ? renewBeforeDays : undefined; + + await certificateDAL.updateById(cert.id, { + profileId, + renewBeforeDays: finalRenewBeforeDays + }); return { certificate: bufferToString(certificate), @@ -371,7 +520,16 @@ export const certificateV3ServiceFactory = ({ throw new NotFoundError({ message: "Certificate was signed but could not be found in database" }); } - await certificateDAL.updateById(cert.id, { profileId }); + const certificateTtlInDays = parseTtlToDays(validity.ttl); + const renewBeforeDays = calculateRenewalThreshold(profile.apiConfig?.renewBeforeDays, certificateTtlInDays); + + const finalRenewBeforeDays = + renewBeforeDays && isValidRenewalTiming(renewBeforeDays, new Date(cert.notAfter)) ? renewBeforeDays : undefined; + + await certificateDAL.updateById(cert.id, { + profileId, + renewBeforeDays: finalRenewBeforeDays + }); const certificateString = extractCertificateFromBuffer(certificate as unknown as Buffer); const certificateChainString = extractCertificateFromBuffer(certificateChain as unknown as Buffer); @@ -479,9 +637,341 @@ export const certificateV3ServiceFactory = ({ }); }; + const renewCertificate = async ({ + certificateId, + actor, + actorId, + actorAuthMethod, + 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 = + 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 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 because requested validity period exceeds maximum allowed duration by the profile template: ${validationResult.errors.join(", ")}` + }); + } + + validateAlgorithmCompatibility(ca, template); + const notBefore = new Date(); + const notAfter = new Date(Date.now() + parseTtlToDays(ttl) * 24 * 60 * 60 * 1000); + + 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 + }); + + 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" }); + } + + const certificateTtlInDays = parseTtlToDays(ttl); + const finalRenewBeforeDays = calculateRenewalThreshold(profile.apiConfig?.renewBeforeDays, certificateTtlInDays); + + await certificateDAL.updateById(newCert.id, { + profileId: originalCert.profileId, + renewBeforeDays: finalRenewBeforeDays, + renewedFromId: originalCert.id + }); + + 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 + }; + }; + + const updateRenewalConfig = async ({ + certificateId, + renewBeforeDays, + actor, + actorId, + actorAuthMethod, + actorOrgId + }: TUpdateRenewalConfigDTO): Promise => { + const certificate = await certificateDAL.findById(certificateId); + if (!certificate) { + throw new NotFoundError({ message: "Certificate not found" }); + } + + const { permission } = await permissionService.getProjectPermission({ + actor, + actorId, + projectId: certificate.projectId, + actorAuthMethod, + actorOrgId, + actionProjectType: ActionProjectType.CertificateManager + }); + + ForbiddenError.from(permission).throwUnlessCan( + ProjectPermissionCertificateActions.Edit, + ProjectPermissionSub.Certificates + ); + + if (!certificate.profileId) { + throw new BadRequestError({ + message: "Certificate is not eligible for auto-renewal: certificate was not issued from a profile" + }); + } + + const profile = await certificateProfileDAL.findByIdWithConfigs(certificate.profileId); + if (!profile) { + throw new NotFoundError({ message: "Certificate profile not found" }); + } + + if (profile.enrollmentType !== "api") { + throw new ForbiddenRequestError({ + message: "Certificate is not eligible for auto-renewal: EST certificates cannot be auto-renewed" + }); + } + + if (certificate.status !== CertStatus.ACTIVE) { + throw new BadRequestError({ + message: `Certificate is not eligible for auto-renewal: certificate status is ${certificate.status}, must be active` + }); + } + + const now = new Date(); + if (certificate.notAfter <= now) { + throw new BadRequestError({ + message: "Certificate is not eligible for auto-renewal: certificate has expired" + }); + } + + if (certificate.revokedAt) { + throw new BadRequestError({ + message: "Certificate is not eligible for auto-renewal: certificate has been revoked" + }); + } + + if (certificate.renewedById) { + throw new BadRequestError({ + message: "Certificate is not eligible for auto-renewal: certificate has already been renewed" + }); + } + + const certificateTtlInDays = Math.ceil( + (new Date(certificate.notAfter).getTime() - new Date(certificate.notBefore).getTime()) / (24 * 60 * 60 * 1000) + ); + + if (renewBeforeDays >= certificateTtlInDays) { + throw new BadRequestError({ + message: "Invalid renewal configuration: renewal threshold exceeds certificate validity period" + }); + } + + if (!isValidRenewalTiming(renewBeforeDays, new Date(certificate.notAfter))) { + throw new BadRequestError({ + message: "Invalid renewal configuration: renewal would be triggered immediately or in the past" + }); + } + + await certificateDAL.updateById(certificateId, { + renewBeforeDays + }); + + return { + projectId: certificate.projectId, + renewBeforeDays + }; + }; + + const disableRenewalConfig = async ({ + certificateId, + actor, + actorId, + actorAuthMethod, + actorOrgId + }: TDisableRenewalConfigDTO): Promise => { + const certificate = await certificateDAL.findById(certificateId); + if (!certificate) { + throw new NotFoundError({ message: "Certificate not found" }); + } + + const { permission } = await permissionService.getProjectPermission({ + actor, + actorId, + projectId: certificate.projectId, + actorAuthMethod, + actorOrgId, + actionProjectType: ActionProjectType.CertificateManager + }); + + ForbiddenError.from(permission).throwUnlessCan( + ProjectPermissionCertificateActions.Edit, + ProjectPermissionSub.Certificates + ); + + if (!certificate.profileId) { + throw new BadRequestError({ + message: "Certificate is not eligible for auto-renewal: certificate was not issued from a profile" + }); + } + + const profile = await certificateProfileDAL.findByIdWithConfigs(certificate.profileId); + if (!profile) { + throw new NotFoundError({ message: "Certificate profile not found" }); + } + + if (profile.enrollmentType !== "api") { + throw new ForbiddenRequestError({ + message: "Certificate is not eligible for auto-renewal: EST certificates cannot be auto-renewed" + }); + } + + await certificateDAL.updateById(certificateId, { + renewBeforeDays: null + }); + + return { + projectId: certificate.projectId + }; + }; + return { issueCertificateFromProfile, signCertificateFromProfile, - orderCertificateFromProfile + orderCertificateFromProfile, + renewCertificate, + updateRenewalConfig, + disableRenewalConfig }; }; diff --git a/backend/src/services/certificate-v3/certificate-v3-types.ts b/backend/src/services/certificate-v3/certificate-v3-types.ts index b54042c5c..705765c7c 100644 --- a/backend/src/services/certificate-v3/certificate-v3-types.ts +++ b/backend/src/services/certificate-v3/certificate-v3-types.ts @@ -97,3 +97,25 @@ export type TCertificateOrderResponse = { projectId: string; profileName: string; }; + +export type TRenewCertificateDTO = { + certificateId: string; +} & Omit; + +export type TUpdateRenewalConfigDTO = { + certificateId: string; + renewBeforeDays: number; +} & Omit; + +export type TDisableRenewalConfigDTO = { + certificateId: string; +} & Omit; + +export type TRenewalConfigResponse = { + projectId: string; + renewBeforeDays: number; +}; + +export type TDisableRenewalResponse = { + projectId: string; +}; diff --git a/backend/src/services/enrollment-config/api-enrollment-config-dal.ts b/backend/src/services/enrollment-config/api-enrollment-config-dal.ts index 1edfdae6c..ec4b8f76b 100644 --- a/backend/src/services/enrollment-config/api-enrollment-config-dal.ts +++ b/backend/src/services/enrollment-config/api-enrollment-config-dal.ts @@ -69,15 +69,15 @@ export const apiEnrollmentConfigDALFactory = (db: TDbClient) => { const profiles = await query .where((qb) => { void qb - .whereNull(`${TableName.PkiApiEnrollmentConfig}.autoRenewDays`) - .orWhere(`${TableName.PkiApiEnrollmentConfig}.autoRenewDays`, "<=", renewalThresholdDays); + .whereNull(`${TableName.PkiApiEnrollmentConfig}.renewBeforeDays`) + .orWhere(`${TableName.PkiApiEnrollmentConfig}.renewBeforeDays`, "<=", renewalThresholdDays); }) .select((tx || db).ref("id").withSchema(TableName.PkiCertificateProfile)) .select((tx || db).ref("name").withSchema(TableName.PkiCertificateProfile)) .select((tx || db).ref("projectId").withSchema(TableName.PkiCertificateProfile)) - .select((tx || db).ref("autoRenewDays").withSchema(TableName.PkiCertificateProfile)); + .select((tx || db).ref("renewBeforeDays").withSchema(TableName.PkiCertificateProfile)); - return profiles as Array<{ id: string; name: string; projectId: string; autoRenewDays?: number }>; + return profiles as Array<{ id: string; name: string; projectId: string; renewBeforeDays?: number }>; } catch (error) { throw new DatabaseError({ error, name: "Find profiles for auto renewal" }); } diff --git a/backend/src/services/enrollment-config/enrollment-config-types.ts b/backend/src/services/enrollment-config/enrollment-config-types.ts index 516f135fd..d2e03e4da 100644 --- a/backend/src/services/enrollment-config/enrollment-config-types.ts +++ b/backend/src/services/enrollment-config/enrollment-config-types.ts @@ -25,5 +25,5 @@ export interface TEstConfigData { export interface TApiConfigData { autoRenew: boolean; - autoRenewDays?: number; + renewBeforeDays?: number; } diff --git a/frontend/src/hooks/api/certificateProfiles/types.ts b/frontend/src/hooks/api/certificateProfiles/types.ts index a9b6b7060..b5c53e11b 100644 --- a/frontend/src/hooks/api/certificateProfiles/types.ts +++ b/frontend/src/hooks/api/certificateProfiles/types.ts @@ -35,7 +35,7 @@ export type TCertificateProfileWithDetails = TCertificateProfile & { apiConfig?: { id: string; autoRenew: boolean; - autoRenewDays?: number; + renewBeforeDays?: number; }; }; @@ -53,7 +53,7 @@ export type TCreateCertificateProfileDTO = { }; apiConfig?: { autoRenew?: boolean; - autoRenewDays?: number; + renewBeforeDays?: number; }; }; @@ -68,7 +68,7 @@ export type TUpdateCertificateProfileDTO = { }; apiConfig?: { autoRenew?: boolean; - autoRenewDays?: number; + renewBeforeDays?: number; }; }; diff --git a/frontend/src/hooks/api/certificates/index.tsx b/frontend/src/hooks/api/certificates/index.tsx index ddac04730..a60ebf91e 100644 --- a/frontend/src/hooks/api/certificates/index.tsx +++ b/frontend/src/hooks/api/certificates/index.tsx @@ -1,2 +1,8 @@ -export { useDeleteCert, useImportCertificate, useRevokeCert } from "./mutations"; +export { + useDeleteCert, + useImportCertificate, + useRenewCertificate, + useRevokeCert, + useUpdateRenewalConfig +} from "./mutations"; export { useGetCert, useGetCertBody } from "./queries"; diff --git a/frontend/src/hooks/api/certificates/mutations.tsx b/frontend/src/hooks/api/certificates/mutations.tsx index 388295b0a..699cbb8eb 100644 --- a/frontend/src/hooks/api/certificates/mutations.tsx +++ b/frontend/src/hooks/api/certificates/mutations.tsx @@ -9,7 +9,10 @@ import { TDeleteCertDTO, TImportCertificateDTO, TImportCertificateResponse, - TRevokeCertDTO + TRenewCertificateDTO, + TRenewCertificateResponse, + TRevokeCertDTO, + TUpdateRenewalConfigDTO } from "./types"; export const useDeleteCert = () => { @@ -77,3 +80,57 @@ export const useImportCertificate = () => { } }); }; + +export const useRenewCertificate = () => { + const queryClient = useQueryClient(); + return useMutation({ + mutationFn: async ({ certificateId }) => { + const { data } = await apiRequest.post( + `/api/v3/certificates/${certificateId}/renew`, + {} + ); + return data; + }, + onSuccess: (data) => { + queryClient.invalidateQueries({ + queryKey: ["certificate-profiles", "list"] + }); + queryClient.invalidateQueries({ + queryKey: pkiSubscriberKeys.allPkiSubscriberCertificates() + }); + queryClient.invalidateQueries({ + queryKey: projectKeys.allProjectCertificates() + }); + if (data.projectId) { + queryClient.invalidateQueries({ + queryKey: projectKeys.forProjectCertificates(data.projectId) + }); + } + } + }); +}; + +export const useUpdateRenewalConfig = () => { + const queryClient = useQueryClient(); + return useMutation< + { message: string; renewBeforeDays?: number }, + object, + TUpdateRenewalConfigDTO & { disableAutoRenewal?: boolean } + >({ + mutationFn: async ({ certificateId, renewBeforeDays, disableAutoRenewal }) => { + const { data } = await apiRequest.patch<{ message: string; renewBeforeDays?: number }>( + `/api/v3/certificates/${certificateId}/config`, + { renewBeforeDays, disableAutoRenewal } + ); + return data; + }, + onSuccess: (_, { projectSlug }) => { + queryClient.invalidateQueries({ + queryKey: projectKeys.forProjectCertificates(projectSlug) + }); + queryClient.invalidateQueries({ + queryKey: projectKeys.allProjectCertificates() + }); + } + }); +}; diff --git a/frontend/src/hooks/api/certificates/types.ts b/frontend/src/hooks/api/certificates/types.ts index 1ec3292a3..80d02e3f8 100644 --- a/frontend/src/hooks/api/certificates/types.ts +++ b/frontend/src/hooks/api/certificates/types.ts @@ -4,6 +4,7 @@ export type TCertificate = { id: string; caId: string; certificateTemplateId?: string; + profileId?: string; status: CertStatus; friendlyName: string; commonName: string; @@ -13,6 +14,11 @@ export type TCertificate = { notAfter: string; keyUsages: CertKeyUsage[]; extendedKeyUsages: CertExtendedKeyUsage[]; + renewBeforeDays?: number; + renewedBy?: string; + renewedFromId?: string; + renewedById?: string; + renewalError?: string; }; export type TDeleteCertDTO = { @@ -43,3 +49,23 @@ export type TImportCertificateResponse = { privateKey: string; serialNumber: string; }; + +export type TRenewCertificateDTO = { + certificateId: string; +}; + +export type TRenewCertificateResponse = { + certificate: string; + issuingCaCertificate: string; + certificateChain: string; + privateKey?: string; + serialNumber: string; + certificateId: string; + projectId: string; +}; + +export type TUpdateRenewalConfigDTO = { + certificateId: string; + renewBeforeDays?: number; + projectSlug: string; +}; diff --git a/frontend/src/pages/cert-manager/CertificatesPage/components/CertificateManageRenewalModal.tsx b/frontend/src/pages/cert-manager/CertificatesPage/components/CertificateManageRenewalModal.tsx new file mode 100644 index 000000000..86adf948e --- /dev/null +++ b/frontend/src/pages/cert-manager/CertificatesPage/components/CertificateManageRenewalModal.tsx @@ -0,0 +1,274 @@ +import { useEffect } from "react"; +import { Controller, useForm } from "react-hook-form"; +import { zodResolver } from "@hookform/resolvers/zod"; +import { z } from "zod"; + +import { createNotification } from "@app/components/notifications"; +import { Button, FormControl, Input, Modal, ModalContent } from "@app/components/v2"; +import { useProject } from "@app/context"; +import { useUpdateRenewalConfig } from "@app/hooks/api"; +import { UsePopUpState } from "@app/hooks/usePopUp"; + +const DEFAULT_RENEWAL_BEFORE_DAYS = 20; +const MIN_RENEWAL_BEFORE_DAYS = 1; +const MAX_RENEWAL_BEFORE_DAYS = 30; + +const formSchema = 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"); + +type FormData = z.infer; + +type Props = { + popUp: UsePopUpState<["manageRenewal"]>; + handlePopUpToggle: (popUpName: keyof UsePopUpState<["manageRenewal"]>, state?: boolean) => void; +}; + +export const CertificateManageRenewalModal = ({ popUp, handlePopUpToggle }: Props) => { + const { currentProject } = useProject(); + const { mutateAsync: updateRenewalConfig, isPending: isUpdatingConfig } = + useUpdateRenewalConfig(); + + const certificateData = popUp.manageRenewal.data as { + certificateId: string; + commonName: string; + profileId: string; + renewBeforeDays?: number; + ttlDays: number; + notAfter: string; + renewalError?: string; + renewedFromId?: string; + renewedById?: string; + }; + + const isAutoRenewalEnabled = Boolean( + certificateData?.renewBeforeDays && certificateData.renewBeforeDays > 0 + ); + + const hasRenewalError = Boolean(certificateData?.renewalError); + + const { + control, + handleSubmit, + formState: { errors }, + reset + } = useForm({ + resolver: zodResolver(formSchema), + defaultValues: { + renewBeforeDays: DEFAULT_RENEWAL_BEFORE_DAYS + } + }); + + useEffect(() => { + if (popUp.manageRenewal.isOpen) { + reset({ + renewBeforeDays: certificateData?.renewBeforeDays || DEFAULT_RENEWAL_BEFORE_DAYS + }); + } + }, [popUp.manageRenewal.isOpen, certificateData?.renewBeforeDays, reset]); + + const onUpdateRenewal = async (data: FormData) => { + try { + if (!currentProject?.slug) { + createNotification({ + text: "Unable to update auto-renewal: Project not found. Please refresh the page and try again.", + type: "error" + }); + 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, + projectSlug: currentProject.slug + }); + + createNotification({ + text: isAutoRenewalEnabled + ? "Auto-renewal configuration updated successfully" + : "Auto-renewal enabled successfully", + type: "success" + }); + + handlePopUpToggle("manageRenewal", false); + } catch (err) { + console.error(err); + createNotification({ + text: isAutoRenewalEnabled + ? "Failed to update auto-renewal configuration. Please check your inputs and try again." + : "Failed to enable auto-renewal. Please check your inputs and try again.", + type: "error" + }); + } + }; + + const isLoading = isUpdatingConfig; + + const getModalTitle = () => { + if (hasRenewalError) { + return `Fix Auto-Renewal: ${certificateData?.commonName || ""}`; + } + if (isAutoRenewalEnabled) { + return `Manage Auto-Renewal for ${certificateData?.commonName || ""}`; + } + return `Enable Auto-Renewal for ${certificateData?.commonName || ""}`; + }; + + return ( + { + handlePopUpToggle("manageRenewal", isOpen); + }} + > + + {/* Show renewal error if present */} + {hasRenewalError && ( +
+
+
+ ! +
+
+

Automatic Renewal Failed

+

+ The last automatic renewal attempt failed: {certificateData.renewalError} +

+

+ You can reconfigure auto-renewal below or disable it completely. +

+
+
+
+ )} + + {/* 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" + /> + )} + /> + + +
+ + +
+
+ )} + + {/* 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" + /> + )} + /> + + +
+ + +
+
+ )} +
+
+ ); +}; diff --git a/frontend/src/pages/cert-manager/CertificatesPage/components/CertificateRenewalConfigModal.tsx b/frontend/src/pages/cert-manager/CertificatesPage/components/CertificateRenewalConfigModal.tsx new file mode 100644 index 000000000..1c195dd22 --- /dev/null +++ b/frontend/src/pages/cert-manager/CertificatesPage/components/CertificateRenewalConfigModal.tsx @@ -0,0 +1,177 @@ +import { Controller, useForm } from "react-hook-form"; +import { zodResolver } from "@hookform/resolvers/zod"; +import { z } from "zod"; + +import { createNotification } from "@app/components/notifications"; +import { Button, FormControl, Input, Modal, ModalContent } from "@app/components/v2"; +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") +}); + +type FormData = z.infer; + +type Props = { + popUp: UsePopUpState<["configureRenewal"]>; + handlePopUpToggle: ( + popUpName: keyof UsePopUpState<["configureRenewal"]>, + state?: boolean + ) => void; +}; + +export const CertificateRenewalConfigModal = ({ popUp, handlePopUpToggle }: Props) => { + const { currentProject } = useProject(); + const { mutateAsync: updateRenewalConfig, isPending: isSubmitting } = useUpdateRenewalConfig(); + + const certificateData = popUp.configureRenewal.data as { + certificateId: string; + commonName: string; + profileId: string; + renewBeforeDays?: number; + ttlDays: number; + }; + + const { + control, + handleSubmit, + formState: { errors }, + watch + } = useForm({ + resolver: zodResolver(formSchema), + defaultValues: { + renewBeforeDays: certificateData?.renewBeforeDays || 7 + } + }); + + const renewBeforeDays = watch("renewBeforeDays"); + + 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", + type: "error" + }); + return; + } + + await updateRenewalConfig({ + certificateId: certificateData.certificateId, + renewBeforeDays: data.renewBeforeDays, + projectSlug: currentProject.slug + }); + + createNotification({ + text: "Successfully updated auto-renewal configuration", + type: "success" + }); + + handlePopUpToggle("configureRenewal", false); + } catch (err) { + console.error(err); + createNotification({ + text: "Failed to update auto-renewal configuration", + type: "error" + }); + } + }; + + return ( + { + handlePopUpToggle("configureRenewal", isOpen); + }} + > + +
+
+

+ Configure when this certificate should be automatically renewed. The certificate will + be renewed when it has the specified number of days remaining before expiration. +

+ +
+

+ Certificate TTL: {certificateData?.ttlDays} days +

+

+ Current Setting:{" "} + {certificateData?.renewBeforeDays + ? `${certificateData.renewBeforeDays} days before expiration` + : "Disabled"} +

+
+ + ( + + { + const value = parseInt(e.target.value, 10); + field.onChange(Number.isNaN(value) ? 0 : value); + }} + /> + + )} + /> + + {renewBeforeDays && certificateData?.ttlDays && ( +
+

+ {renewBeforeDays >= (certificateData.ttlDays || 0) + ? "⚠️ 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/CertificateRenewalDisableModal.tsx b/frontend/src/pages/cert-manager/CertificatesPage/components/CertificateRenewalDisableModal.tsx new file mode 100644 index 000000000..619272041 --- /dev/null +++ b/frontend/src/pages/cert-manager/CertificatesPage/components/CertificateRenewalDisableModal.tsx @@ -0,0 +1,94 @@ +import { createNotification } from "@app/components/notifications"; +import { Button, Modal, ModalContent } from "@app/components/v2"; +import { useProject } from "@app/context"; +import { useUpdateRenewalConfig } from "@app/hooks/api"; +import { UsePopUpState } from "@app/hooks/usePopUp"; + +type Props = { + popUp: UsePopUpState<["disableRenewal"]>; + handlePopUpToggle: (popUpName: keyof UsePopUpState<["disableRenewal"]>, state?: boolean) => void; +}; + +export const CertificateRenewalDisableModal = ({ popUp, handlePopUpToggle }: Props) => { + const { currentProject } = useProject(); + const { mutateAsync: updateRenewalConfig, isPending: isSubmitting } = useUpdateRenewalConfig(); + + const certificateData = popUp.disableRenewal.data as { + certificateId: string; + commonName: string; + }; + + const onDisableConfirm = async () => { + try { + if (!currentProject?.slug) { + createNotification({ + text: "Project not found", + type: "error" + }); + return; + } + + await updateRenewalConfig({ + certificateId: certificateData.certificateId, + projectSlug: currentProject.slug, + disableAutoRenewal: true + }); + + createNotification({ + text: "Successfully disabled auto-renewal", + type: "success" + }); + + handlePopUpToggle("disableRenewal", false); + } catch (err) { + console.error(err); + createNotification({ + text: "Failed to disable auto-renewal", + type: "error" + }); + } + }; + + return ( + { + handlePopUpToggle("disableRenewal", isOpen); + }} + > + +
+

+ Are you sure you want to disable auto-renewal for this certificate? +

+
+

+ Warning: Once disabled, this certificate will not be automatically + renewed and may expire without notice. You can re-enable auto-renewal at any time. +

+
+
+ +
+ + +
+
+
+ ); +}; diff --git a/frontend/src/pages/cert-manager/CertificatesPage/components/CertificateRenewalModal.tsx b/frontend/src/pages/cert-manager/CertificatesPage/components/CertificateRenewalModal.tsx new file mode 100644 index 000000000..0e2b1c17d --- /dev/null +++ b/frontend/src/pages/cert-manager/CertificatesPage/components/CertificateRenewalModal.tsx @@ -0,0 +1,80 @@ +import { faRedo } from "@fortawesome/free-solid-svg-icons"; +import { FontAwesomeIcon } from "@fortawesome/react-fontawesome"; + +import { createNotification } from "@app/components/notifications"; +import { Button, Modal, ModalContent } from "@app/components/v2"; +import { useRenewCertificate } from "@app/hooks/api"; +import { UsePopUpState } from "@app/hooks/usePopUp"; + +type Props = { + popUp: UsePopUpState<["renewCertificate"]>; + handlePopUpToggle: ( + popUpName: keyof UsePopUpState<["renewCertificate"]>, + state?: boolean + ) => void; +}; + +export const CertificateRenewalModal = ({ popUp, handlePopUpToggle }: Props) => { + const { mutateAsync: renewCertificate, isPending: isRenewing } = useRenewCertificate(); + + const onRenewConfirm = async () => { + try { + const { certificateId } = popUp.renewCertificate.data as { certificateId: string }; + + await renewCertificate({ + certificateId + }); + + createNotification({ + text: "Certificate renewed successfully", + type: "success" + }); + + handlePopUpToggle("renewCertificate", false); + } catch (err) { + console.error(err); + } + }; + + const certificateData = popUp.renewCertificate.data as { + certificateId: string; + commonName: string; + profileId: string; + }; + + return ( + { + handlePopUpToggle("renewCertificate", isOpen); + }} + > + +
+

+ Are you sure you want to renew this certificate now? +

+
+ +
+ + +
+
+
+ ); +}; diff --git a/frontend/src/pages/cert-manager/CertificatesPage/components/CertificatesSection.tsx b/frontend/src/pages/cert-manager/CertificatesPage/components/CertificatesSection.tsx index bce21123b..4102d8ea5 100644 --- a/frontend/src/pages/cert-manager/CertificatesPage/components/CertificatesSection.tsx +++ b/frontend/src/pages/cert-manager/CertificatesPage/components/CertificatesSection.tsx @@ -16,7 +16,9 @@ import { usePopUp } from "@app/hooks/usePopUp"; import { CertificateCertModal } from "./CertificateCertModal"; import { CertificateImportModal } from "./CertificateImportModal"; import { CertificateIssuanceModal } from "./CertificateIssuanceModal"; +import { CertificateManageRenewalModal } from "./CertificateManageRenewalModal"; import { CertificateModal } from "./CertificateModal"; +import { CertificateRenewalModal } from "./CertificateRenewalModal"; import { CertificateRevocationModal } from "./CertificateRevocationModal"; import { CertificatesTable } from "./CertificatesTable"; @@ -33,7 +35,9 @@ export const CertificatesSection = () => { "certificateImport", "certificateCert", "deleteCertificate", - "revokeCertificate" + "revokeCertificate", + "manageRenewal", + "renewCertificate" ] as const); const onRemoveCertificateSubmit = async (serialNumber: string) => { @@ -98,6 +102,8 @@ export const CertificatesSection = () => { )} + + { + const expiryDate = new Date(notAfter); + const now = new Date(); + const oneDayFromNow = new Date(now.getTime() + 24 * 60 * 60 * 1000); + return expiryDate <= oneDayFromNow; +}; + +const getAutoRenewalInfo = (certificate: TCertificate) => { + if (certificate.renewedById) { + return { text: "Renewed", variant: "success" as const }; + } + + const isRevoked = certificate.status === CertStatus.REVOKED; + const isExpired = new Date(certificate.notAfter) < new Date(); + const hasNoProfile = !certificate.profileId; + const isExpiringWithinDay = isExpiringWithinOneDay(certificate.notAfter); + + if (isRevoked || isExpired || hasNoProfile || isExpiringWithinDay) { + return null; + } + + if (certificate.renewalError) { + return { + text: "Failed", + variant: "danger" as const, + tooltip: certificate.renewalError + }; + } + + if (!certificate.renewBeforeDays) { + return { text: "Disabled", variant: "primary" as const }; + } + + const notAfterDate = new Date(certificate.notAfter); + const renewalDate = new Date( + notAfterDate.getTime() - certificate.renewBeforeDays * 24 * 60 * 60 * 1000 + ); + const now = new Date(); + + if (renewalDate <= now) { + return { text: "Due Now", variant: "danger" as const }; + } + + const daysUntilRenewal = Math.ceil( + (renewalDate.getTime() - now.getTime()) / (24 * 60 * 60 * 1000) + ); + + if (daysUntilRenewal <= 7) { + return { text: `Renews in ${daysUntilRenewal}d`, variant: "primary" as const }; + } + + return { text: `Renews in ${daysUntilRenewal}d`, variant: "success" as const }; +}; + type Props = { handlePopUpOpen: ( popUpName: keyof UsePopUpState< - ["certificate", "deleteCertificate", "revokeCertificate", "certificateCert"] + [ + "certificate", + "deleteCertificate", + "revokeCertificate", + "certificateCert", + "manageRenewal", + "renewCertificate" + ] >, data?: { serialNumber?: string; commonName?: string; + certificateId?: string; + profileId?: string; + renewBeforeDays?: number; + ttlDays?: number; + notAfter?: string; + renewalError?: string; + renewedFromId?: string; + renewedById?: string; } ) => void; }; @@ -69,10 +142,10 @@ export const CertificatesTable = ({ handlePopUpOpen }: Props) => { limit: perPage }); - // Fetch CA data to determine capabilities + const { mutateAsync: updateRenewalConfig } = useUpdateRenewalConfig(); + const { data: caData } = useListCasByProjectId(currentProject?.id ?? ""); - // Create mapping from caId to CA type for capability checking const caCapabilityMap = useMemo(() => { if (!caData) return {}; @@ -83,6 +156,35 @@ export const CertificatesTable = ({ handlePopUpOpen }: Props) => { return map; }, [caData]); + const handleDisableAutoRenewal = async (certificateId: string, commonName: string) => { + try { + if (!currentProject?.slug) { + createNotification({ + text: "Unable to disable auto-renewal: Project not found. Please refresh the page and try again.", + type: "error" + }); + return; + } + + await updateRenewalConfig({ + certificateId, + projectSlug: currentProject.slug, + disableAutoRenewal: true + }); + + createNotification({ + text: `Auto-renewal disabled for ${commonName}`, + type: "success" + }); + } catch (err) { + console.error(err); + createNotification({ + text: "Failed to disable auto-renewal. Please try again or contact support if the issue persists.", + type: "error" + }); + } + }; + return ( @@ -92,14 +194,16 @@ export const CertificatesTable = ({ handlePopUpOpen }: Props) => { + - {isPending && } + {isPending && } {!isPending && data?.certificates.map((certificate) => { const { variant, label } = getCertValidUntilBadgeDetails(certificate.notAfter); + const autoRenewalInfo = getAutoRenewalInfo(certificate); return ( @@ -120,6 +224,25 @@ export const CertificatesTable = ({ handlePopUpOpen }: Props) => { ? format(new Date(certificate.notAfter), "yyyy-MM-dd") : "-"} + @@ -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 && From 0ba6b5e86b9d0d8afe89b2ac391f2e9cfcf566b5 Mon Sep 17 00:00:00 2001 From: Carlos Monastyrski Date: Fri, 24 Oct 2025 03:02:26 -0300 Subject: [PATCH 3/6] Address PR suggestions --- ...1021112356_add-certificate-auto-renewal.ts | 24 +- backend/src/db/schemas/certificates.ts | 4 +- .../ee/services/audit-log/audit-log-types.ts | 5 + .../server/routes/v3/certificates-router.ts | 29 +- .../internal-certificate-authority-service.ts | 53 ++- .../internal-certificate-authority-types.ts | 5 + .../certificate-constants.ts | 18 - .../certificate-common/certificate-utils.ts | 75 ---- .../certificate-v3/certificate-v3-queue.ts | 56 +-- .../certificate-v3-service.test.ts | 142 +++++-- .../certificate-v3/certificate-v3-service.ts | 397 ++++++++++-------- .../certificate-v3/certificate-v3-types.ts | 3 + .../services/certificate/certificate-dal.ts | 31 +- .../services/certificate/certificate-types.ts | 5 + .../src/services/project/project-service.ts | 2 +- .../src/hooks/api/certificates/mutations.tsx | 4 +- frontend/src/hooks/api/certificates/types.ts | 6 +- .../CertificateManageRenewalModal.tsx | 30 +- .../CertificateRenewalDisableModal.tsx | 2 +- .../components/CertificatesTable.tsx | 104 +++-- .../components/useCertificateTemplate.ts | 22 +- 21 files changed, 578 insertions(+), 439 deletions(-) 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) => {
- + @@ -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 From fe2d57154b8de92d031bd108cc50188ef08f8000 Mon Sep 17 00:00:00 2001 From: Carlos Monastyrski Date: Fri, 24 Oct 2025 19:57:06 -0300 Subject: [PATCH 4/6] Fix sign-certificate logic and minor improvements on the certificates table --- backend/src/server/routes/index.ts | 1 + .../src/server/routes/v1/project-router.ts | 2 +- .../server/routes/v3/certificates-router.ts | 13 +- .../internal-certificate-authority-service.ts | 3 +- .../certificate-csr-utils.ts | 183 ++++++++++++++++++ .../certificate-est-v3-service.ts | 77 +------- .../certificate-v3-service.test.ts | 67 +++++++ .../certificate-v3/certificate-v3-service.ts | 96 ++++++--- .../certificate-v3/certificate-v3-types.ts | 2 - .../services/certificate/certificate-dal.ts | 40 +++- .../src/services/project/project-service.ts | 4 +- frontend/src/hooks/api/certificates/types.ts | 1 + .../components/CertificatesTable.tsx | 17 +- 13 files changed, 386 insertions(+), 120 deletions(-) create mode 100644 backend/src/services/certificate-common/certificate-csr-utils.ts diff --git a/backend/src/server/routes/index.ts b/backend/src/server/routes/index.ts index d7a84e87f..923a497a0 100644 --- a/backend/src/server/routes/index.ts +++ b/backend/src/server/routes/index.ts @@ -2137,6 +2137,7 @@ export const registerRoutes = async ( const certificateV3Service = certificateV3ServiceFactory({ certificateDAL, + certificateSecretDAL, certificateAuthorityDAL, certificateProfileDAL, certificateTemplateV2Service, diff --git a/backend/src/server/routes/v1/project-router.ts b/backend/src/server/routes/v1/project-router.ts index c1f4140e5..c3bffa2fc 100644 --- a/backend/src/server/routes/v1/project-router.ts +++ b/backend/src/server/routes/v1/project-router.ts @@ -1200,7 +1200,7 @@ export const registerProjectRouter = async (server: FastifyZodProvider) => { }), response: { 200: z.object({ - certificates: z.array(CertificatesSchema), + certificates: z.array(CertificatesSchema.extend({ hasPrivateKey: z.boolean() })), totalCount: z.number() }) } diff --git a/backend/src/server/routes/v3/certificates-router.ts b/backend/src/server/routes/v3/certificates-router.ts index 52ad47772..d2d696596 100644 --- a/backend/src/server/routes/v3/certificates-router.ts +++ b/backend/src/server/routes/v3/certificates-router.ts @@ -18,6 +18,7 @@ import { CertKeyUsageType, CertSubjectAlternativeNameType } from "@app/services/certificate-common/certificate-constants"; +import { extractCertificateRequestFromCSR } from "@app/services/certificate-common/certificate-csr-utils"; import { mapEnumsForValidation } from "@app/services/certificate-common/certificate-utils"; import { validateTemplateRegexField } from "@app/services/certificate-template/certificate-template-validators"; @@ -169,9 +170,7 @@ export const registerCertificatesRouter = async (server: FastifyZodProvider) => .min(1, "TTL cannot be empty") .refine((val) => ms(val) > 0, "TTL must be a positive number"), notBefore: validateCaDateField.optional(), - notAfter: validateCaDateField.optional(), - signatureAlgorithm: z.nativeEnum(CertSignatureAlgorithm), - keyAlgorithm: z.nativeEnum(CertKeyAlgorithm) + notAfter: validateCaDateField.optional() }) .refine(validateTtlAndDateFields, { message: @@ -192,6 +191,8 @@ export const registerCertificatesRouter = async (server: FastifyZodProvider) => }, onRequest: verifyAuth([AuthMode.JWT, AuthMode.IDENTITY_ACCESS_TOKEN]), handler: async (req) => { + const certificateRequest = extractCertificateRequestFromCSR(req.body.csr); + const data = await server.services.certificateV3.signCertificateFromProfile({ actor: req.permission.type, actorId: req.permission.id, @@ -203,9 +204,7 @@ export const registerCertificatesRouter = async (server: FastifyZodProvider) => ttl: req.body.ttl }, notBefore: req.body.notBefore ? new Date(req.body.notBefore) : undefined, - notAfter: req.body.notAfter ? new Date(req.body.notAfter) : undefined, - signatureAlgorithm: req.body.signatureAlgorithm, - keyAlgorithm: req.body.keyAlgorithm + notAfter: req.body.notAfter ? new Date(req.body.notAfter) : undefined }); await server.services.auditLog.createAuditLog({ @@ -217,7 +216,7 @@ export const registerCertificatesRouter = async (server: FastifyZodProvider) => certificateProfileId: req.body.profileId, certificateId: data.certificateId, profileName: data.profileName, - commonName: "" + commonName: certificateRequest.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 94c74939a..a7292e366 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 @@ -1728,7 +1728,8 @@ export const internalCertificateAuthorityServiceFactory = ({ certificateAuthorityDAL, certificateAuthoritySecretDAL, projectDAL, - kmsService + kmsService, + signatureAlgorithm: alg }); const caCrl = await certificateAuthorityCrlDAL.findOne({ caSecretId: caSecret.id }); diff --git a/backend/src/services/certificate-common/certificate-csr-utils.ts b/backend/src/services/certificate-common/certificate-csr-utils.ts new file mode 100644 index 000000000..7578950af --- /dev/null +++ b/backend/src/services/certificate-common/certificate-csr-utils.ts @@ -0,0 +1,183 @@ +import * as x509 from "@peculiar/x509"; + +import { BadRequestError } from "@app/lib/errors"; + +import { + CertExtendedKeyUsageOIDToName, + CertKeyAlgorithm, + CertKeyUsage, + CertSignatureAlgorithm, + mapLegacyAltNameType, + TAltNameMapping, + TAltNameType +} from "../certificate/certificate-types"; +import { parseDistinguishedName } from "../certificate-authority/certificate-authority-fns"; +import { validateAndMapAltNameType } from "../certificate-authority/certificate-authority-validators"; +import { TCertificateRequest } from "../certificate-template-v2/certificate-template-v2-types"; +import { mapLegacyExtendedKeyUsageToStandard, mapLegacyKeyUsageToStandard } from "./certificate-constants"; + +/** + * Extracts certificate request data from a CSR string + * @param csr - The CSR in PEM format + * @returns TCertificateRequest object with parsed CSR data + */ +export const extractCertificateRequestFromCSR = (csr: string): TCertificateRequest => { + const csrObj = new x509.Pkcs10CertificateRequest(csr); + const subject = parseDistinguishedName(csrObj.subject); + + const certificateRequest: TCertificateRequest = { + commonName: subject.commonName, + organization: subject.organization, + organizationUnit: subject.ou, + locality: subject.locality, + state: subject.province, + country: subject.country + }; + + const csrKeyUsageExtension = csrObj.getExtension("2.5.29.15") as x509.KeyUsagesExtension; + if (csrKeyUsageExtension) { + const csrKeyUsages = Object.values(CertKeyUsage).filter( + // eslint-disable-next-line no-bitwise + (keyUsage) => (x509.KeyUsageFlags[keyUsage] & csrKeyUsageExtension.usages) !== 0 + ); + certificateRequest.keyUsages = csrKeyUsages.map(mapLegacyKeyUsageToStandard); + } + + const csrExtendedKeyUsageExtension = csrObj.getExtension("2.5.29.37") as x509.ExtendedKeyUsageExtension; + if (csrExtendedKeyUsageExtension) { + const csrExtendedKeyUsages = csrExtendedKeyUsageExtension.usages.map( + (ekuOid) => CertExtendedKeyUsageOIDToName[ekuOid as string] + ); + certificateRequest.extendedKeyUsages = csrExtendedKeyUsages.map(mapLegacyExtendedKeyUsageToStandard); + } + + const sanExtension = csrObj.extensions.find((ext) => ext.type === "2.5.29.17"); + if (sanExtension) { + const sanNames = new x509.GeneralNames(sanExtension.value); + const altNamesArray: TAltNameMapping[] = sanNames.items + .filter( + (value) => + value.type === TAltNameType.EMAIL || + value.type === TAltNameType.DNS || + value.type === TAltNameType.IP || + value.type === TAltNameType.URL + ) + .map((name): TAltNameMapping => { + const altNameType = validateAndMapAltNameType(name.value); + if (!altNameType) { + throw new BadRequestError({ message: `Invalid altName from CSR: ${name.value}` }); + } + return altNameType; + }); + + certificateRequest.subjectAlternativeNames = altNamesArray.map((altName) => ({ + type: mapLegacyAltNameType(altName.type), + value: altName.value + })); + } + + return certificateRequest; +}; + +/** + * Extracts the key algorithm and signature algorithm from a CSR + * @param csr - The CSR in PEM format + * @returns Object containing keyAlgorithm and signatureAlgorithm + */ +export const extractAlgorithmsFromCSR = (csr: string) => { + const csrObj = new x509.Pkcs10CertificateRequest(csr); + + // Extract key algorithm from public key + const { publicKey } = csrObj; + let keyAlgorithm: CertKeyAlgorithm; + + if (publicKey.algorithm.name === "RSASSA-PKCS1-v1_5") { + const rsaPublicKey = publicKey as unknown as { algorithm: { modulusLength: number } }; + const keySize = rsaPublicKey.algorithm.modulusLength; + switch (keySize) { + case 2048: + keyAlgorithm = CertKeyAlgorithm.RSA_2048; + break; + case 3072: + keyAlgorithm = CertKeyAlgorithm.RSA_3072; + break; + case 4096: + keyAlgorithm = CertKeyAlgorithm.RSA_4096; + break; + default: + throw new BadRequestError({ + message: `Unsupported RSA key size in CSR: ${keySize}. Supported: 2048, 3072, 4096` + }); + } + } else if (publicKey.algorithm.name === "ECDSA") { + const ecPublicKey = publicKey as unknown as { algorithm: { namedCurve: string } }; + const { namedCurve } = ecPublicKey.algorithm; + switch (namedCurve) { + case "P-256": + keyAlgorithm = CertKeyAlgorithm.ECDSA_P256; + break; + case "P-384": + keyAlgorithm = CertKeyAlgorithm.ECDSA_P384; + break; + case "P-521": + keyAlgorithm = CertKeyAlgorithm.ECDSA_P521; + break; + default: + throw new BadRequestError({ + message: `Unsupported ECDSA curve in CSR: ${namedCurve}. Supported: P-256, P-384, P-521` + }); + } + } else { + throw new BadRequestError({ + message: `Unsupported key algorithm in CSR: ${publicKey.algorithm.name}. Supported: RSASSA-PKCS1-v1_5, ECDSA` + }); + } + + const signatureAlgorithm = csrObj.signatureAlgorithm.name; + const hashName = (csrObj.signatureAlgorithm as unknown as { hash?: { name: string } }).hash?.name; + + let normalizedSignatureAlg: CertSignatureAlgorithm; + + if (signatureAlgorithm === "RSASSA-PKCS1-v1_5") { + switch (hashName) { + case "SHA-256": + normalizedSignatureAlg = CertSignatureAlgorithm.RSA_SHA256; + break; + case "SHA-384": + normalizedSignatureAlg = CertSignatureAlgorithm.RSA_SHA384; + break; + case "SHA-512": + normalizedSignatureAlg = CertSignatureAlgorithm.RSA_SHA512; + break; + default: + throw new BadRequestError({ + message: `Unsupported RSA hash algorithm in CSR: ${hashName}. Supported: SHA-256, SHA-384, SHA-512` + }); + } + } else if (signatureAlgorithm === "ECDSA") { + switch (hashName) { + case "SHA-256": + normalizedSignatureAlg = CertSignatureAlgorithm.ECDSA_SHA256; + break; + case "SHA-384": + normalizedSignatureAlg = CertSignatureAlgorithm.ECDSA_SHA384; + break; + case "SHA-512": + normalizedSignatureAlg = CertSignatureAlgorithm.ECDSA_SHA512; + break; + default: + throw new BadRequestError({ + message: `Unsupported ECDSA hash algorithm in CSR: ${hashName}. Supported: SHA-256, SHA-384, SHA-512` + }); + } + } else { + throw new BadRequestError({ + message: `Unsupported signature algorithm in CSR: ${signatureAlgorithm}. Supported: RSASSA-PKCS1-v1_5, ECDSA` + }); + } + + return { + keyAlgorithm, + signatureAlgorithm: normalizedSignatureAlg + }; +}; diff --git a/backend/src/services/certificate-est-v3/certificate-est-v3-service.ts b/backend/src/services/certificate-est-v3/certificate-est-v3-service.ts index f6dbfba52..0d8ef30d0 100644 --- a/backend/src/services/certificate-est-v3/certificate-est-v3-service.ts +++ b/backend/src/services/certificate-est-v3/certificate-est-v3-service.ts @@ -3,31 +3,15 @@ import * as x509 from "@peculiar/x509"; import { extractX509CertFromChain } from "@app/lib/certificates/extract-certificate"; import { BadRequestError, NotFoundError, UnauthorizedError } from "@app/lib/errors"; import { isCertChainValid } from "@app/services/certificate/certificate-fns"; -import { - CertExtendedKeyUsageOIDToName, - CertKeyUsage, - mapLegacyAltNameType, - TAltNameMapping, - TAltNameType -} from "@app/services/certificate/certificate-types"; import { TCertificateAuthorityCertDALFactory } from "@app/services/certificate-authority/certificate-authority-cert-dal"; import { TCertificateAuthorityDALFactory } from "@app/services/certificate-authority/certificate-authority-dal"; -import { - getCaCertChain, - getCaCertChains, - parseDistinguishedName -} from "@app/services/certificate-authority/certificate-authority-fns"; -import { validateAndMapAltNameType } from "@app/services/certificate-authority/certificate-authority-validators"; +import { getCaCertChain, getCaCertChains } from "@app/services/certificate-authority/certificate-authority-fns"; import { TInternalCertificateAuthorityServiceFactory } from "@app/services/certificate-authority/internal/internal-certificate-authority-service"; -import { - mapLegacyExtendedKeyUsageToStandard, - mapLegacyKeyUsageToStandard -} from "@app/services/certificate-common/certificate-constants"; +import { extractCertificateRequestFromCSR } from "@app/services/certificate-common/certificate-csr-utils"; import { mapEnumsForValidation } from "@app/services/certificate-common/certificate-utils"; import { TCertificateProfileDALFactory } from "@app/services/certificate-profile/certificate-profile-dal"; import { EnrollmentType } from "@app/services/certificate-profile/certificate-profile-types"; import { TCertificateTemplateV2ServiceFactory } from "@app/services/certificate-template-v2/certificate-template-v2-service"; -import { TCertificateRequest } from "@app/services/certificate-template-v2/certificate-template-v2-types"; import { TEstEnrollmentConfigDALFactory } from "@app/services/enrollment-config/est-enrollment-config-dal"; import { TKmsServiceFactory } from "@app/services/kms/kms-service"; import { TProjectDALFactory } from "@app/services/project/project-dal"; @@ -61,63 +45,6 @@ export const certificateEstV3ServiceFactory = ({ certificateProfileDAL, estEnrollmentConfigDAL }: TCertificateEstV3ServiceFactoryDep) => { - const extractCertificateRequestFromCSR = (csr: string): TCertificateRequest => { - const csrObj = new x509.Pkcs10CertificateRequest(csr); - const subject = parseDistinguishedName(csrObj.subject); - - const certificateRequest: TCertificateRequest = { - commonName: subject.commonName, - organization: subject.organization, - organizationUnit: subject.ou, - locality: subject.locality, - state: subject.province, - country: subject.country - }; - - const csrKeyUsageExtension = csrObj.getExtension("2.5.29.15") as x509.KeyUsagesExtension; - if (csrKeyUsageExtension) { - const csrKeyUsages = Object.values(CertKeyUsage).filter( - // eslint-disable-next-line no-bitwise - (keyUsage) => (x509.KeyUsageFlags[keyUsage] & csrKeyUsageExtension.usages) !== 0 - ); - certificateRequest.keyUsages = csrKeyUsages.map(mapLegacyKeyUsageToStandard); - } - - const csrExtendedKeyUsageExtension = csrObj.getExtension("2.5.29.37") as x509.ExtendedKeyUsageExtension; - if (csrExtendedKeyUsageExtension) { - const csrExtendedKeyUsages = csrExtendedKeyUsageExtension.usages.map( - (ekuOid) => CertExtendedKeyUsageOIDToName[ekuOid as string] - ); - certificateRequest.extendedKeyUsages = csrExtendedKeyUsages.map(mapLegacyExtendedKeyUsageToStandard); - } - - const sanExtension = csrObj.extensions.find((ext) => ext.type === "2.5.29.17"); - if (sanExtension) { - const sanNames = new x509.GeneralNames(sanExtension.value); - const altNamesArray: TAltNameMapping[] = sanNames.items - .filter( - (value) => - value.type === TAltNameType.EMAIL || - value.type === TAltNameType.DNS || - value.type === TAltNameType.IP || - value.type === TAltNameType.URL - ) - .map((name): TAltNameMapping => { - const altNameType = validateAndMapAltNameType(name.value); - if (!altNameType) { - throw new BadRequestError({ message: `Invalid altName from CSR: ${name.value}` }); - } - return altNameType; - }); - - certificateRequest.subjectAlternativeNames = altNamesArray.map((altName) => ({ - type: mapLegacyAltNameType(altName.type), - value: altName.value - })); - } - - return certificateRequest; - }; const simpleEnrollByProfile = async ({ csr, profileId, 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 2dab87b4a..0c70571dd 100644 --- a/backend/src/services/certificate-v3/certificate-v3-service.test.ts +++ b/backend/src/services/certificate-v3/certificate-v3-service.test.ts @@ -9,6 +9,7 @@ import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; import { TPermissionServiceFactory } from "@app/ee/services/permission/permission-service-types"; import { BadRequestError, ForbiddenRequestError, NotFoundError } from "@app/lib/errors"; import { TCertificateDALFactory } from "@app/services/certificate/certificate-dal"; +import { TCertificateSecretDALFactory } from "@app/services/certificate/certificate-secret-dal"; import { ACMESANType, CertificateOrderStatus, CertStatus } from "@app/services/certificate/certificate-types"; import { TCertificateAuthorityDALFactory } from "@app/services/certificate-authority/certificate-authority-dal"; import { CaStatus } from "@app/services/certificate-authority/certificate-authority-enums"; @@ -24,8 +25,17 @@ import { EnrollmentType } from "@app/services/certificate-profile/certificate-pr import { TCertificateTemplateV2ServiceFactory } from "@app/services/certificate-template-v2/certificate-template-v2-service"; import { ActorType, AuthMethod } from "../auth/auth-type"; +import { + extractAlgorithmsFromCSR, + extractCertificateRequestFromCSR +} from "../certificate-common/certificate-csr-utils"; import { certificateV3ServiceFactory, TCertificateV3ServiceFactory } from "./certificate-v3-service"; +vi.mock("../certificate-common/certificate-csr-utils", () => ({ + extractCertificateRequestFromCSR: vi.fn(), + extractAlgorithmsFromCSR: vi.fn() +})); + describe("CertificateV3Service", () => { let service: TCertificateV3ServiceFactory; @@ -39,6 +49,10 @@ describe("CertificateV3Service", () => { }) }; + const mockCertificateSecretDAL: Pick = { + findOne: vi.fn() + }; + const mockCertificateAuthorityDAL: Pick = { findByIdWithAssociatedCa: vi.fn() }; @@ -101,8 +115,20 @@ describe("CertificateV3Service", () => { } }); + vi.mocked(extractCertificateRequestFromCSR).mockReturnValue({ + commonName: "test.example.com", + keyUsages: [CertKeyUsageType.DIGITAL_SIGNATURE], + extendedKeyUsages: [CertExtendedKeyUsageType.SERVER_AUTH] + }); + + vi.mocked(extractAlgorithmsFromCSR).mockReturnValue({ + keyAlgorithm: "RSA_2048" as any, + signatureAlgorithm: "RSA-SHA256" as any + }); + service = certificateV3ServiceFactory({ certificateDAL: mockCertificateDAL, + certificateSecretDAL: mockCertificateSecretDAL, certificateAuthorityDAL: mockCertificateAuthorityDAL, certificateProfileDAL: mockCertificateProfileDAL, certificateTemplateV2Service: mockCertificateTemplateV2Service, @@ -647,6 +673,11 @@ describe("CertificateV3Service", () => { vi.mocked(mockCertificateProfileDAL.findByIdWithConfigs).mockResolvedValue(mockProfile); vi.mocked(mockCertificateAuthorityDAL.findByIdWithAssociatedCa).mockResolvedValue(mockCA); vi.mocked(mockCertificateTemplateV2Service.getTemplateV2ById).mockResolvedValue(mockTemplate); + vi.mocked(mockCertificateTemplateV2Service.validateCertificateRequest).mockResolvedValue({ + isValid: true, + errors: [], + warnings: [] + }); vi.mocked(mockInternalCaService.signCertFromCa).mockResolvedValue(mockSignResult as any); vi.mocked(mockCertificateDAL.findOne).mockResolvedValue(mockCertRecord); vi.mocked(mockCertificateDAL.updateById).mockResolvedValue(mockCertRecord); @@ -1586,6 +1617,7 @@ describe("CertificateV3Service", () => { it("should successfully renew eligible certificate", async () => { // Mock the initial findById call vi.mocked(mockCertificateDAL.findById).mockResolvedValue(mockOriginalCert); + vi.mocked(mockCertificateSecretDAL.findOne).mockResolvedValue({ id: "secret-123", certId: "cert-123" } as any); vi.mocked(mockCertificateProfileDAL.findByIdWithConfigs).mockResolvedValue(mockProfile); vi.mocked(mockCertificateAuthorityDAL.findByIdWithAssociatedCa).mockResolvedValue(mockCA); vi.mocked(mockCertificateTemplateV2Service.getTemplateV2ById).mockResolvedValue(mockTemplate); @@ -1650,6 +1682,7 @@ describe("CertificateV3Service", () => { errors: ["Subject alternative name not allowed"], warnings: [] }); + vi.mocked(mockCertificateSecretDAL.findOne).mockResolvedValue({ id: "secret-123", certId: "cert-123" } as any); // Mock updateById to handle the renewal error logging vi.mocked(mockCertificateDAL.updateById).mockResolvedValue(mockOriginalCert); @@ -1705,11 +1738,37 @@ describe("CertificateV3Service", () => { ).rejects.toThrow("Only certificates issued from a profile can be renewed"); }); + it("should reject renewal if certificate was issued from CSR (external private key)", async () => { + vi.mocked(mockCertificateDAL.findById).mockResolvedValue(mockOriginalCert); + vi.mocked(mockCertificateProfileDAL.findByIdWithConfigs).mockResolvedValue(mockProfile); + vi.mocked(mockCertificateSecretDAL.findOne).mockResolvedValue(null as any); + + vi.mocked(mockCertificateDAL.transaction).mockImplementation(async (callback: (tx: any) => Promise) => { + const mockTx = {}; + return callback(mockTx); + }); + + await expect( + service.renewCertificate({ + certificateId: "cert-123", + ...mockActor + }) + ).rejects.toThrow(ForbiddenRequestError); + + await expect( + service.renewCertificate({ + certificateId: "cert-123", + ...mockActor + }) + ).rejects.toThrow("certificates issued from CSR (external private key) cannot be renewed"); + }); + it("should reject renewal if certificate is already renewed", async () => { const alreadyRenewedCert = { ...mockOriginalCert, renewedByCertificateId: "cert-456" }; vi.mocked(mockCertificateDAL.findById).mockResolvedValue(alreadyRenewedCert); vi.mocked(mockCertificateProfileDAL.findByIdWithConfigs).mockResolvedValue(mockProfile); vi.mocked(mockCertificateAuthorityDAL.findByIdWithAssociatedCa).mockResolvedValue(mockCA); + vi.mocked(mockCertificateSecretDAL.findOne).mockResolvedValue({ id: "secret-123", certId: "cert-123" } as any); // Mock updateById to handle the renewal error logging vi.mocked(mockCertificateDAL.updateById).mockResolvedValue(alreadyRenewedCert); @@ -1743,6 +1802,7 @@ describe("CertificateV3Service", () => { vi.mocked(mockCertificateDAL.findById).mockResolvedValue(expiredCert); vi.mocked(mockCertificateProfileDAL.findByIdWithConfigs).mockResolvedValue(mockProfile); vi.mocked(mockCertificateAuthorityDAL.findByIdWithAssociatedCa).mockResolvedValue(mockCA); + vi.mocked(mockCertificateSecretDAL.findOne).mockResolvedValue({ id: "secret-123", certId: "cert-123" } as any); // Mock updateById to handle the renewal error logging vi.mocked(mockCertificateDAL.updateById).mockResolvedValue(expiredCert); @@ -1776,6 +1836,7 @@ describe("CertificateV3Service", () => { vi.mocked(mockCertificateDAL.findById).mockResolvedValue(revokedCert); vi.mocked(mockCertificateProfileDAL.findByIdWithConfigs).mockResolvedValue(mockProfile); vi.mocked(mockCertificateAuthorityDAL.findByIdWithAssociatedCa).mockResolvedValue(mockCA); + vi.mocked(mockCertificateSecretDAL.findOne).mockResolvedValue({ id: "secret-123", certId: "cert-123" } as any); // Mock updateById to handle the renewal error logging vi.mocked(mockCertificateDAL.updateById).mockResolvedValue(revokedCert); @@ -1806,6 +1867,7 @@ describe("CertificateV3Service", () => { vi.mocked(mockCertificateDAL.findById).mockResolvedValue(mockOriginalCert); vi.mocked(mockCertificateProfileDAL.findByIdWithConfigs).mockResolvedValue(mockProfile); vi.mocked(mockCertificateAuthorityDAL.findByIdWithAssociatedCa).mockResolvedValue(inactiveCA); + vi.mocked(mockCertificateSecretDAL.findOne).mockResolvedValue({ id: "secret-123", certId: "cert-123" } as any); // Mock updateById to handle the renewal error logging vi.mocked(mockCertificateDAL.updateById).mockResolvedValue(mockOriginalCert); @@ -1842,6 +1904,7 @@ describe("CertificateV3Service", () => { vi.mocked(mockCertificateDAL.findById).mockResolvedValue(mockOriginalCert); vi.mocked(mockCertificateProfileDAL.findByIdWithConfigs).mockResolvedValue(mockProfile); vi.mocked(mockCertificateAuthorityDAL.findByIdWithAssociatedCa).mockResolvedValue(shortLivedCA); + vi.mocked(mockCertificateSecretDAL.findOne).mockResolvedValue({ id: "secret-123", certId: "cert-123" } as any); // Mock updateById to handle the renewal error logging vi.mocked(mockCertificateDAL.updateById).mockResolvedValue(mockOriginalCert); @@ -1873,6 +1936,7 @@ describe("CertificateV3Service", () => { vi.mocked(mockCertificateDAL.findById).mockResolvedValue(mockOriginalCert); vi.mocked(mockCertificateProfileDAL.findByIdWithConfigs).mockResolvedValue(mockProfile); vi.mocked(mockCertificateAuthorityDAL.findByIdWithAssociatedCa).mockResolvedValue(mockCA); + vi.mocked(mockCertificateSecretDAL.findOne).mockResolvedValue({ id: "secret-123", certId: "cert-123" } as any); vi.mocked(mockCertificateTemplateV2Service.getTemplateV2ById).mockResolvedValue(mockTemplate); vi.mocked(mockCertificateTemplateV2Service.validateCertificateRequest).mockResolvedValue({ isValid: true, @@ -1929,6 +1993,7 @@ describe("CertificateV3Service", () => { vi.mocked(mockCertificateDAL.findById).mockResolvedValue(mockCert as any); vi.mocked(mockCertificateProfileDAL.findByIdWithConfigs).mockResolvedValue(mockProfile as any); + vi.mocked(mockCertificateSecretDAL.findOne).mockResolvedValue({ id: "secret-123", certId: "cert-123" } as any); vi.mocked(mockCertificateDAL.updateById).mockResolvedValue(mockCert as any); const result = await service.updateRenewalConfig({ @@ -2004,6 +2069,7 @@ describe("CertificateV3Service", () => { vi.mocked(mockCertificateDAL.findById).mockResolvedValue(mockCert as any); vi.mocked(mockCertificateProfileDAL.findByIdWithConfigs).mockResolvedValue(mockProfile as any); + vi.mocked(mockCertificateSecretDAL.findOne).mockResolvedValue({ id: "secret-123", certId: "cert-123" } as any); await expect( service.updateRenewalConfig({ @@ -2048,6 +2114,7 @@ describe("CertificateV3Service", () => { vi.mocked(mockCertificateDAL.findById).mockResolvedValue(mockCert as any); vi.mocked(mockCertificateProfileDAL.findByIdWithConfigs).mockResolvedValue(mockProfile as any); + vi.mocked(mockCertificateSecretDAL.findOne).mockResolvedValue({ id: "secret-123", certId: "cert-123" } as any); await expect( service.updateRenewalConfig({ diff --git a/backend/src/services/certificate-v3/certificate-v3-service.ts b/backend/src/services/certificate-v3/certificate-v3-service.ts index 42980f8a7..0a721b2db 100644 --- a/backend/src/services/certificate-v3/certificate-v3-service.ts +++ b/backend/src/services/certificate-v3/certificate-v3-service.ts @@ -12,6 +12,7 @@ import { import { BadRequestError, ForbiddenRequestError, NotFoundError } from "@app/lib/errors"; import { ActorAuthMethod, ActorType } from "@app/services/auth/auth-type"; import { TCertificateDALFactory } from "@app/services/certificate/certificate-dal"; +import { TCertificateSecretDALFactory } from "@app/services/certificate/certificate-secret-dal"; import { CertExtendedKeyUsage, CertificateOrderStatus, @@ -32,6 +33,10 @@ import { EnrollmentType } from "@app/services/certificate-profile/certificate-pr import { TCertificateTemplateV2ServiceFactory } from "@app/services/certificate-template-v2/certificate-template-v2-service"; import { CertSubjectAlternativeNameType } from "../certificate-common/certificate-constants"; +import { + extractAlgorithmsFromCSR, + extractCertificateRequestFromCSR +} from "../certificate-common/certificate-csr-utils"; import { bufferToString, buildCertificateSubjectFromTemplate, @@ -58,6 +63,7 @@ import { type TCertificateV3ServiceFactoryDep = { certificateDAL: Pick; + certificateSecretDAL: Pick; certificateAuthorityDAL: Pick; certificateProfileDAL: Pick; certificateTemplateV2Service: Pick< @@ -296,8 +302,28 @@ const parseTtlToDays = (ttl: string): number => { } }; +const calculateFinalRenewBeforeDays = ( + profile: { apiConfig?: { autoRenew?: boolean; renewBeforeDays?: number } }, + ttl: string, + certificateExpiryDate: Date +): number | undefined => { + if (!profile.apiConfig?.autoRenew || !profile.apiConfig.renewBeforeDays) { + return undefined; + } + + const certificateTtlInDays = parseTtlToDays(ttl); + const renewBeforeDays = calculateRenewalThreshold(profile.apiConfig.renewBeforeDays, certificateTtlInDays); + + if (!renewBeforeDays) { + return undefined; + } + + return isValidRenewalTiming(renewBeforeDays, certificateExpiryDate) ? renewBeforeDays : undefined; +}; + export const certificateV3ServiceFactory = ({ certificateDAL, + certificateSecretDAL, certificateAuthorityDAL, certificateProfileDAL, certificateTemplateV2Service, @@ -412,11 +438,11 @@ export const certificateV3ServiceFactory = ({ throw new NotFoundError({ message: "Certificate was issued but could not be found in database" }); } - const certificateTtlInDays = parseTtlToDays(certificateRequest.validity.ttl); - const renewBeforeDays = calculateRenewalThreshold(profile.apiConfig?.renewBeforeDays, certificateTtlInDays); - - const finalRenewBeforeDays = - renewBeforeDays && isValidRenewalTiming(renewBeforeDays, new Date(cert.notAfter)) ? renewBeforeDays : undefined; + const finalRenewBeforeDays = calculateFinalRenewBeforeDays( + profile, + certificateRequest.validity.ttl, + new Date(cert.notAfter) + ); await certificateDAL.updateById(cert.id, { profileId, @@ -442,8 +468,6 @@ export const certificateV3ServiceFactory = ({ validity, notBefore, notAfter, - signatureAlgorithm, - keyAlgorithm, actor, actorId, actorAuthMethod, @@ -480,22 +504,27 @@ export const certificateV3ServiceFactory = ({ throw new NotFoundError({ message: "Certificate template not found for this profile" }); } + const certificateRequest = extractCertificateRequestFromCSR(csr); + const mappedCertificateRequest = mapEnumsForValidation(certificateRequest); + + const { keyAlgorithm: extractedKeyAlgorithm, signatureAlgorithm: extractedSignatureAlgorithm } = + extractAlgorithmsFromCSR(csr); + + const validationResult = await certificateTemplateV2Service.validateCertificateRequest( + profile.certificateTemplateId, + mappedCertificateRequest + ); + + if (!validationResult.isValid) { + throw new BadRequestError({ + message: `Certificate request validation failed: ${validationResult.errors.join(", ")}` + }); + } + validateAlgorithmCompatibility(ca, template); - const effectiveSignatureAlgorithm = signatureAlgorithm; - const effectiveKeyAlgorithm = keyAlgorithm; - - if (template.algorithms?.keyAlgorithm && !effectiveKeyAlgorithm) { - throw new BadRequestError({ - message: "Key algorithm is required by template policy but not provided in request" - }); - } - - if (template.algorithms?.signature && !effectiveSignatureAlgorithm) { - throw new BadRequestError({ - message: "Signature algorithm is required by template policy but not provided in request" - }); - } + const effectiveSignatureAlgorithm = extractedSignatureAlgorithm; + const effectiveKeyAlgorithm = extractedKeyAlgorithm; const { certificate, certificateChain, issuingCaCertificate, serialNumber } = await internalCaService.signCertFromCa({ @@ -516,11 +545,7 @@ export const certificateV3ServiceFactory = ({ throw new NotFoundError({ message: "Certificate was signed but could not be found in database" }); } - const certificateTtlInDays = parseTtlToDays(validity.ttl); - const renewBeforeDays = calculateRenewalThreshold(profile.apiConfig?.renewBeforeDays, certificateTtlInDays); - - const finalRenewBeforeDays = - renewBeforeDays && isValidRenewalTiming(renewBeforeDays, new Date(cert.notAfter)) ? renewBeforeDays : undefined; + const finalRenewBeforeDays = calculateFinalRenewBeforeDays(profile, validity.ttl, new Date(cert.notAfter)); await certificateDAL.updateById(cert.id, { profileId, @@ -675,6 +700,14 @@ export const certificateV3ServiceFactory = ({ }); } + const certificateSecret = await certificateSecretDAL.findOne({ certId: originalCert.id }, tx); + if (!certificateSecret) { + throw new ForbiddenRequestError({ + message: + "Certificate is not eligible for renewal: certificates issued from CSR (external private key) cannot be renewed" + }); + } + if (!internal) { const { permission } = await permissionService.getProjectPermission({ actor, @@ -791,8 +824,7 @@ export const certificateV3ServiceFactory = ({ 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 finalRenewBeforeDays = calculateFinalRenewBeforeDays(profile, ttl, notAfter); const { certificate, certificateChain, issuingCaCertificate, serialNumber } = await internalCaService.issueCertFromCa({ @@ -907,6 +939,14 @@ export const certificateV3ServiceFactory = ({ }); } + const certificateSecret = await certificateSecretDAL.findOne({ certId: certificate.id }); + if (!certificateSecret) { + throw new ForbiddenRequestError({ + message: + "Certificate is not eligible for auto-renewal: certificates issued from CSR (external private key) cannot be auto-renewed" + }); + } + if (certificate.status !== CertStatus.ACTIVE) { throw new BadRequestError({ message: `Certificate is not eligible for auto-renewal: certificate status is ${certificate.status}, must be active` diff --git a/backend/src/services/certificate-v3/certificate-v3-types.ts b/backend/src/services/certificate-v3/certificate-v3-types.ts index 9bbc7f743..a62a25b73 100644 --- a/backend/src/services/certificate-v3/certificate-v3-types.ts +++ b/backend/src/services/certificate-v3/certificate-v3-types.ts @@ -35,8 +35,6 @@ export type TSignCertificateFromProfileDTO = { }; notBefore?: Date; notAfter?: Date; - signatureAlgorithm?: string; - keyAlgorithm?: string; } & Omit; export type TOrderCertificateFromProfileDTO = { diff --git a/backend/src/services/certificate/certificate-dal.ts b/backend/src/services/certificate/certificate-dal.ts index d69f063ff..eb40b85a5 100644 --- a/backend/src/services/certificate/certificate-dal.ts +++ b/backend/src/services/certificate/certificate-dal.ts @@ -134,6 +134,7 @@ export const certificateDALFactory = (db: TDbClient) => { `${TableName.Certificate}.profileId`, `${TableName.PkiCertificateProfile}.id` ) + .innerJoin(TableName.CertificateSecret, `${TableName.Certificate}.id`, `${TableName.CertificateSecret}.certId`) .where(`${TableName.Certificate}.status`, CertStatus.ACTIVE) .whereNull(`${TableName.Certificate}.renewedByCertificateId`) .whereNull(`${TableName.Certificate}.renewalError`) @@ -157,6 +158,42 @@ export const certificateDALFactory = (db: TDbClient) => { } }; + const findWithPrivateKeyInfo = async ( + filter: Partial, + options?: { offset?: number; limit?: number; sort?: [string, "asc" | "desc"][] } + ): Promise<(TCertificates & { hasPrivateKey: boolean })[]> => { + try { + let query = db + .replicaNode()(TableName.Certificate) + .leftJoin(TableName.CertificateSecret, `${TableName.Certificate}.id`, `${TableName.CertificateSecret}.certId`) + .select(selectAllTableCols(TableName.Certificate)) + .select(db.ref(`${TableName.CertificateSecret}.certId`).as("privateKeyRef")) + .where(filter); + + if (options?.offset) { + query = query.offset(options.offset); + } + if (options?.limit) { + query = query.limit(options.limit); + } + if (options?.sort) { + options.sort.forEach(([column, direction]) => { + query = query.orderBy(column, direction); + }); + } + + const results = await query; + return results.map((row) => { + return { + ...row, + hasPrivateKey: row.privateKeyRef !== null + }; + }); + } catch (error) { + throw new DatabaseError({ error, name: "Find certificates with private key info" }); + } + }; + return { ...certificateOrm, countCertificatesInProject, @@ -164,6 +201,7 @@ export const certificateDALFactory = (db: TDbClient) => { findLatestActiveCertForSubscriber, findAllActiveCertsForSubscriber, findExpiredSyncedCertificates, - findCertificatesEligibleForRenewal + findCertificatesEligibleForRenewal, + findWithPrivateKeyInfo }; }; diff --git a/backend/src/services/project/project-service.ts b/backend/src/services/project/project-service.ts index f38764dd9..05b007a6a 100644 --- a/backend/src/services/project/project-service.ts +++ b/backend/src/services/project/project-service.ts @@ -154,7 +154,7 @@ type TProjectServiceFactoryDep = { >; pkiSubscriberDAL: Pick; certificateAuthorityDAL: Pick; - certificateDAL: Pick; + certificateDAL: Pick; certificateTemplateDAL: Pick; pkiAlertDAL: Pick; pkiCollectionDAL: Pick; @@ -938,7 +938,7 @@ export const projectServiceFactory = ({ ProjectPermissionSub.Certificates ); - const certificates = await certificateDAL.find( + const certificates = await certificateDAL.findWithPrivateKeyInfo( { projectId, ...(friendlyName && { friendlyName }), diff --git a/frontend/src/hooks/api/certificates/types.ts b/frontend/src/hooks/api/certificates/types.ts index d38fcace0..622276e24 100644 --- a/frontend/src/hooks/api/certificates/types.ts +++ b/frontend/src/hooks/api/certificates/types.ts @@ -19,6 +19,7 @@ export type TCertificate = { renewedFromCertificateId?: string; renewedByCertificateId?: string; renewalError?: string; + hasPrivateKey?: boolean; }; export type TDeleteCertDTO = { diff --git a/frontend/src/pages/cert-manager/CertificatesPage/components/CertificatesTable.tsx b/frontend/src/pages/cert-manager/CertificatesPage/components/CertificatesTable.tsx index 6114583d6..11aa2356e 100644 --- a/frontend/src/pages/cert-manager/CertificatesPage/components/CertificatesTable.tsx +++ b/frontend/src/pages/cert-manager/CertificatesPage/components/CertificatesTable.tsx @@ -70,7 +70,7 @@ const getAutoRenewalInfo = (certificate: TCertificate) => { return { text: "Not Available", variant: "instance" as const, - tooltip: "Auto-renewal is not available for revoked certificates" + tooltip: "Renewal is not available for revoked certificates" }; } @@ -78,7 +78,7 @@ const getAutoRenewalInfo = (certificate: TCertificate) => { return { text: "Not Available", variant: "instance" as const, - tooltip: "Auto-renewal is not available for expired certificates" + tooltip: "Renewal is not available for expired certificates" }; } @@ -86,7 +86,15 @@ const getAutoRenewalInfo = (certificate: TCertificate) => { return { text: "Not Available", variant: "instance" as const, - tooltip: "Auto-renewal requires a certificate profile" + tooltip: "Renewal requires a certificate profile" + }; + } + + if (certificate.hasPrivateKey === false) { + return { + text: "Not Available", + variant: "instance" as const, + tooltip: "Renewal is not available for certificates with externally generated private keys" }; } @@ -344,6 +352,7 @@ export const CertificatesTable = ({ handlePopUpOpen }: Props) => { {(() => { const canManageRenewal = certificate.profileId && + certificate.hasPrivateKey !== false && !certificate.renewedByCertificateId && !isRevoked && !isExpired && @@ -407,6 +416,7 @@ export const CertificatesTable = ({ handlePopUpOpen }: Props) => { {(() => { const canDisableRenewal = certificate.profileId && + certificate.hasPrivateKey !== false && !certificate.renewedByCertificateId && !isRevoked && !isExpired && @@ -445,6 +455,7 @@ export const CertificatesTable = ({ handlePopUpOpen }: Props) => { {(() => { const canRenew = certificate.profileId && + certificate.hasPrivateKey !== false && !certificate.renewedByCertificateId && !isRevoked && !isExpired; From 8f96ffa7c8576903a9d1c93d88e391f6d028ec64 Mon Sep 17 00:00:00 2001 From: Carlos Monastyrski Date: Mon, 27 Oct 2025 09:47:09 -0300 Subject: [PATCH 5/6] Small change on the renew column badge for disabled options --- .../CertificatesPage/components/CertificatesTable.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/frontend/src/pages/cert-manager/CertificatesPage/components/CertificatesTable.tsx b/frontend/src/pages/cert-manager/CertificatesPage/components/CertificatesTable.tsx index 11aa2356e..0550746b4 100644 --- a/frontend/src/pages/cert-manager/CertificatesPage/components/CertificatesTable.tsx +++ b/frontend/src/pages/cert-manager/CertificatesPage/components/CertificatesTable.tsx @@ -115,7 +115,7 @@ const getAutoRenewalInfo = (certificate: TCertificate) => { } if (!certificate.renewBeforeDays) { - return { text: "Disabled", variant: "primary" as const }; + return { text: "Auto-Renewal Disabled", variant: "primary" as const }; } const notAfterDate = new Date(certificate.notAfter); From 8961e5d83d1cecbe1d665646a3a86c91e9fc0e99 Mon Sep 17 00:00:00 2001 From: Carlos Monastyrski Date: Mon, 27 Oct 2025 10:24:12 -0300 Subject: [PATCH 6/6] Use PG instead of bullMQ on the new certificates renew queue --- backend/src/server/routes/index.ts | 2 +- .../certificate-v3/certificate-v3-queue.ts | 237 +++++++++--------- 2 files changed, 126 insertions(+), 113 deletions(-) diff --git a/backend/src/server/routes/index.ts b/backend/src/server/routes/index.ts index 923a497a0..c135db83e 100644 --- a/backend/src/server/routes/index.ts +++ b/backend/src/server/routes/index.ts @@ -2339,7 +2339,7 @@ export const registerRoutes = async ( await dailyReminderQueueService.startSecretReminderMigrationJob(); await dailyExpiringPkiItemAlert.startSendingAlerts(); await pkiSubscriberQueue.startDailyAutoRenewalJob(); - await certificateV3Queue.startDailyAutoRenewalJob(); + await certificateV3Queue.init(); await kmsService.startService(hsmStatus); await microsoftTeamsService.start(); await dynamicSecretQueueService.init(); diff --git a/backend/src/services/certificate-v3/certificate-v3-queue.ts b/backend/src/services/certificate-v3/certificate-v3-queue.ts index 7c1b9d004..db7349cd5 100644 --- a/backend/src/services/certificate-v3/certificate-v3-queue.ts +++ b/backend/src/services/certificate-v3/certificate-v3-queue.ts @@ -1,5 +1,6 @@ /* eslint-disable no-await-in-loop */ import { EventType, TAuditLogServiceFactory } from "@app/ee/services/audit-log/audit-log-types"; +import { getConfig } from "@app/lib/config/env"; import { logger } from "@app/lib/logger"; import { QueueJobs, QueueName, TQueueServiceFactory } from "@app/queue"; @@ -21,130 +22,142 @@ export const certificateV3QueueServiceFactory = ({ certificateV3Service, auditLogService }: TCertificateV3QueueServiceFactoryDep) => { - queueService.start(QueueName.CertificateV3AutoRenewal, async (job) => { - if (job.name === QueueJobs.CertificateV3DailyAutoRenewal) { - logger.info(`${QueueJobs.CertificateV3DailyAutoRenewal}: queue task started`); + const appCfg = getConfig(); - 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.findCertificatesEligibleForRenewal({ - limit: QUEUE_BATCH_SIZE, - offset - }); - - if (certificates.length === 0) { - hasMore = false; - break; - } - - 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 ( - certificate.renewBeforeDays < MIN_RENEW_BEFORE_DAYS || - certificate.renewBeforeDays > MAX_RENEW_BEFORE_DAYS - ) { - // eslint-disable-next-line no-continue - continue; - } - } - - await certificateV3Service.renewCertificate({ - actor: ActorType.PLATFORM, - actorId: "", - actorAuthMethod: null, - actorOrgId: "", - certificateId: certificate.id, - internal: true - }); - - 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() || "", - profileName: certificate.profileName || "" - } - } - }); - } catch (error) { - 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 - } - } - }); - } - } - - offset += QUEUE_BATCH_SIZE; - } - - logger.info( - `${QueueJobs.CertificateV3DailyAutoRenewal}: queue task completed. Renewed ${totalCertificatesRenewed} certificates out of ${totalCertificatesFound}` - ); + const init = async () => { + if (appCfg.isSecondaryInstance) { + return; } - }); - - const startDailyAutoRenewalJob = async () => { - const { DAILY_CRON_SCHEDULE, QUEUE_START_DELAY_MS } = CERTIFICATE_RENEWAL_CONFIG; await queueService.stopRepeatableJob( QueueName.CertificateV3AutoRenewal, QueueJobs.CertificateV3DailyAutoRenewal, - { pattern: DAILY_CRON_SCHEDULE, utc: true }, + { pattern: CERTIFICATE_RENEWAL_CONFIG.DAILY_CRON_SCHEDULE, utc: true }, QueueName.CertificateV3AutoRenewal ); - await queueService.queue(QueueName.CertificateV3AutoRenewal, QueueJobs.CertificateV3DailyAutoRenewal, undefined, { - delay: QUEUE_START_DELAY_MS, - jobId: QueueName.CertificateV3AutoRenewal, - repeat: { pattern: DAILY_CRON_SCHEDULE, utc: true } - }); + await queueService.startPg( + QueueJobs.CertificateV3DailyAutoRenewal, + async () => { + try { + logger.info(`${QueueJobs.CertificateV3DailyAutoRenewal}: queue task started`); + + 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.findCertificatesEligibleForRenewal({ + limit: QUEUE_BATCH_SIZE, + offset + }); + + if (certificates.length === 0) { + hasMore = false; + break; + } + + 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 ( + certificate.renewBeforeDays < MIN_RENEW_BEFORE_DAYS || + certificate.renewBeforeDays > MAX_RENEW_BEFORE_DAYS + ) { + // eslint-disable-next-line no-continue + continue; + } + } + + await certificateV3Service.renewCertificate({ + actor: ActorType.PLATFORM, + actorId: "", + actorAuthMethod: null, + actorOrgId: "", + certificateId: certificate.id, + internal: true + }); + + 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() || "", + profileName: certificate.profileName || "" + } + } + }); + } catch (error) { + 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 + } + } + }); + } + } + + offset += QUEUE_BATCH_SIZE; + } + + logger.info( + `${QueueJobs.CertificateV3DailyAutoRenewal}: queue task completed. Renewed ${totalCertificatesRenewed} certificates out of ${totalCertificatesFound}` + ); + } catch (error) { + logger.error(error, `${QueueJobs.CertificateV3DailyAutoRenewal}: certificate renewal failed`); + throw error; + } + }, + { + batchSize: 1, + workerCount: 1, + pollingIntervalSeconds: 60 + } + ); + + await queueService.schedulePg( + QueueJobs.CertificateV3DailyAutoRenewal, + CERTIFICATE_RENEWAL_CONFIG.DAILY_CRON_SCHEDULE, + undefined, + { tz: "UTC" } + ); }; - queueService.listen(QueueName.CertificateV3AutoRenewal, "failed", (_, err) => { - logger.error(err, `${QueueName.CertificateV3AutoRenewal}: failed`); - }); - return { - startDailyAutoRenewalJob + init }; }; -export type TCertificateV3QueueFactory = ReturnType; +export type TCertificateV3QueueServiceFactory = ReturnType;
Status Not Before Not AfterAuto Renewal
{certificate.commonName} + {autoRenewalInfo && + (autoRenewalInfo.tooltip ? ( +
+ + {autoRenewalInfo.text} + + + + +
+ ) : ( + {autoRenewalInfo.text} + ))} +
@@ -172,10 +295,154 @@ 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 && + !isRevoked && + !isExpired && + !hasFailed && + !isExpiringWithinDay; + + if (!canManageRenewal) return null; + + return ( + + {(isAllowed) => { + const isAutoRenewalEnabled = Boolean( + certificate.renewBeforeDays && certificate.renewBeforeDays > 0 + ); + + return ( + { + const notAfterDate = new Date(certificate.notAfter); + const notBeforeDate = new Date(certificate.notBefore); + const ttlDays = Math.ceil( + (notAfterDate.getTime() - notBeforeDate.getTime()) / + (24 * 60 * 60 * 1000) + ); + handlePopUpOpen("manageRenewal", { + certificateId: certificate.id, + commonName: certificate.commonName, + profileId: certificate.profileId, + renewBeforeDays: certificate.renewBeforeDays, + ttlDays, + notAfter: certificate.notAfter, + renewalError: certificate.renewalError, + renewedFromId: certificate.renewedFromId, + renewedById: certificate.renewedById + }); + }} + disabled={!isAllowed} + icon={} + > + {isAutoRenewalEnabled + ? "Manage auto renewal" + : "Enable auto renewal"} + + ); + }} + + ); + })()} + {/* 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 && + !isRevoked && + !isExpired && + !isExpiringWithinDay && + isAutoRenewalEnabled; + + if (!canDisableRenewal) return null; + + return ( + + {(isAllowed) => ( + { + await handleDisableAutoRenewal( + certificate.id, + certificate.commonName + ); + }} + disabled={!isAllowed} + icon={} + > + Disable auto renewal + + )} + + ); + })()} + {/* 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 && + !isRevoked && + !isExpired; + + if (!canRenew) return null; + + return ( + + {(isAllowed) => ( + { + handlePopUpOpen("renewCertificate", { + certificateId: certificate.id, + commonName: certificate.commonName + }); + }} + disabled={!isAllowed} + icon={} + > + Renew Now + + )} + + ); + })()} {/* Only show revoke button if CA supports revocation */} {(() => { const caType = caCapabilityMap[certificate.caId]; - // If caId not found in map, assume CA supports revocation to avoid hiding revoke option const supportsRevocation = !caType || caSupportsCapability(caType, CaCapability.REVOKE_CERTIFICATES); diff --git a/frontend/src/pages/cert-manager/PoliciesPage/components/CertificateProfilesTab/CreateProfileModal.tsx b/frontend/src/pages/cert-manager/PoliciesPage/components/CertificateProfilesTab/CreateProfileModal.tsx index 3f7553f83..ea82fd843 100644 --- a/frontend/src/pages/cert-manager/PoliciesPage/components/CertificateProfilesTab/CreateProfileModal.tsx +++ b/frontend/src/pages/cert-manager/PoliciesPage/components/CertificateProfilesTab/CreateProfileModal.tsx @@ -1,5 +1,7 @@ import { useEffect } from "react"; import { Controller, useForm } from "react-hook-form"; +import { faQuestionCircle } from "@fortawesome/free-solid-svg-icons"; +import { FontAwesomeIcon } from "@fortawesome/react-fontawesome"; import { zodResolver } from "@hookform/resolvers/zod"; import { z } from "zod"; @@ -13,7 +15,8 @@ import { ModalContent, Select, SelectItem, - TextArea + TextArea, + Tooltip } from "@app/components/v2"; import { useProject } from "@app/context"; import { useListCasByProjectId } from "@app/hooks/api/ca/queries"; @@ -67,7 +70,7 @@ const createSchema = z apiConfig: z .object({ autoRenew: z.boolean().optional(), - autoRenewDays: z.number().min(1).max(365).optional() + renewBeforeDays: z.number().min(1).max(365).optional() }) .optional() }) @@ -115,7 +118,7 @@ const editSchema = z apiConfig: z .object({ autoRenew: z.boolean().optional(), - autoRenewDays: z.number().min(1).max(365).optional() + renewBeforeDays: z.number().min(1).max(365).optional() }) .optional() }) @@ -183,7 +186,7 @@ export const CreateProfileModal = ({ isOpen, onClose, profile, mode = "create" } profile.enrollmentType === "api" ? { autoRenew: profile.apiConfig?.autoRenew || false, - autoRenewDays: profile.apiConfig?.autoRenewDays || 30 + renewBeforeDays: profile.apiConfig?.renewBeforeDays || 30 } : undefined } @@ -195,7 +198,7 @@ export const CreateProfileModal = ({ isOpen, onClose, profile, mode = "create" } certificateTemplateId: "", apiConfig: { autoRenew: false, - autoRenewDays: 30 + renewBeforeDays: 30 } } }); @@ -225,7 +228,7 @@ export const CreateProfileModal = ({ isOpen, onClose, profile, mode = "create" } profile.enrollmentType === "api" ? { autoRenew: profile.apiConfig?.autoRenew || false, - autoRenewDays: profile.apiConfig?.autoRenewDays || 30 + renewBeforeDays: profile.apiConfig?.renewBeforeDays || 30 } : undefined }); @@ -389,7 +392,7 @@ export const CreateProfileModal = ({ isOpen, onClose, profile, mode = "create" } } else { setValue("apiConfig", { autoRenew: false, - autoRenewDays: 30 + renewBeforeDays: 30 }); setValue("estConfig", undefined); } @@ -433,7 +436,7 @@ export const CreateProfileModal = ({ isOpen, onClose, profile, mode = "create" } setValue("estConfig", undefined); setValue("apiConfig", { autoRenew: false, - autoRenewDays: 30 + renewBeforeDays: 30 }); } onChange(value); @@ -535,9 +538,18 @@ export const CreateProfileModal = ({ isOpen, onClose, profile, mode = "create" } name="apiConfig.autoRenew" render={({ field: { value, onChange }, fieldState: { error } }) => ( - - Enable Auto-Renewal - +
+ + Enable Auto-Renewal By Default + + + + +
)} /> @@ -548,10 +560,10 @@ export const CreateProfileModal = ({ isOpen, onClose, profile, mode = "create" }
( From e364eb15dbdb81f0ed5d50417a6bf66a8c36ae50 Mon Sep 17 00:00:00 2001 From: Carlos Monastyrski Date: Thu, 23 Oct 2025 10:23:40 -0300 Subject: [PATCH 2/6] Address greptile comments --- ...1021112356_add-certificate-auto-renewal.ts | 6 +- backend/src/server/routes/index.ts | 3 - .../server/routes/v3/certificates-router.ts | 12 +- .../certificate-constants.ts | 30 ++ .../certificate-common/certificate-utils.ts | 75 +++++ .../certificate-v3/certificate-v3-queue.ts | 266 ++++++------------ .../certificate-v3-service.test.ts | 4 +- .../certificate-v3/certificate-v3-service.ts | 5 +- .../services/certificate/certificate-dal.ts | 45 ++- .../src/hooks/api/certificates/mutations.tsx | 2 +- frontend/src/hooks/api/certificates/types.ts | 1 + .../CertificateManageRenewalModal.tsx | 225 +++++++-------- .../CertificateRenewalConfigModal.tsx | 35 +-- .../components/CertificatesTable.tsx | 45 +-- 14 files changed, 396 insertions(+), 358 deletions(-) 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}Status Not Before Not AfterAuto RenewalRenewal Status