Merge pull request #4728 from Infisical/feat/ENG-3942

PKI: Add support for certificate auto-renewal and manual renewal workflows
This commit is contained in:
carlosmonastyrski
2025-10-27 11:42:08 -03:00
committed by GitHub
41 changed files with 3352 additions and 251 deletions
@@ -0,0 +1,51 @@
import { Knex } from "knex";
import { TableName } from "../schemas";
export async function up(knex: Knex): Promise<void> {
if (await knex.schema.hasColumn(TableName.PkiApiEnrollmentConfig, "autoRenewDays")) {
await knex.schema.alterTable(TableName.PkiApiEnrollmentConfig, (t) => {
t.renameColumn("autoRenewDays", "renewBeforeDays");
});
}
if (!(await knex.schema.hasColumn(TableName.Certificate, "renewBeforeDays"))) {
await knex.schema.alterTable(TableName.Certificate, (t) => {
t.integer("renewBeforeDays").nullable();
t.uuid("renewedFromCertificateId").nullable();
t.uuid("renewedByCertificateId").nullable();
t.text("renewalError").nullable();
t.string("keyAlgorithm").nullable();
t.string("signatureAlgorithm").nullable();
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");
});
}
}
export async function down(knex: Knex): Promise<void> {
if (await knex.schema.hasColumn(TableName.Certificate, "renewBeforeDays")) {
await knex.schema.alterTable(TableName.Certificate, (t) => {
t.dropForeign(["renewedFromCertificateId"]);
t.dropForeign(["renewedByCertificateId"]);
t.dropIndex("renewedFromCertificateId");
t.dropIndex("renewedByCertificateId");
t.dropIndex("renewBeforeDays");
t.dropColumn("renewBeforeDays");
t.dropColumn("renewedFromCertificateId");
t.dropColumn("renewedByCertificateId");
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.renameColumn("renewBeforeDays", "autoRenewDays");
});
}
}
+7 -1
View File
@@ -27,7 +27,13 @@ export const CertificatesSchema = z.object({
extendedKeyUsages: z.string().array().nullable().optional(), extendedKeyUsages: z.string().array().nullable().optional(),
projectId: z.string(), projectId: z.string(),
pkiSubscriberId: z.string().uuid().nullable().optional(), pkiSubscriberId: z.string().uuid().nullable().optional(),
profileId: z.string().uuid().nullable().optional() profileId: z.string().uuid().nullable().optional(),
renewBeforeDays: z.number().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()
}); });
export type TCertificates = z.infer<typeof CertificatesSchema>; export type TCertificates = z.infer<typeof CertificatesSchema>;
@@ -10,7 +10,7 @@ import { TImmutableDBKeys } from "./models";
export const PkiApiEnrollmentConfigsSchema = z.object({ export const PkiApiEnrollmentConfigsSchema = z.object({
id: z.string().uuid(), id: z.string().uuid(),
autoRenew: z.boolean().default(false).nullable().optional(), autoRenew: z.boolean().default(false).nullable().optional(),
autoRenewDays: z.number().nullable().optional(), renewBeforeDays: z.number().nullable().optional(),
createdAt: z.date(), createdAt: z.date(),
updatedAt: z.date() updatedAt: z.date()
}); });
@@ -340,6 +340,8 @@ export enum EventType {
ISSUE_PKI_SUBSCRIBER_CERT = "issue-pki-subscriber-cert", ISSUE_PKI_SUBSCRIBER_CERT = "issue-pki-subscriber-cert",
SIGN_PKI_SUBSCRIBER_CERT = "sign-pki-subscriber-cert", SIGN_PKI_SUBSCRIBER_CERT = "sign-pki-subscriber-cert",
AUTOMATED_RENEW_SUBSCRIBER_CERT = "automated-renew-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", LIST_PKI_SUBSCRIBER_CERTS = "list-pki-subscriber-certs",
GET_SUBSCRIBER_ACTIVE_CERT_BUNDLE = "get-subscriber-active-cert-bundle", GET_SUBSCRIBER_ACTIVE_CERT_BUNDLE = "get-subscriber-active-cert-bundle",
CREATE_KMS = "create-kms", CREATE_KMS = "create-kms",
@@ -367,6 +369,9 @@ export enum EventType {
ISSUE_CERTIFICATE_FROM_PROFILE = "issue-certificate-from-profile", ISSUE_CERTIFICATE_FROM_PROFILE = "issue-certificate-from-profile",
SIGN_CERTIFICATE_FROM_PROFILE = "sign-certificate-from-profile", SIGN_CERTIFICATE_FROM_PROFILE = "sign-certificate-from-profile",
ORDER_CERTIFICATE_FROM_PROFILE = "order-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_CREATE_SLACK_INTEGRATION = "attempt-create-slack-integration",
ATTEMPT_REINSTALL_SLACK_INTEGRATION = "attempt-reinstall-slack-integration", ATTEMPT_REINSTALL_SLACK_INTEGRATION = "attempt-reinstall-slack-integration",
GET_PROJECT_SLACK_CONFIG = "get-project-slack-config", GET_PROJECT_SLACK_CONFIG = "get-project-slack-config",
@@ -2458,6 +2463,29 @@ interface AutomatedRenewPkiSubscriberCert {
}; };
} }
interface AutomatedRenewCertificate {
type: EventType.AUTOMATED_RENEW_CERTIFICATE;
metadata: {
certificateId: string;
commonName: string;
profileId: string;
renewBeforeDays: string;
profileName: string;
};
}
interface AutomatedRenewCertificateFailed {
type: EventType.AUTOMATED_RENEW_CERTIFICATE_FAILED;
metadata: {
certificateId: string;
commonName: string;
profileId: string;
renewBeforeDays: string;
profileName: string;
error: string;
};
}
interface SignPkiSubscriberCert { interface SignPkiSubscriberCert {
type: EventType.SIGN_PKI_SUBSCRIBER_CERT; type: EventType.SIGN_PKI_SUBSCRIBER_CERT;
metadata: { metadata: {
@@ -2720,6 +2748,16 @@ interface OrderCertificateFromProfile {
}; };
} }
interface RenewCertificate {
type: EventType.RENEW_CERTIFICATE;
metadata: {
originalCertificateId: string;
newCertificateId: string;
profileName: string;
commonName: string;
};
}
interface AttemptCreateSlackIntegration { interface AttemptCreateSlackIntegration {
type: EventType.ATTEMPT_CREATE_SLACK_INTEGRATION; type: EventType.ATTEMPT_CREATE_SLACK_INTEGRATION;
metadata: { metadata: {
@@ -4009,6 +4047,23 @@ interface PamResourceDeleteEvent {
}; };
} }
interface UpdateCertificateRenewalConfigEvent {
type: EventType.UPDATE_CERTIFICATE_RENEWAL_CONFIG;
metadata: {
certificateId: string;
renewBeforeDays: string;
commonName: string;
};
}
interface DisableCertificateRenewalConfigEvent {
type: EventType.DISABLE_CERTIFICATE_RENEWAL_CONFIG;
metadata: {
certificateId: string;
commonName: string;
};
}
export type Event = export type Event =
| CreateSubOrganizationEvent | CreateSubOrganizationEvent
| UpdateSubOrganizationEvent | UpdateSubOrganizationEvent
@@ -4216,6 +4271,7 @@ export type Event =
| IssueCertificateFromProfile | IssueCertificateFromProfile
| SignCertificateFromProfile | SignCertificateFromProfile
| OrderCertificateFromProfile | OrderCertificateFromProfile
| RenewCertificate
| GetAzureAdCsTemplatesEvent | GetAzureAdCsTemplatesEvent
| AttemptCreateSlackIntegration | AttemptCreateSlackIntegration
| AttemptReinstallSlackIntegration | AttemptReinstallSlackIntegration
@@ -4373,4 +4429,8 @@ export type Event =
| PamResourceGetEvent | PamResourceGetEvent
| PamResourceCreateEvent | PamResourceCreateEvent
| PamResourceUpdateEvent | PamResourceUpdateEvent
| PamResourceDeleteEvent; | PamResourceDeleteEvent
| UpdateCertificateRenewalConfigEvent
| DisableCertificateRenewalConfigEvent
| AutomatedRenewCertificate
| AutomatedRenewCertificateFailed;
+6
View File
@@ -78,6 +78,7 @@ export enum QueueName {
SecretReminderMigration = "secret-reminder-migration", SecretReminderMigration = "secret-reminder-migration",
UserNotification = "user-notification", UserNotification = "user-notification",
HealthAlert = "health-alert", HealthAlert = "health-alert",
CertificateV3AutoRenewal = "certificate-v3-auto-renewal",
PamAccountRotation = "pam-account-rotation" PamAccountRotation = "pam-account-rotation"
} }
@@ -128,6 +129,7 @@ export enum QueueJobs {
SecretReminderMigration = "secret-reminder-migration", SecretReminderMigration = "secret-reminder-migration",
UserNotification = "user-notification-job", UserNotification = "user-notification-job",
HealthAlert = "health-alert", HealthAlert = "health-alert",
CertificateV3DailyAutoRenewal = "certificate-v3-daily-auto-renewal",
PamAccountRotation = "pam-account-rotation" PamAccountRotation = "pam-account-rotation"
} }
@@ -359,6 +361,10 @@ export type TQueueJobTypes = {
name: QueueJobs.HealthAlert; name: QueueJobs.HealthAlert;
payload: undefined; payload: undefined;
}; };
[QueueName.CertificateV3AutoRenewal]: {
name: QueueJobs.CertificateV3DailyAutoRenewal;
payload: undefined;
};
[QueueName.PamAccountRotation]: { [QueueName.PamAccountRotation]: {
name: QueueJobs.PamAccountRotation; name: QueueJobs.PamAccountRotation;
payload: undefined; payload: undefined;
+10
View File
@@ -177,6 +177,7 @@ import { certificateTemplateEstConfigDALFactory } from "@app/services/certificat
import { certificateTemplateServiceFactory } from "@app/services/certificate-template/certificate-template-service"; import { certificateTemplateServiceFactory } from "@app/services/certificate-template/certificate-template-service";
import { certificateTemplateV2DALFactory } from "@app/services/certificate-template-v2/certificate-template-v2-dal"; 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 { 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 { certificateV3ServiceFactory } from "@app/services/certificate-v3/certificate-v3-service";
import { cmekServiceFactory } from "@app/services/cmek/cmek-service"; import { cmekServiceFactory } from "@app/services/cmek/cmek-service";
import { convertorServiceFactory } from "@app/services/convertor/convertor-service"; import { convertorServiceFactory } from "@app/services/convertor/convertor-service";
@@ -2136,6 +2137,7 @@ export const registerRoutes = async (
const certificateV3Service = certificateV3ServiceFactory({ const certificateV3Service = certificateV3ServiceFactory({
certificateDAL, certificateDAL,
certificateSecretDAL,
certificateAuthorityDAL, certificateAuthorityDAL,
certificateProfileDAL, certificateProfileDAL,
certificateTemplateV2Service, certificateTemplateV2Service,
@@ -2143,6 +2145,13 @@ export const registerRoutes = async (
permissionService permissionService
}); });
const certificateV3Queue = certificateV3QueueServiceFactory({
queueService,
certificateDAL,
certificateV3Service,
auditLogService
});
const certificateEstV3Service = certificateEstV3ServiceFactory({ const certificateEstV3Service = certificateEstV3ServiceFactory({
internalCertificateAuthorityService, internalCertificateAuthorityService,
certificateTemplateV2Service, certificateTemplateV2Service,
@@ -2330,6 +2339,7 @@ export const registerRoutes = async (
await dailyReminderQueueService.startSecretReminderMigrationJob(); await dailyReminderQueueService.startSecretReminderMigrationJob();
await dailyExpiringPkiItemAlert.startSendingAlerts(); await dailyExpiringPkiItemAlert.startSendingAlerts();
await pkiSubscriberQueue.startDailyAutoRenewalJob(); await pkiSubscriberQueue.startDailyAutoRenewalJob();
await certificateV3Queue.init();
await kmsService.startService(hsmStatus); await kmsService.startService(hsmStatus);
await microsoftTeamsService.start(); await microsoftTeamsService.start();
await dynamicSecretQueueService.init(); await dynamicSecretQueueService.init();
@@ -42,7 +42,7 @@ export const registerCertificateProfilesRouter = async (server: FastifyZodProvid
apiConfig: z apiConfig: z
.object({ .object({
autoRenew: z.boolean().default(false), autoRenew: z.boolean().default(false),
autoRenewDays: z.number().min(1).max(365).optional() renewBeforeDays: z.number().min(1).max(30).optional()
}) })
.optional() .optional()
}) })
@@ -150,7 +150,7 @@ export const registerCertificateProfilesRouter = async (server: FastifyZodProvid
.object({ .object({
id: z.string(), id: z.string(),
autoRenew: z.boolean(), autoRenew: z.boolean(),
autoRenewDays: z.number().optional() renewBeforeDays: z.number().optional()
}) })
.optional() .optional()
}).array(), }).array(),
@@ -230,7 +230,7 @@ export const registerCertificateProfilesRouter = async (server: FastifyZodProvid
.object({ .object({
id: z.string(), id: z.string(),
autoRenew: z.boolean(), autoRenew: z.boolean(),
autoRenewDays: z.number().optional() renewBeforeDays: z.number().optional()
}) })
.optional(), .optional(),
metrics: z metrics: z
@@ -355,7 +355,7 @@ export const registerCertificateProfilesRouter = async (server: FastifyZodProvid
apiConfig: z apiConfig: z
.object({ .object({
autoRenew: z.boolean().default(false), autoRenew: z.boolean().default(false),
autoRenewDays: z.number().min(1).max(365).optional() renewBeforeDays: z.number().min(1).max(30).optional()
}) })
.optional() .optional()
}) })
@@ -1200,7 +1200,7 @@ export const registerProjectRouter = async (server: FastifyZodProvider) => {
}), }),
response: { response: {
200: z.object({ 200: z.object({
certificates: z.array(CertificatesSchema), certificates: z.array(CertificatesSchema.extend({ hasPrivateKey: z.boolean() })),
totalCount: z.number() totalCount: z.number()
}) })
} }
@@ -18,6 +18,7 @@ import {
CertKeyUsageType, CertKeyUsageType,
CertSubjectAlternativeNameType CertSubjectAlternativeNameType
} from "@app/services/certificate-common/certificate-constants"; } 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 { mapEnumsForValidation } from "@app/services/certificate-common/certificate-utils";
import { validateTemplateRegexField } from "@app/services/certificate-template/certificate-template-validators"; import { validateTemplateRegexField } from "@app/services/certificate-template/certificate-template-validators";
@@ -84,8 +85,8 @@ export const registerCertificatesRouter = async (server: FastifyZodProvider) =>
}) })
) )
.optional(), .optional(),
signatureAlgorithm: z.nativeEnum(CertSignatureAlgorithm).optional(), signatureAlgorithm: z.nativeEnum(CertSignatureAlgorithm),
keyAlgorithm: z.nativeEnum(CertKeyAlgorithm).optional() keyAlgorithm: z.nativeEnum(CertKeyAlgorithm)
}) })
.refine(validateTtlAndDateFields, { .refine(validateTtlAndDateFields, {
message: message:
@@ -169,9 +170,7 @@ export const registerCertificatesRouter = async (server: FastifyZodProvider) =>
.min(1, "TTL cannot be empty") .min(1, "TTL cannot be empty")
.refine((val) => ms(val) > 0, "TTL must be a positive number"), .refine((val) => ms(val) > 0, "TTL must be a positive number"),
notBefore: validateCaDateField.optional(), notBefore: validateCaDateField.optional(),
notAfter: validateCaDateField.optional(), notAfter: validateCaDateField.optional()
signatureAlgorithm: z.nativeEnum(CertSignatureAlgorithm).optional(),
keyAlgorithm: z.nativeEnum(CertKeyAlgorithm).optional()
}) })
.refine(validateTtlAndDateFields, { .refine(validateTtlAndDateFields, {
message: message:
@@ -192,6 +191,8 @@ export const registerCertificatesRouter = async (server: FastifyZodProvider) =>
}, },
onRequest: verifyAuth([AuthMode.JWT, AuthMode.IDENTITY_ACCESS_TOKEN]), onRequest: verifyAuth([AuthMode.JWT, AuthMode.IDENTITY_ACCESS_TOKEN]),
handler: async (req) => { handler: async (req) => {
const certificateRequest = extractCertificateRequestFromCSR(req.body.csr);
const data = await server.services.certificateV3.signCertificateFromProfile({ const data = await server.services.certificateV3.signCertificateFromProfile({
actor: req.permission.type, actor: req.permission.type,
actorId: req.permission.id, actorId: req.permission.id,
@@ -203,9 +204,7 @@ export const registerCertificatesRouter = async (server: FastifyZodProvider) =>
ttl: req.body.ttl ttl: req.body.ttl
}, },
notBefore: req.body.notBefore ? new Date(req.body.notBefore) : undefined, notBefore: req.body.notBefore ? new Date(req.body.notBefore) : undefined,
notAfter: req.body.notAfter ? new Date(req.body.notAfter) : undefined, notAfter: req.body.notAfter ? new Date(req.body.notAfter) : undefined
signatureAlgorithm: req.body.signatureAlgorithm,
keyAlgorithm: req.body.keyAlgorithm
}); });
await server.services.auditLog.createAuditLog({ await server.services.auditLog.createAuditLog({
@@ -217,7 +216,7 @@ export const registerCertificatesRouter = async (server: FastifyZodProvider) =>
certificateProfileId: req.body.profileId, certificateProfileId: req.body.profileId,
certificateId: data.certificateId, certificateId: data.certificateId,
profileName: data.profileName, profileName: data.profileName,
commonName: "" commonName: certificateRequest.commonName || ""
} }
} }
}); });
@@ -260,8 +259,8 @@ export const registerCertificatesRouter = async (server: FastifyZodProvider) =>
notBefore: validateCaDateField.optional(), notBefore: validateCaDateField.optional(),
notAfter: validateCaDateField.optional(), notAfter: validateCaDateField.optional(),
commonName: validateTemplateRegexField.optional(), commonName: validateTemplateRegexField.optional(),
signatureAlgorithm: z.nativeEnum(CertSignatureAlgorithm).optional(), signatureAlgorithm: z.nativeEnum(CertSignatureAlgorithm),
keyAlgorithm: z.nativeEnum(CertKeyAlgorithm).optional() keyAlgorithm: z.nativeEnum(CertKeyAlgorithm)
}) })
.refine(validateTtlAndDateFields, { .refine(validateTtlAndDateFields, {
message: message:
@@ -343,4 +342,145 @@ export const registerCertificatesRouter = async (server: FastifyZodProvider) =>
return data; 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,
commonName: data.commonName
}
}
});
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(),
enableAutoRenewal: z.boolean().optional()
})
.refine((data) => !(data.renewBeforeDays !== undefined && data.enableAutoRenewal === false), {
message: "Cannot specify both renewBeforeDays and enableAutoRenewal=false"
}),
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.enableAutoRenewal === false) {
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,
commonName: data.commonName
}
}
});
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(),
commonName: data.commonName
}
}
});
return {
message: "Certificate configuration updated successfully",
renewBeforeDays: data.renewBeforeDays
};
}
return {
message: "No configuration changes requested"
};
}
});
}; };
@@ -2,12 +2,14 @@
import { ForbiddenError, subject } from "@casl/ability"; import { ForbiddenError, subject } from "@casl/ability";
import * as x509 from "@peculiar/x509"; import * as x509 from "@peculiar/x509";
import slugify from "@sindresorhus/slugify"; import slugify from "@sindresorhus/slugify";
import { Knex } from "knex";
import { ActionProjectType, TableName, TCertificateAuthorities, TCertificateTemplates } from "@app/db/schemas"; import { ActionProjectType, TableName, TCertificateAuthorities, TCertificateTemplates } from "@app/db/schemas";
import { TPermissionServiceFactory } from "@app/ee/services/permission/permission-service-types"; import { TPermissionServiceFactory } from "@app/ee/services/permission/permission-service-types";
import { import {
ProjectPermissionActions, ProjectPermissionActions,
ProjectPermissionCertificateActions, ProjectPermissionCertificateActions,
ProjectPermissionCertificateProfileActions,
ProjectPermissionPkiTemplateActions, ProjectPermissionPkiTemplateActions,
ProjectPermissionSub ProjectPermissionSub
} from "@app/ee/services/permission/project-permission"; } from "@app/ee/services/permission/project-permission";
@@ -1180,7 +1182,9 @@ export const internalCertificateAuthorityServiceFactory = ({
extendedKeyUsages, extendedKeyUsages,
signatureAlgorithm, signatureAlgorithm,
keyAlgorithm, keyAlgorithm,
isFromProfile isFromProfile,
internal = false,
tx
}: TIssueCertFromCaDTO) => { }: TIssueCertFromCaDTO) => {
let ca: TCertificateAuthorityWithAssociatedCa | undefined; let ca: TCertificateAuthorityWithAssociatedCa | undefined;
let certificateTemplate: TCertificateTemplates | undefined; let certificateTemplate: TCertificateTemplates | undefined;
@@ -1210,19 +1214,28 @@ export const internalCertificateAuthorityServiceFactory = ({
throw new NotFoundError({ message: `Internal CA with ID '${caId}' not found` }); throw new NotFoundError({ message: `Internal CA with ID '${caId}' not found` });
} }
const { permission } = await permissionService.getProjectPermission({ if (!internal) {
actor, const { permission } = await permissionService.getProjectPermission({
actorId, actor,
projectId: ca.projectId, actorId,
actorAuthMethod, projectId: ca.projectId,
actorOrgId, actorAuthMethod,
actionProjectType: ActionProjectType.CertificateManager actorOrgId,
}); actionProjectType: ActionProjectType.CertificateManager
});
ForbiddenError.from(permission).throwUnlessCan( if (isFromProfile) {
ProjectPermissionCertificateActions.Create, ForbiddenError.from(permission).throwUnlessCan(
ProjectPermissionSub.Certificates 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" }); if (ca.status !== CaStatus.ACTIVE) throw new BadRequestError({ message: "CA is not active" });
if (!ca.internalCa.activeCaCertId) if (!ca.internalCa.activeCaCertId)
@@ -1473,7 +1486,7 @@ export const internalCertificateAuthorityServiceFactory = ({
plainText: Buffer.from(certificateChainPem) plainText: Buffer.from(certificateChainPem)
}); });
await certificateDAL.transaction(async (tx) => { const executeIssueCertOperations = async (transaction: Knex) => {
const cert = await certificateDAL.create( const cert = await certificateDAL.create(
{ {
caId: (ca as TCertificateAuthorities).id, caId: (ca as TCertificateAuthorities).id,
@@ -1488,9 +1501,11 @@ export const internalCertificateAuthorityServiceFactory = ({
notAfter: notAfterDate, notAfter: notAfterDate,
keyUsages: selectedKeyUsages, keyUsages: selectedKeyUsages,
extendedKeyUsages: selectedExtendedKeyUsages, extendedKeyUsages: selectedExtendedKeyUsages,
projectId: ca!.projectId projectId: ca!.projectId,
keyAlgorithm: effectiveKeyAlgorithm,
signatureAlgorithm: signatureAlgorithm || ca!.internalCa!.keyAlgorithm
}, },
tx transaction
); );
await certificateBodyDAL.create( await certificateBodyDAL.create(
@@ -1499,7 +1514,7 @@ export const internalCertificateAuthorityServiceFactory = ({
encryptedCertificate, encryptedCertificate,
encryptedCertificateChain encryptedCertificateChain
}, },
tx transaction
); );
await certificateSecretDAL.create( await certificateSecretDAL.create(
@@ -1507,7 +1522,7 @@ export const internalCertificateAuthorityServiceFactory = ({
certId: cert.id, certId: cert.id,
encryptedPrivateKey encryptedPrivateKey
}, },
tx transaction
); );
if (collectionId) { if (collectionId) {
@@ -1516,12 +1531,18 @@ export const internalCertificateAuthorityServiceFactory = ({
pkiCollectionId: collectionId, pkiCollectionId: collectionId,
certId: cert.id certId: cert.id
}, },
tx transaction
); );
} }
return cert; return cert;
}); };
if (tx) {
await executeIssueCertOperations(tx);
} else {
await certificateDAL.transaction(executeIssueCertOperations);
}
return { return {
certificate: leafCert.toString("pem"), certificate: leafCert.toString("pem"),
@@ -1593,10 +1614,17 @@ export const internalCertificateAuthorityServiceFactory = ({
actionProjectType: ActionProjectType.CertificateManager actionProjectType: ActionProjectType.CertificateManager
}); });
ForbiddenError.from(permission).throwUnlessCan( if (dto.isFromProfile && dto.profileId) {
ProjectPermissionCertificateActions.Create, ForbiddenError.from(permission).throwUnlessCan(
ProjectPermissionSub.Certificates 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" }); if (ca.status !== CaStatus.ACTIVE) throw new BadRequestError({ message: "CA is not active" });
@@ -1700,7 +1728,8 @@ export const internalCertificateAuthorityServiceFactory = ({
certificateAuthorityDAL, certificateAuthorityDAL,
certificateAuthoritySecretDAL, certificateAuthoritySecretDAL,
projectDAL, projectDAL,
kmsService kmsService,
signatureAlgorithm: alg
}); });
const caCrl = await certificateAuthorityCrlDAL.findOne({ caSecretId: caSecret.id }); const caCrl = await certificateAuthorityCrlDAL.findOne({ caSecretId: caSecret.id });
@@ -1917,7 +1946,9 @@ export const internalCertificateAuthorityServiceFactory = ({
notAfter: notAfterDate, notAfter: notAfterDate,
keyUsages: selectedKeyUsages, keyUsages: selectedKeyUsages,
extendedKeyUsages: selectedExtendedKeyUsages, extendedKeyUsages: selectedExtendedKeyUsages,
projectId: ca!.projectId projectId: ca!.projectId,
keyAlgorithm: keyAlgorithm || ca!.internalCa!.keyAlgorithm,
signatureAlgorithm: signatureAlgorithm || ca!.internalCa!.keyAlgorithm
}, },
tx tx
); );
@@ -1,3 +1,4 @@
import { Knex } from "knex";
import { z } from "zod"; import { z } from "zod";
import { TCertificateAuthorityCrlDALFactory } from "@app/ee/services/certificate-authority-crl/certificate-authority-crl-dal"; import { TCertificateAuthorityCrlDALFactory } from "@app/ee/services/certificate-authority-crl/certificate-authority-crl-dal";
@@ -139,6 +140,9 @@ export type TIssueCertFromCaDTO = {
signatureAlgorithm?: CertSignatureAlgorithm; signatureAlgorithm?: CertSignatureAlgorithm;
keyAlgorithm?: CertKeyAlgorithm; keyAlgorithm?: CertKeyAlgorithm;
isFromProfile?: boolean; isFromProfile?: boolean;
profileId?: string;
internal?: boolean;
tx?: Knex;
} & Omit<TProjectPermission, "projectId">; } & Omit<TProjectPermission, "projectId">;
export type TSignCertFromCaDTO = export type TSignCertFromCaDTO =
@@ -159,6 +163,7 @@ export type TSignCertFromCaDTO =
signatureAlgorithm?: string; signatureAlgorithm?: string;
keyAlgorithm?: string; keyAlgorithm?: string;
isFromProfile?: boolean; isFromProfile?: boolean;
profileId?: string;
} }
| ({ | ({
isInternal: false; isInternal: false;
@@ -177,6 +182,7 @@ export type TSignCertFromCaDTO =
signatureAlgorithm?: string; signatureAlgorithm?: string;
keyAlgorithm?: string; keyAlgorithm?: string;
isFromProfile?: boolean; isFromProfile?: boolean;
profileId?: string;
} & Omit<TProjectPermission, "projectId">); } & Omit<TProjectPermission, "projectId">);
export type TGetCaCertificateTemplatesDTO = { export type TGetCaCertificateTemplatesDTO = {
@@ -175,6 +175,26 @@ export enum CertSignatureAlgorithm {
ECDSA_SHA512 = "ECDSA-SHA512" 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_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 SAN_TYPE_OPTIONS = Object.values(CertSubjectAlternativeNameType);
export const KEY_USAGE_OPTIONS = Object.values(CertKeyUsageType); export const KEY_USAGE_OPTIONS = Object.values(CertKeyUsageType);
export const EXTENDED_KEY_USAGE_OPTIONS = Object.values(CertExtendedKeyUsageType); export const EXTENDED_KEY_USAGE_OPTIONS = Object.values(CertExtendedKeyUsageType);
@@ -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
};
};
@@ -3,31 +3,15 @@ import * as x509 from "@peculiar/x509";
import { extractX509CertFromChain } from "@app/lib/certificates/extract-certificate"; import { extractX509CertFromChain } from "@app/lib/certificates/extract-certificate";
import { BadRequestError, NotFoundError, UnauthorizedError } from "@app/lib/errors"; import { BadRequestError, NotFoundError, UnauthorizedError } from "@app/lib/errors";
import { isCertChainValid } from "@app/services/certificate/certificate-fns"; 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 { TCertificateAuthorityCertDALFactory } from "@app/services/certificate-authority/certificate-authority-cert-dal";
import { TCertificateAuthorityDALFactory } from "@app/services/certificate-authority/certificate-authority-dal"; import { TCertificateAuthorityDALFactory } from "@app/services/certificate-authority/certificate-authority-dal";
import { import { getCaCertChain, getCaCertChains } from "@app/services/certificate-authority/certificate-authority-fns";
getCaCertChain,
getCaCertChains,
parseDistinguishedName
} from "@app/services/certificate-authority/certificate-authority-fns";
import { validateAndMapAltNameType } from "@app/services/certificate-authority/certificate-authority-validators";
import { TInternalCertificateAuthorityServiceFactory } from "@app/services/certificate-authority/internal/internal-certificate-authority-service"; import { TInternalCertificateAuthorityServiceFactory } from "@app/services/certificate-authority/internal/internal-certificate-authority-service";
import { import { extractCertificateRequestFromCSR } from "@app/services/certificate-common/certificate-csr-utils";
mapLegacyExtendedKeyUsageToStandard,
mapLegacyKeyUsageToStandard
} from "@app/services/certificate-common/certificate-constants";
import { mapEnumsForValidation } from "@app/services/certificate-common/certificate-utils"; import { mapEnumsForValidation } from "@app/services/certificate-common/certificate-utils";
import { TCertificateProfileDALFactory } from "@app/services/certificate-profile/certificate-profile-dal"; import { TCertificateProfileDALFactory } from "@app/services/certificate-profile/certificate-profile-dal";
import { EnrollmentType } from "@app/services/certificate-profile/certificate-profile-types"; import { EnrollmentType } from "@app/services/certificate-profile/certificate-profile-types";
import { TCertificateTemplateV2ServiceFactory } from "@app/services/certificate-template-v2/certificate-template-v2-service"; 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 { TEstEnrollmentConfigDALFactory } from "@app/services/enrollment-config/est-enrollment-config-dal";
import { TKmsServiceFactory } from "@app/services/kms/kms-service"; import { TKmsServiceFactory } from "@app/services/kms/kms-service";
import { TProjectDALFactory } from "@app/services/project/project-dal"; import { TProjectDALFactory } from "@app/services/project/project-dal";
@@ -61,63 +45,6 @@ export const certificateEstV3ServiceFactory = ({
certificateProfileDAL, certificateProfileDAL,
estEnrollmentConfigDAL estEnrollmentConfigDAL
}: TCertificateEstV3ServiceFactoryDep) => { }: 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 ({ const simpleEnrollByProfile = async ({
csr, csr,
profileId, profileId,
@@ -109,7 +109,7 @@ export const certificateProfileDALFactory = (db: TDbClient) => {
db.ref("encryptedCaChain").withSchema(TableName.PkiEstEnrollmentConfig).as("estConfigEncryptedCaChain"), db.ref("encryptedCaChain").withSchema(TableName.PkiEstEnrollmentConfig).as("estConfigEncryptedCaChain"),
db.ref("id").withSchema(TableName.PkiApiEnrollmentConfig).as("apiConfigId"), db.ref("id").withSchema(TableName.PkiApiEnrollmentConfig).as("apiConfigId"),
db.ref("autoRenew").withSchema(TableName.PkiApiEnrollmentConfig).as("apiConfigAutoRenew"), 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) .where(`${TableName.PkiCertificateProfile}.id`, id)
.first(); .first();
@@ -132,7 +132,7 @@ export const certificateProfileDALFactory = (db: TDbClient) => {
? ({ ? ({
id: result.apiConfigId, id: result.apiConfigId,
autoRenew: !!result.apiConfigAutoRenew, autoRenew: !!result.apiConfigAutoRenew,
autoRenewDays: result.apiConfigAutoRenewDays || undefined renewBeforeDays: result.apiConfigRenewBeforeDays || undefined
} as TCertificateProfileWithConfigs["apiConfig"]) } as TCertificateProfileWithConfigs["apiConfig"])
: undefined; : undefined;
@@ -264,7 +264,7 @@ export const certificateProfileDALFactory = (db: TDbClient) => {
db.ref("encryptedCaChain").withSchema(TableName.PkiEstEnrollmentConfig).as("estEncryptedCaChain"), db.ref("encryptedCaChain").withSchema(TableName.PkiEstEnrollmentConfig).as("estEncryptedCaChain"),
db.ref("id").withSchema(TableName.PkiApiEnrollmentConfig).as("apiId"), db.ref("id").withSchema(TableName.PkiApiEnrollmentConfig).as("apiId"),
db.ref("autoRenew").withSchema(TableName.PkiApiEnrollmentConfig).as("apiAutoRenew"), 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) { if (includeMetrics) {
@@ -290,7 +290,7 @@ export const certificateProfileDALFactory = (db: TDbClient) => {
db.ref("encryptedCaChain").withSchema(TableName.PkiEstEnrollmentConfig).as("estEncryptedCaChain"), db.ref("encryptedCaChain").withSchema(TableName.PkiEstEnrollmentConfig).as("estEncryptedCaChain"),
db.ref("id").withSchema(TableName.PkiApiEnrollmentConfig).as("apiId"), db.ref("id").withSchema(TableName.PkiApiEnrollmentConfig).as("apiId"),
db.ref("autoRenew").withSchema(TableName.PkiApiEnrollmentConfig).as("apiAutoRenew"), 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(certificates.id) as total_certificates"),
db.raw( db.raw(
'COUNT(CASE WHEN certificates."revokedAt" IS NULL AND certificates."notAfter" > ? THEN 1 END) as active_certificates', '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, id: result.apiId as string,
autoRenew: !!result.apiAutoRenew, autoRenew: !!result.apiAutoRenew,
autoRenewDays: (result.apiAutoRenewDays as number) || undefined renewBeforeDays: (result.apiRenewBeforeDays as number) || undefined
} }
: undefined; : undefined;
@@ -25,7 +25,7 @@ export const createCertificateProfileSchema = z
apiConfig: z apiConfig: z
.object({ .object({
autoRenew: z.boolean().default(false), autoRenew: z.boolean().default(false),
autoRenewDays: z.number().min(1).max(365).optional() renewBeforeDays: z.number().min(1).max(30).optional()
}) })
.optional() .optional()
}) })
@@ -75,7 +75,7 @@ export const updateCertificateProfileSchema = z
apiConfig: z apiConfig: z
.object({ .object({
autoRenew: z.boolean().default(false), autoRenew: z.boolean().default(false),
autoRenewDays: z.number().min(1).max(365).optional() renewBeforeDays: z.number().min(1).max(30).optional()
}) })
.optional() .optional()
}) })
@@ -110,7 +110,7 @@ describe("CertificateProfileService", () => {
apiConfig: { apiConfig: {
id: "api-config-123", id: "api-config-123",
autoRenew: true, autoRenew: true,
autoRenewDays: 30 renewBeforeDays: 30
} }
}; };
@@ -202,7 +202,7 @@ describe("CertificateProfileService", () => {
certificateTemplateId: "template-123", certificateTemplateId: "template-123",
apiConfig: { apiConfig: {
autoRenew: true, autoRenew: true,
autoRenewDays: 30 renewBeforeDays: 30
} }
}; };
@@ -323,7 +323,7 @@ describe("CertificateProfileService", () => {
certificateTemplateId: "template-123", certificateTemplateId: "template-123",
apiConfig: { apiConfig: {
autoRenew: true, autoRenew: true,
autoRenewDays: 30 renewBeforeDays: 30
} }
}; };
@@ -761,7 +761,7 @@ describe("CertificateProfileService", () => {
certificateTemplateId: "template-123", certificateTemplateId: "template-123",
apiConfig: { apiConfig: {
autoRenew: true, autoRenew: true,
autoRenewDays: 30 renewBeforeDays: 30
} }
}; };
@@ -786,7 +786,7 @@ describe("CertificateProfileService", () => {
certificateTemplateId: "template-123", certificateTemplateId: "template-123",
apiConfig: { apiConfig: {
autoRenew: true, autoRenew: true,
autoRenewDays: 7 renewBeforeDays: 7
} }
}; };
@@ -808,7 +808,7 @@ describe("CertificateProfileService", () => {
expect(mockApiEnrollmentConfigDAL.create).toHaveBeenCalledWith( expect(mockApiEnrollmentConfigDAL.create).toHaveBeenCalledWith(
{ {
autoRenew: true, autoRenew: true,
autoRenewDays: 7 renewBeforeDays: 7
}, },
undefined undefined
); );
@@ -225,7 +225,7 @@ export const certificateProfileServiceFactory = ({
const apiConfig = await apiEnrollmentConfigDAL.create( const apiConfig = await apiEnrollmentConfigDAL.create(
{ {
autoRenew: data.apiConfig.autoRenew, autoRenew: data.apiConfig.autoRenew,
autoRenewDays: data.apiConfig.autoRenewDays renewBeforeDays: data.apiConfig.renewBeforeDays
}, },
tx tx
); );
@@ -343,7 +343,7 @@ export const certificateProfileServiceFactory = ({
existingProfile.apiConfigId, existingProfile.apiConfigId,
{ {
autoRenew: apiConfig.autoRenew, autoRenew: apiConfig.autoRenew,
autoRenewDays: apiConfig.autoRenewDays renewBeforeDays: apiConfig.renewBeforeDays
}, },
tx tx
); );
@@ -26,7 +26,7 @@ export type TCertificateProfileUpdate = Omit<TPkiCertificateProfilesUpdate, "enr
}; };
apiConfig?: { apiConfig?: {
autoRenew?: boolean; autoRenew?: boolean;
autoRenewDays?: number; renewBeforeDays?: number;
}; };
}; };
@@ -52,7 +52,7 @@ export type TCertificateProfileWithConfigs = TCertificateProfile & {
apiConfig?: { apiConfig?: {
id: string; id: string;
autoRenew: boolean; autoRenew: boolean;
autoRenewDays?: number; renewBeforeDays?: number;
}; };
metrics?: TCertificateProfileMetrics; metrics?: TCertificateProfileMetrics;
}; };
@@ -762,32 +762,36 @@ export const certificateTemplateV2ServiceFactory = ({
actorId, actorId,
actorAuthMethod, actorAuthMethod,
actorOrgId, actorOrgId,
templateId templateId,
internal = false
}: { }: {
actor: ActorType; actor: ActorType;
actorId: string; actorId: string;
actorAuthMethod: ActorAuthMethod; actorAuthMethod: ActorAuthMethod;
actorOrgId: string; actorOrgId: string;
templateId: string; templateId: string;
internal?: boolean;
}): Promise<TCertificateTemplateV2> => { }): Promise<TCertificateTemplateV2> => {
const template = await certificateTemplateV2DAL.findById(templateId); const template = await certificateTemplateV2DAL.findById(templateId);
if (!template) { if (!template) {
throw new NotFoundError({ message: "Certificate template not found" }); throw new NotFoundError({ message: "Certificate template not found" });
} }
const { permission } = await permissionService.getProjectPermission({ if (!internal) {
actor, const { permission } = await permissionService.getProjectPermission({
actorId, actor,
projectId: template.projectId, actorId,
actorAuthMethod, projectId: template.projectId,
actorOrgId, actorAuthMethod,
actionProjectType: ActionProjectType.CertificateManager actorOrgId,
}); actionProjectType: ActionProjectType.CertificateManager
});
ForbiddenError.from(permission).throwUnlessCan( ForbiddenError.from(permission).throwUnlessCan(
ProjectPermissionPkiTemplateActions.Read, ProjectPermissionPkiTemplateActions.Read,
ProjectPermissionSub.CertificateTemplates ProjectPermissionSub.CertificateTemplates
); );
}
return template; return template;
}; };
@@ -0,0 +1,163 @@
/* 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";
import { ActorType } from "../auth/auth-type";
import { TCertificateDALFactory } from "../certificate/certificate-dal";
import { CERTIFICATE_RENEWAL_CONFIG } from "../certificate-common/certificate-constants";
import { TCertificateV3ServiceFactory } from "./certificate-v3-service";
type TCertificateV3QueueServiceFactoryDep = {
queueService: TQueueServiceFactory;
certificateDAL: Pick<TCertificateDALFactory, "findCertificatesEligibleForRenewal" | "updateById">;
certificateV3Service: TCertificateV3ServiceFactory;
auditLogService: Pick<TAuditLogServiceFactory, "createAuditLog">;
};
export const certificateV3QueueServiceFactory = ({
queueService,
certificateDAL,
certificateV3Service,
auditLogService
}: TCertificateV3QueueServiceFactoryDep) => {
const appCfg = getConfig();
const init = async () => {
if (appCfg.isSecondaryInstance) {
return;
}
await queueService.stopRepeatableJob(
QueueName.CertificateV3AutoRenewal,
QueueJobs.CertificateV3DailyAutoRenewal,
{ pattern: CERTIFICATE_RENEWAL_CONFIG.DAILY_CRON_SCHEDULE, utc: true },
QueueName.CertificateV3AutoRenewal
);
await queueService.startPg<QueueName.CertificateV3AutoRenewal>(
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" }
);
};
return {
init
};
};
export type TCertificateV3QueueServiceFactory = ReturnType<typeof certificateV3QueueServiceFactory>;
@@ -7,10 +7,12 @@ import { ForbiddenError } from "@casl/ability";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { TPermissionServiceFactory } from "@app/ee/services/permission/permission-service-types"; 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 { TCertificateDALFactory } from "@app/services/certificate/certificate-dal";
import { ACMESANType, CertificateOrderStatus } from "@app/services/certificate/certificate-types"; 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 { 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 { TInternalCertificateAuthorityServiceFactory } from "@app/services/certificate-authority/internal/internal-certificate-authority-service";
import { import {
CertExtendedKeyUsageType, CertExtendedKeyUsageType,
@@ -23,14 +25,32 @@ import { EnrollmentType } from "@app/services/certificate-profile/certificate-pr
import { TCertificateTemplateV2ServiceFactory } from "@app/services/certificate-template-v2/certificate-template-v2-service"; import { TCertificateTemplateV2ServiceFactory } from "@app/services/certificate-template-v2/certificate-template-v2-service";
import { ActorType, AuthMethod } from "../auth/auth-type"; import { ActorType, AuthMethod } from "../auth/auth-type";
import {
extractAlgorithmsFromCSR,
extractCertificateRequestFromCSR
} from "../certificate-common/certificate-csr-utils";
import { certificateV3ServiceFactory, TCertificateV3ServiceFactory } from "./certificate-v3-service"; import { certificateV3ServiceFactory, TCertificateV3ServiceFactory } from "./certificate-v3-service";
vi.mock("../certificate-common/certificate-csr-utils", () => ({
extractCertificateRequestFromCSR: vi.fn(),
extractAlgorithmsFromCSR: vi.fn()
}));
describe("CertificateV3Service", () => { describe("CertificateV3Service", () => {
let service: TCertificateV3ServiceFactory; let service: TCertificateV3ServiceFactory;
const mockCertificateDAL: Pick<TCertificateDALFactory, "findOne" | "updateById"> = { const mockCertificateDAL: Pick<TCertificateDALFactory, "findOne" | "findById" | "updateById" | "transaction"> = {
findOne: vi.fn(), findOne: vi.fn(),
updateById: vi.fn() findById: vi.fn(),
updateById: vi.fn(),
transaction: vi.fn().mockImplementation(async (callback: (tx: any) => Promise<unknown>) => {
const mockTx = {};
return callback(mockTx);
})
};
const mockCertificateSecretDAL: Pick<TCertificateSecretDALFactory, "findOne"> = {
findOne: vi.fn()
}; };
const mockCertificateAuthorityDAL: Pick<TCertificateAuthorityDALFactory, "findByIdWithAssociatedCa"> = { const mockCertificateAuthorityDAL: Pick<TCertificateAuthorityDALFactory, "findByIdWithAssociatedCa"> = {
@@ -76,7 +96,7 @@ describe("CertificateV3Service", () => {
beforeEach(() => { beforeEach(() => {
// Reset all mocks before each test // Reset all mocks before each test
vi.clearAllMocks(); vi.resetAllMocks();
// Mock ForbiddenError.from static method // Mock ForbiddenError.from static method
vi.spyOn(ForbiddenError, "from").mockReturnValue({ vi.spyOn(ForbiddenError, "from").mockReturnValue({
@@ -95,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({ service = certificateV3ServiceFactory({
certificateDAL: mockCertificateDAL, certificateDAL: mockCertificateDAL,
certificateSecretDAL: mockCertificateSecretDAL,
certificateAuthorityDAL: mockCertificateAuthorityDAL, certificateAuthorityDAL: mockCertificateAuthorityDAL,
certificateProfileDAL: mockCertificateProfileDAL, certificateProfileDAL: mockCertificateProfileDAL,
certificateTemplateV2Service: mockCertificateTemplateV2Service, certificateTemplateV2Service: mockCertificateTemplateV2Service,
@@ -641,6 +673,11 @@ describe("CertificateV3Service", () => {
vi.mocked(mockCertificateProfileDAL.findByIdWithConfigs).mockResolvedValue(mockProfile); vi.mocked(mockCertificateProfileDAL.findByIdWithConfigs).mockResolvedValue(mockProfile);
vi.mocked(mockCertificateAuthorityDAL.findByIdWithAssociatedCa).mockResolvedValue(mockCA); vi.mocked(mockCertificateAuthorityDAL.findByIdWithAssociatedCa).mockResolvedValue(mockCA);
vi.mocked(mockCertificateTemplateV2Service.getTemplateV2ById).mockResolvedValue(mockTemplate); 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(mockInternalCaService.signCertFromCa).mockResolvedValue(mockSignResult as any);
vi.mocked(mockCertificateDAL.findOne).mockResolvedValue(mockCertRecord); vi.mocked(mockCertificateDAL.findOne).mockResolvedValue(mockCertRecord);
vi.mocked(mockCertificateDAL.updateById).mockResolvedValue(mockCertRecord); vi.mocked(mockCertificateDAL.updateById).mockResolvedValue(mockCertRecord);
@@ -1460,4 +1497,714 @@ describe("CertificateV3Service", () => {
).resolves.toBeDefined(); ).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,
renewedByCertificateId: 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,
renewedFromCertificateId: 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(() => {
// 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 () => {
// 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);
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);
// Mock the transaction to return the expected structure
vi.mocked(mockCertificateDAL.transaction).mockImplementation(async (callback: (tx: any) => Promise<unknown>) => {
const mockTx = {};
const result = await callback(mockTx);
return result;
});
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,
renewedFromCertificateId: "cert-123"
},
{}
);
expect(mockCertificateDAL.updateById).toHaveBeenCalledWith(
"cert-123",
{
renewedByCertificateId: "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: []
});
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);
// Set up transaction mock to properly handle errors
vi.mocked(mockCertificateDAL.transaction).mockImplementation(async (callback: (tx: any) => Promise<unknown>) => {
const mockTx = {};
return callback(mockTx);
});
await expect(
service.renewCertificate({
certificateId: "cert-123",
...mockActor
})
).rejects.toThrow(BadRequestError);
await expect(
service.renewCertificate({
certificateId: "cert-123",
...mockActor
})
).rejects.toThrow("Certificate renewal failed. Errors: Subject alternative name not allowed");
// 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);
// Set up transaction mock to properly handle errors
vi.mocked(mockCertificateDAL.transaction).mockImplementation(async (callback: (tx: any) => Promise<unknown>) => {
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("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<unknown>) => {
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);
// Set up transaction mock to properly handle errors
vi.mocked(mockCertificateDAL.transaction).mockImplementation(async (callback: (tx: any) => Promise<unknown>) => {
const mockTx = {};
return callback(mockTx);
});
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);
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);
// Set up transaction mock to properly handle errors
vi.mocked(mockCertificateDAL.transaction).mockImplementation(async (callback: (tx: any) => Promise<unknown>) => {
const mockTx = {};
return callback(mockTx);
});
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);
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);
// Set up transaction mock to properly handle errors
vi.mocked(mockCertificateDAL.transaction).mockImplementation(async (callback: (tx: any) => Promise<unknown>) => {
const mockTx = {};
return callback(mockTx);
});
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);
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);
// Set up transaction mock to properly handle errors
vi.mocked(mockCertificateDAL.transaction).mockImplementation(async (callback: (tx: any) => Promise<unknown>) => {
const mockTx = {};
return callback(mockTx);
});
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);
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);
// Set up transaction mock to properly handle errors
vi.mocked(mockCertificateDAL.transaction).mockImplementation(async (callback: (tx: any) => Promise<unknown>) => {
const mockTx = {};
return callback(mockTx);
});
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 \(.+\) after its issuing CA \(.+\)/);
});
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(mockCertificateSecretDAL.findOne).mockResolvedValue({ id: "secret-123", certId: "cert-123" } as any);
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);
// Set up transaction mock to properly handle the renewal process
vi.mocked(mockCertificateDAL.transaction).mockImplementation(async (callback: (tx: any) => Promise<unknown>) => {
const mockTx = {};
return callback(mockTx);
});
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",
renewedByCertificateId: null,
notBefore: new Date("2026-01-01"),
notAfter: new Date("2026-02-01"),
projectId: "project-123",
status: CertStatus.ACTIVE,
revokedAt: null,
commonName: ""
};
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(mockCertificateSecretDAL.findOne).mockResolvedValue({ id: "secret-123", certId: "cert-123" } 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,
commonName: ""
});
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,
renewedByCertificateId: 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",
renewedByCertificateId: "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);
vi.mocked(mockCertificateSecretDAL.findOne).mockResolvedValue({ id: "secret-123", certId: "cert-123" } 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",
renewedByCertificateId: 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);
vi.mocked(mockCertificateSecretDAL.findOne).mockResolvedValue({ id: "secret-123", certId: "cert-123" } 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",
commonName: ""
};
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",
commonName: ""
});
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");
});
});
}); });
@@ -1,36 +1,49 @@
import { ForbiddenError } from "@casl/ability"; import { ForbiddenError } from "@casl/ability";
import { randomUUID } from "crypto"; import { randomUUID } from "crypto";
import RE2 from "re2";
import { ActionProjectType } from "@app/db/schemas"; import { ActionProjectType } from "@app/db/schemas";
import { TPermissionServiceFactory } from "@app/ee/services/permission/permission-service-types"; import { TPermissionServiceFactory } from "@app/ee/services/permission/permission-service-types";
import { import {
ProjectPermissionCertificateActions,
ProjectPermissionCertificateProfileActions, ProjectPermissionCertificateProfileActions,
ProjectPermissionSub ProjectPermissionSub
} from "@app/ee/services/permission/project-permission"; } from "@app/ee/services/permission/project-permission";
import { BadRequestError, ForbiddenRequestError, NotFoundError } from "@app/lib/errors"; import { BadRequestError, ForbiddenRequestError, NotFoundError } from "@app/lib/errors";
import { ActorAuthMethod, ActorType } from "@app/services/auth/auth-type"; import { ActorAuthMethod, ActorType } from "@app/services/auth/auth-type";
import { TCertificateDALFactory } from "@app/services/certificate/certificate-dal"; import { TCertificateDALFactory } from "@app/services/certificate/certificate-dal";
import { TCertificateSecretDALFactory } from "@app/services/certificate/certificate-secret-dal";
import { import {
CertExtendedKeyUsage,
CertificateOrderStatus, CertificateOrderStatus,
CertKeyAlgorithm, CertKeyAlgorithm,
CertSignatureAlgorithm CertKeyType,
CertKeyUsage,
CertSignatureAlgorithm,
CertStatus
} from "@app/services/certificate/certificate-types"; } from "@app/services/certificate/certificate-types";
import { import {
TCertificateAuthorityDALFactory, TCertificateAuthorityDALFactory,
TCertificateAuthorityWithAssociatedCa TCertificateAuthorityWithAssociatedCa
} from "@app/services/certificate-authority/certificate-authority-dal"; } 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 { TInternalCertificateAuthorityServiceFactory } from "@app/services/certificate-authority/internal/internal-certificate-authority-service";
import { TCertificateProfileDALFactory } from "@app/services/certificate-profile/certificate-profile-dal"; import { TCertificateProfileDALFactory } from "@app/services/certificate-profile/certificate-profile-dal";
import { EnrollmentType } from "@app/services/certificate-profile/certificate-profile-types"; import { EnrollmentType } from "@app/services/certificate-profile/certificate-profile-types";
import { TCertificateTemplateV2ServiceFactory } from "@app/services/certificate-template-v2/certificate-template-v2-service"; import { TCertificateTemplateV2ServiceFactory } from "@app/services/certificate-template-v2/certificate-template-v2-service";
import { CertSubjectAlternativeNameType } from "../certificate-common/certificate-constants"; import { CertSubjectAlternativeNameType } from "../certificate-common/certificate-constants";
import {
extractAlgorithmsFromCSR,
extractCertificateRequestFromCSR
} from "../certificate-common/certificate-csr-utils";
import { import {
bufferToString, bufferToString,
buildCertificateSubjectFromTemplate, buildCertificateSubjectFromTemplate,
buildSubjectAlternativeNamesFromTemplate, buildSubjectAlternativeNamesFromTemplate,
convertExtendedKeyUsageArrayFromLegacy,
convertExtendedKeyUsageArrayToLegacy, convertExtendedKeyUsageArrayToLegacy,
convertKeyUsageArrayFromLegacy,
convertKeyUsageArrayToLegacy, convertKeyUsageArrayToLegacy,
mapEnumsForValidation, mapEnumsForValidation,
normalizeDateForApi normalizeDateForApi
@@ -38,13 +51,19 @@ import {
import { import {
TCertificateFromProfileResponse, TCertificateFromProfileResponse,
TCertificateOrderResponse, TCertificateOrderResponse,
TDisableRenewalConfigDTO,
TDisableRenewalResponse,
TIssueCertificateFromProfileDTO, TIssueCertificateFromProfileDTO,
TOrderCertificateFromProfileDTO, TOrderCertificateFromProfileDTO,
TSignCertificateFromProfileDTO TRenewalConfigResponse,
TRenewCertificateDTO,
TSignCertificateFromProfileDTO,
TUpdateRenewalConfigDTO
} from "./certificate-v3-types"; } from "./certificate-v3-types";
type TCertificateV3ServiceFactoryDep = { type TCertificateV3ServiceFactoryDep = {
certificateDAL: Pick<TCertificateDALFactory, "findOne" | "updateById">; certificateDAL: Pick<TCertificateDALFactory, "findOne" | "findById" | "updateById" | "transaction">;
certificateSecretDAL: Pick<TCertificateSecretDALFactory, "findOne">;
certificateAuthorityDAL: Pick<TCertificateAuthorityDALFactory, "findByIdWithAssociatedCa">; certificateAuthorityDAL: Pick<TCertificateAuthorityDALFactory, "findByIdWithAssociatedCa">;
certificateProfileDAL: Pick<TCertificateProfileDALFactory, "findByIdWithConfigs">; certificateProfileDAL: Pick<TCertificateProfileDALFactory, "findByIdWithConfigs">;
certificateTemplateV2Service: Pick< certificateTemplateV2Service: Pick<
@@ -95,6 +114,77 @@ const validateProfileAndPermissions = async (
return profile; return profile;
}; };
const validateRenewalEligibility = (
certificate: {
id: string;
status: string;
notBefore: Date;
notAfter: Date;
revokedAt?: Date | null;
renewedByCertificateId?: 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.renewedByCertificateId) {
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 validateCaSupport = (ca: TCertificateAuthorityWithAssociatedCa, operation: string) => {
const caType = (ca.externalCa?.type as CaType) ?? CaType.INTERNAL; const caType = (ca.externalCa?.type as CaType) ?? CaType.INTERNAL;
if (caType !== CaType.INTERNAL) { if (caType !== CaType.INTERNAL) {
@@ -129,11 +219,11 @@ const validateAlgorithmCompatibility = (
const keyType = parts[parts.length - 1]; const keyType = parts[parts.length - 1];
if (caKeyAlgorithm.startsWith("RSA")) { if (caKeyAlgorithm.startsWith("RSA")) {
return keyType === "RSA"; return keyType === CertKeyType.RSA;
} }
if (caKeyAlgorithm.startsWith("EC")) { if (caKeyAlgorithm.startsWith("EC")) {
return keyType === "ECDSA"; return keyType === CertKeyType.ECDSA;
} }
return false; return false;
@@ -155,8 +245,85 @@ const extractCertificateFromBuffer = (certData: Buffer | { rawData: Buffer } | s
return bufferToString(certData as unknown as Buffer); 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}` });
}
};
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 = ({ export const certificateV3ServiceFactory = ({
certificateDAL, certificateDAL,
certificateSecretDAL,
certificateAuthorityDAL, certificateAuthorityDAL,
certificateProfileDAL, certificateProfileDAL,
certificateTemplateV2Service, certificateTemplateV2Service,
@@ -198,7 +365,8 @@ export const certificateV3ServiceFactory = ({
actorId, actorId,
actorAuthMethod, actorAuthMethod,
actorOrgId, actorOrgId,
templateId: profile.certificateTemplateId templateId: profile.certificateTemplateId,
internal: true
}); });
if (!template) { if (!template) {
throw new NotFoundError({ message: "Certificate template not found for this profile" }); throw new NotFoundError({ message: "Certificate template not found for this profile" });
@@ -222,10 +390,6 @@ export const certificateV3ServiceFactory = ({
validateCaSupport(ca, "direct certificate issuance"); validateCaSupport(ca, "direct certificate issuance");
if (!actorAuthMethod) {
throw new BadRequestError({ message: "Authentication method is required for certificate issuance" });
}
validateAlgorithmCompatibility(ca, template); validateAlgorithmCompatibility(ca, template);
const effectiveSignatureAlgorithm = certificateRequest.signatureAlgorithm as CertSignatureAlgorithm | undefined; const effectiveSignatureAlgorithm = certificateRequest.signatureAlgorithm as CertSignatureAlgorithm | undefined;
@@ -274,7 +438,16 @@ export const certificateV3ServiceFactory = ({
throw new NotFoundError({ message: "Certificate was issued but could not be found in database" }); throw new NotFoundError({ message: "Certificate was issued but could not be found in database" });
} }
await certificateDAL.updateById(cert.id, { profileId }); const finalRenewBeforeDays = calculateFinalRenewBeforeDays(
profile,
certificateRequest.validity.ttl,
new Date(cert.notAfter)
);
await certificateDAL.updateById(cert.id, {
profileId,
renewBeforeDays: finalRenewBeforeDays
});
return { return {
certificate: bufferToString(certificate), certificate: bufferToString(certificate),
@@ -284,7 +457,8 @@ export const certificateV3ServiceFactory = ({
serialNumber, serialNumber,
certificateId: cert.id, certificateId: cert.id,
projectId: profile.projectId, projectId: profile.projectId,
profileName: profile.slug profileName: profile.slug,
commonName: cert.commonName || ""
}; };
}; };
@@ -294,8 +468,6 @@ export const certificateV3ServiceFactory = ({
validity, validity,
notBefore, notBefore,
notAfter, notAfter,
signatureAlgorithm,
keyAlgorithm,
actor, actor,
actorId, actorId,
actorAuthMethod, actorAuthMethod,
@@ -319,38 +491,40 @@ export const certificateV3ServiceFactory = ({
validateCaSupport(ca, "CSR signing"); validateCaSupport(ca, "CSR signing");
if (!actorAuthMethod) {
throw new BadRequestError({ message: "Authentication method is required for certificate signing" });
}
const template = await certificateTemplateV2Service.getTemplateV2ById({ const template = await certificateTemplateV2Service.getTemplateV2ById({
actor, actor,
actorId, actorId,
actorAuthMethod, actorAuthMethod,
actorOrgId, actorOrgId,
templateId: profile.certificateTemplateId templateId: profile.certificateTemplateId,
internal: true
}); });
if (!template) { if (!template) {
throw new NotFoundError({ message: "Certificate template not found for this profile" }); 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); validateAlgorithmCompatibility(ca, template);
const effectiveSignatureAlgorithm = signatureAlgorithm; const effectiveSignatureAlgorithm = extractedSignatureAlgorithm;
const effectiveKeyAlgorithm = keyAlgorithm; const effectiveKeyAlgorithm = extractedKeyAlgorithm;
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 { certificate, certificateChain, issuingCaCertificate, serialNumber } = const { certificate, certificateChain, issuingCaCertificate, serialNumber } =
await internalCaService.signCertFromCa({ await internalCaService.signCertFromCa({
@@ -371,7 +545,12 @@ export const certificateV3ServiceFactory = ({
throw new NotFoundError({ message: "Certificate was signed but could not be found in database" }); throw new NotFoundError({ message: "Certificate was signed but could not be found in database" });
} }
await certificateDAL.updateById(cert.id, { profileId }); const finalRenewBeforeDays = calculateFinalRenewBeforeDays(profile, validity.ttl, new Date(cert.notAfter));
await certificateDAL.updateById(cert.id, {
profileId,
renewBeforeDays: finalRenewBeforeDays
});
const certificateString = extractCertificateFromBuffer(certificate as unknown as Buffer); const certificateString = extractCertificateFromBuffer(certificate as unknown as Buffer);
const certificateChainString = extractCertificateFromBuffer(certificateChain as unknown as Buffer); const certificateChainString = extractCertificateFromBuffer(certificateChain as unknown as Buffer);
@@ -383,7 +562,8 @@ export const certificateV3ServiceFactory = ({
serialNumber, serialNumber,
certificateId: cert.id, certificateId: cert.id,
projectId: profile.projectId, projectId: profile.projectId,
profileName: profile.slug profileName: profile.slug,
commonName: cert.commonName || ""
}; };
}; };
@@ -479,9 +659,405 @@ export const certificateV3ServiceFactory = ({
}); });
}; };
const renewCertificate = async ({
certificateId,
actor,
actorId,
actorAuthMethod,
actorOrgId,
internal = false
}: TRenewCertificateDTO & { internal?: boolean }): Promise<TCertificateFromProfileResponse> => {
const renewalResult = await certificateDAL.transaction(async (tx) => {
const originalCert = await certificateDAL.findById(certificateId, tx);
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 !== EnrollmentType.API) {
throw new ForbiddenRequestError({
message: "Certificate is not eligible for renewal: EST certificates cannot be renewed through this endpoint"
});
}
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,
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
});
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 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 finalRenewBeforeDays = calculateFinalRenewBeforeDays(profile, ttl, notAfter);
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
};
});
return {
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 || ""
};
};
const updateRenewalConfig = async ({
certificateId,
renewBeforeDays,
actor,
actorId,
actorAuthMethod,
actorOrgId
}: TUpdateRenewalConfigDTO): Promise<TRenewalConfigResponse> => {
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 !== EnrollmentType.API) {
throw new ForbiddenRequestError({
message: "Certificate is not eligible for auto-renewal: EST certificates cannot be auto-renewed"
});
}
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`
});
}
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.renewedByCertificateId) {
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,
commonName: certificate.commonName || ""
};
};
const disableRenewalConfig = async ({
certificateId,
actor,
actorId,
actorAuthMethod,
actorOrgId
}: TDisableRenewalConfigDTO): Promise<TDisableRenewalResponse> => {
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 !== 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,
commonName: certificate.commonName || ""
};
};
return { return {
issueCertificateFromProfile, issueCertificateFromProfile,
signCertificateFromProfile, signCertificateFromProfile,
orderCertificateFromProfile orderCertificateFromProfile,
renewCertificate,
updateRenewalConfig,
disableRenewalConfig
}; };
}; };
@@ -35,8 +35,6 @@ export type TSignCertificateFromProfileDTO = {
}; };
notBefore?: Date; notBefore?: Date;
notAfter?: Date; notAfter?: Date;
signatureAlgorithm?: string;
keyAlgorithm?: string;
} & Omit<TProjectPermission, "projectId">; } & Omit<TProjectPermission, "projectId">;
export type TOrderCertificateFromProfileDTO = { export type TOrderCertificateFromProfileDTO = {
@@ -68,6 +66,7 @@ export type TCertificateFromProfileResponse = {
certificateId: string; certificateId: string;
projectId: string; projectId: string;
profileName: string; profileName: string;
commonName: string;
}; };
export type TCertificateOrderResponse = { export type TCertificateOrderResponse = {
@@ -97,3 +96,27 @@ export type TCertificateOrderResponse = {
projectId: string; projectId: string;
profileName: string; profileName: string;
}; };
export type TRenewCertificateDTO = {
certificateId: string;
} & Omit<TProjectPermission, "projectId">;
export type TUpdateRenewalConfigDTO = {
certificateId: string;
renewBeforeDays: number;
} & Omit<TProjectPermission, "projectId">;
export type TDisableRenewalConfigDTO = {
certificateId: string;
} & Omit<TProjectPermission, "projectId">;
export type TRenewalConfigResponse = {
projectId: string;
renewBeforeDays: number;
commonName: string;
};
export type TDisableRenewalResponse = {
projectId: string;
commonName: string;
};
@@ -1,7 +1,7 @@
import { TDbClient } from "@app/db"; import { TDbClient } from "@app/db";
import { TableName, TCertificates } from "@app/db/schemas"; import { TableName, TCertificates } from "@app/db/schemas";
import { DatabaseError } from "@app/lib/errors"; import { DatabaseError } from "@app/lib/errors";
import { ormify } from "@app/lib/knex"; import { ormify, selectAllTableCols } from "@app/lib/knex";
import { CertStatus } from "./certificate-types"; import { CertStatus } from "./certificate-types";
@@ -114,12 +114,94 @@ export const certificateDALFactory = (db: TDbClient) => {
} }
}; };
const findCertificatesEligibleForRenewal = async ({
limit,
offset
}: {
limit: number;
offset: number;
}): 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(selectAllTableCols(TableName.Certificate))
.select(db.ref("slug").withSchema(TableName.PkiCertificateProfile).as("profileName"))
.leftJoin(
TableName.PkiCertificateProfile,
`${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`)
.whereNull(`${TableName.Certificate}.revokedAt`)
.whereNotNull(`${TableName.Certificate}.profileId`)
.whereNotNull(`${TableName.Certificate}.notAfter`)
.where(`${TableName.Certificate}.notAfter`, ">", now)
.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" });
}
};
const findWithPrivateKeyInfo = async (
filter: Partial<TCertificates>,
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 { return {
...certificateOrm, ...certificateOrm,
countCertificatesInProject, countCertificatesInProject,
countCertificatesForPkiSubscriber, countCertificatesForPkiSubscriber,
findLatestActiveCertForSubscriber, findLatestActiveCertForSubscriber,
findAllActiveCertsForSubscriber, findAllActiveCertsForSubscriber,
findExpiredSyncedCertificates findExpiredSyncedCertificates,
findCertificatesEligibleForRenewal,
findWithPrivateKeyInfo
}; };
}; };
@@ -21,6 +21,11 @@ export enum CertKeyAlgorithm {
ECDSA_P521 = "EC_secp521r1" ECDSA_P521 = "EC_secp521r1"
} }
export enum CertKeyType {
RSA = "RSA",
ECDSA = "ECDSA"
}
export enum CertSignatureAlgorithm { export enum CertSignatureAlgorithm {
RSA_SHA256 = "RSA-SHA256", RSA_SHA256 = "RSA-SHA256",
RSA_SHA384 = "RSA-SHA384", RSA_SHA384 = "RSA-SHA384",
@@ -69,15 +69,15 @@ export const apiEnrollmentConfigDALFactory = (db: TDbClient) => {
const profiles = await query const profiles = await query
.where((qb) => { .where((qb) => {
void qb void qb
.whereNull(`${TableName.PkiApiEnrollmentConfig}.autoRenewDays`) .whereNull(`${TableName.PkiApiEnrollmentConfig}.renewBeforeDays`)
.orWhere(`${TableName.PkiApiEnrollmentConfig}.autoRenewDays`, "<=", renewalThresholdDays); .orWhere(`${TableName.PkiApiEnrollmentConfig}.renewBeforeDays`, "<=", renewalThresholdDays);
}) })
.select((tx || db).ref("id").withSchema(TableName.PkiCertificateProfile)) .select((tx || db).ref("id").withSchema(TableName.PkiCertificateProfile))
.select((tx || db).ref("name").withSchema(TableName.PkiCertificateProfile)) .select((tx || db).ref("name").withSchema(TableName.PkiCertificateProfile))
.select((tx || db).ref("projectId").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) { } catch (error) {
throw new DatabaseError({ error, name: "Find profiles for auto renewal" }); throw new DatabaseError({ error, name: "Find profiles for auto renewal" });
} }
@@ -25,5 +25,5 @@ export interface TEstConfigData {
export interface TApiConfigData { export interface TApiConfigData {
autoRenew: boolean; autoRenew: boolean;
autoRenewDays?: number; renewBeforeDays?: number;
} }
@@ -154,7 +154,7 @@ type TProjectServiceFactoryDep = {
>; >;
pkiSubscriberDAL: Pick<TPkiSubscriberDALFactory, "find">; pkiSubscriberDAL: Pick<TPkiSubscriberDALFactory, "find">;
certificateAuthorityDAL: Pick<TCertificateAuthorityDALFactory, "find" | "findWithAssociatedCa">; certificateAuthorityDAL: Pick<TCertificateAuthorityDALFactory, "find" | "findWithAssociatedCa">;
certificateDAL: Pick<TCertificateDALFactory, "find" | "countCertificatesInProject">; certificateDAL: Pick<TCertificateDALFactory, "find" | "countCertificatesInProject" | "findWithPrivateKeyInfo">;
certificateTemplateDAL: Pick<TCertificateTemplateDALFactory, "getCertTemplatesByProjectId">; certificateTemplateDAL: Pick<TCertificateTemplateDALFactory, "getCertTemplatesByProjectId">;
pkiAlertDAL: Pick<TPkiAlertDALFactory, "find">; pkiAlertDAL: Pick<TPkiAlertDALFactory, "find">;
pkiCollectionDAL: Pick<TPkiCollectionDALFactory, "find">; pkiCollectionDAL: Pick<TPkiCollectionDALFactory, "find">;
@@ -938,13 +938,13 @@ export const projectServiceFactory = ({
ProjectPermissionSub.Certificates ProjectPermissionSub.Certificates
); );
const certificates = await certificateDAL.find( const certificates = await certificateDAL.findWithPrivateKeyInfo(
{ {
projectId, projectId,
...(friendlyName && { friendlyName }), ...(friendlyName && { friendlyName }),
...(commonName && { commonName }) ...(commonName && { commonName })
}, },
{ offset, limit, sort: [["updatedAt", "desc"]] } { offset, limit, sort: [["notAfter", "desc"]] }
); );
const count = await certificateDAL.countCertificatesInProject({ const count = await certificateDAL.countCertificatesInProject({
@@ -35,7 +35,7 @@ export type TCertificateProfileWithDetails = TCertificateProfile & {
apiConfig?: { apiConfig?: {
id: string; id: string;
autoRenew: boolean; autoRenew: boolean;
autoRenewDays?: number; renewBeforeDays?: number;
}; };
}; };
@@ -53,7 +53,7 @@ export type TCreateCertificateProfileDTO = {
}; };
apiConfig?: { apiConfig?: {
autoRenew?: boolean; autoRenew?: boolean;
autoRenewDays?: number; renewBeforeDays?: number;
}; };
}; };
@@ -68,7 +68,7 @@ export type TUpdateCertificateProfileDTO = {
}; };
apiConfig?: { apiConfig?: {
autoRenew?: boolean; autoRenew?: boolean;
autoRenewDays?: number; renewBeforeDays?: number;
}; };
}; };
@@ -1,2 +1,8 @@
export { useDeleteCert, useImportCertificate, useRevokeCert } from "./mutations"; export {
useDeleteCert,
useImportCertificate,
useRenewCertificate,
useRevokeCert,
useUpdateRenewalConfig
} from "./mutations";
export { useGetCert, useGetCertBody } from "./queries"; export { useGetCert, useGetCertBody } from "./queries";
@@ -9,7 +9,10 @@ import {
TDeleteCertDTO, TDeleteCertDTO,
TImportCertificateDTO, TImportCertificateDTO,
TImportCertificateResponse, TImportCertificateResponse,
TRevokeCertDTO TRenewCertificateDTO,
TRenewCertificateResponse,
TRevokeCertDTO,
TUpdateRenewalConfigDTO
} from "./types"; } from "./types";
export const useDeleteCert = () => { export const useDeleteCert = () => {
@@ -77,3 +80,57 @@ export const useImportCertificate = () => {
} }
}); });
}; };
export const useRenewCertificate = () => {
const queryClient = useQueryClient();
return useMutation<TRenewCertificateResponse, object, TRenewCertificateDTO>({
mutationFn: async ({ certificateId }) => {
const { data } = await apiRequest.post<TRenewCertificateResponse>(
`/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
>({
mutationFn: async ({ certificateId, renewBeforeDays, enableAutoRenewal }) => {
const { data } = await apiRequest.patch<{ message: string; renewBeforeDays?: number }>(
`/api/v3/certificates/${certificateId}/config`,
{ renewBeforeDays, enableAutoRenewal }
);
return data;
},
onSuccess: (_, { projectSlug }) => {
queryClient.invalidateQueries({
queryKey: projectKeys.forProjectCertificates(projectSlug)
});
queryClient.invalidateQueries({
queryKey: projectKeys.allProjectCertificates()
});
}
});
};
@@ -4,6 +4,7 @@ export type TCertificate = {
id: string; id: string;
caId: string; caId: string;
certificateTemplateId?: string; certificateTemplateId?: string;
profileId?: string;
status: CertStatus; status: CertStatus;
friendlyName: string; friendlyName: string;
commonName: string; commonName: string;
@@ -13,6 +14,12 @@ export type TCertificate = {
notAfter: string; notAfter: string;
keyUsages: CertKeyUsage[]; keyUsages: CertKeyUsage[];
extendedKeyUsages: CertExtendedKeyUsage[]; extendedKeyUsages: CertExtendedKeyUsage[];
renewBeforeDays?: number;
renewedBy?: string;
renewedFromCertificateId?: string;
renewedByCertificateId?: string;
renewalError?: string;
hasPrivateKey?: boolean;
}; };
export type TDeleteCertDTO = { export type TDeleteCertDTO = {
@@ -43,3 +50,24 @@ export type TImportCertificateResponse = {
privateKey: string; privateKey: string;
serialNumber: 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;
enableAutoRenewal?: boolean;
projectSlug: string;
};
@@ -0,0 +1,265 @@
import { useEffect, useMemo } 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 { useGetCertificateProfileById } from "@app/hooks/api/certificateProfiles";
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 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(
(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<ReturnType<typeof createFormSchema>>;
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<void>;
isLoading: boolean;
buttonText: string;
onCancel: () => void;
}) => (
<form onSubmit={onSubmit}>
<FormControl
label="Auto-renew days before expiry"
isError={Boolean(errors.renewBeforeDays)}
errorText={errors.renewBeforeDays?.message}
className="mb-6"
>
<Controller
control={control}
name="renewBeforeDays"
render={({ field }) => (
<Input
{...field}
type="number"
min={MIN_RENEWAL_BEFORE_DAYS}
max={MAX_RENEWAL_BEFORE_DAYS}
onChange={(e) => {
const value = parseInt(e.target.value, 10);
field.onChange(value);
}}
placeholder="Enter days before expiration"
/>
)}
/>
</FormControl>
<div className="flex justify-end gap-3">
<Button type="button" colorSchema="secondary" variant="plain" onClick={onCancel}>
Cancel
</Button>
<Button type="submit" colorSchema="primary" isLoading={isLoading} isDisabled={isLoading}>
{buttonText}
</Button>
</div>
</form>
);
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;
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
);
const hasRenewalError = Boolean(certificateData?.renewalError);
const formSchema = createFormSchema(
certificateData?.ttlDays || 365,
certificateData?.notAfter || ""
);
const {
control,
handleSubmit,
formState: { errors },
reset
} = useForm<FormData>({
resolver: zodResolver(formSchema),
defaultValues: {
renewBeforeDays: defaultRenewalDays
}
});
useEffect(() => {
if (popUp.manageRenewal.isOpen) {
reset({
renewBeforeDays: defaultRenewalDays
});
}
}, [popUp.manageRenewal.isOpen, defaultRenewalDays, 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;
}
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 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 || ""}`;
};
if (!certificateData) {
return null;
}
return (
<Modal
isOpen={popUp?.manageRenewal?.isOpen}
onOpenChange={(isOpen) => {
handlePopUpToggle("manageRenewal", isOpen);
}}
>
<ModalContent title={getModalTitle()}>
{hasRenewalError && (
<div className="mb-6 rounded-md border border-red-600 bg-red-900/20 p-4">
<div className="flex items-start gap-3">
<div className="mt-1 flex h-5 w-5 items-center justify-center rounded-full bg-red-600">
<span className="text-xs font-bold text-white">!</span>
</div>
<div className="flex-1">
<h3 className="font-medium text-red-400">Automatic Renewal Failed</h3>
<p className="mt-1 text-sm text-red-300">
The last automatic renewal attempt failed: {certificateData.renewalError}
</p>
<p className="mt-2 text-sm text-red-300">
You can reconfigure auto-renewal below or disable it completely.
</p>
</div>
</div>
</div>
)}
{(!isAutoRenewalEnabled || hasRenewalError) && (
<RenewalConfigForm
control={control}
errors={errors}
onSubmit={handleSubmit(onUpdateRenewal)}
isLoading={isUpdatingConfig}
buttonText={isAutoRenewalEnabled ? "Update Configuration" : "Enable Auto-Renewal"}
onCancel={() => handlePopUpToggle("manageRenewal", false)}
/>
)}
{isAutoRenewalEnabled && !hasRenewalError && (
<RenewalConfigForm
control={control}
errors={errors}
onSubmit={handleSubmit(onUpdateRenewal)}
isLoading={isUpdatingConfig}
buttonText="Update Configuration"
onCancel={() => handlePopUpToggle("manageRenewal", false)}
/>
)}
</ModalContent>
</Modal>
);
};
@@ -0,0 +1,178 @@
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 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<ReturnType<typeof createFormSchema>>;
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 formSchema = createFormSchema(certificateData.ttlDays);
const {
control,
handleSubmit,
formState: { errors },
watch
} = useForm<FormData>({
resolver: zodResolver(formSchema),
defaultValues: {
renewBeforeDays: certificateData?.renewBeforeDays || 1
}
});
const renewBeforeDays = watch("renewBeforeDays");
const onSubmit = async (data: FormData) => {
try {
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 (
<Modal
isOpen={popUp?.configureRenewal?.isOpen}
onOpenChange={(isOpen) => {
handlePopUpToggle("configureRenewal", isOpen);
}}
>
<ModalContent title={`Configure Auto-Renewal: ${certificateData?.commonName || ""}`}>
<form onSubmit={handleSubmit(onSubmit)}>
<div className="mb-4">
<p className="mb-4 text-sm text-mineshaft-300">
Configure when this certificate should be automatically renewed. The certificate will
be renewed when it has the specified number of days remaining before expiration.
</p>
<div className="mb-4 rounded border bg-mineshaft-800 p-3">
<p className="text-sm text-mineshaft-300">
<strong>Certificate TTL:</strong> {certificateData?.ttlDays} days
</p>
<p className="text-sm text-mineshaft-300">
<strong>Current Setting:</strong>{" "}
{certificateData?.renewBeforeDays
? `${certificateData.renewBeforeDays} days before expiration`
: "Disabled"}
</p>
</div>
<Controller
control={control}
name="renewBeforeDays"
render={({ field }) => (
<FormControl
label="Renew Before Days"
isError={Boolean(errors.renewBeforeDays)}
errorText={errors.renewBeforeDays?.message}
>
<Input
{...field}
type="number"
min={1}
max={certificateData?.ttlDays ? certificateData.ttlDays - 1 : undefined}
placeholder="Enter days before expiration"
onChange={(e) => {
const value = parseInt(e.target.value, 10);
field.onChange(Number.isNaN(value) ? 0 : value);
}}
/>
</FormControl>
)}
/>
{renewBeforeDays && certificateData?.ttlDays && (
<div className="mt-2 rounded bg-primary-900/20 p-2">
<p className="text-sm text-primary-300">
{renewBeforeDays >= certificateData.ttlDays
? "⚠️ Renewal days must be less than certificate TTL"
: `✓ Certificate will be renewed ${renewBeforeDays} days before expiration`}
</p>
</div>
)}
</div>
<div className="flex items-center gap-2">
<Button
className="mr-4"
size="sm"
type="submit"
isLoading={isSubmitting}
isDisabled={isSubmitting || renewBeforeDays >= (certificateData?.ttlDays || 0)}
>
Update Configuration
</Button>
<Button
colorSchema="secondary"
variant="plain"
onClick={() => handlePopUpToggle("configureRenewal", false)}
>
Cancel
</Button>
</div>
</form>
</ModalContent>
</Modal>
);
};
@@ -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,
enableAutoRenewal: false
});
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 (
<Modal
isOpen={popUp?.disableRenewal?.isOpen}
onOpenChange={(isOpen) => {
handlePopUpToggle("disableRenewal", isOpen);
}}
>
<ModalContent title={`Disable Auto-Renewal: ${certificateData?.commonName || ""}`}>
<div className="mb-4">
<p className="mb-3 text-sm text-mineshaft-300">
Are you sure you want to disable auto-renewal for this certificate?
</p>
<div className="rounded border border-yellow-700/50 bg-yellow-900/20 p-3">
<p className="text-sm text-yellow-300">
<strong>Warning:</strong> Once disabled, this certificate will not be automatically
renewed and may expire without notice. You can re-enable auto-renewal at any time.
</p>
</div>
</div>
<div className="flex items-center gap-2">
<Button
className="mr-4"
size="sm"
colorSchema="danger"
onClick={onDisableConfirm}
isLoading={isSubmitting}
isDisabled={isSubmitting}
>
Disable Auto-Renewal
</Button>
<Button
colorSchema="secondary"
variant="plain"
onClick={() => handlePopUpToggle("disableRenewal", false)}
>
Cancel
</Button>
</div>
</ModalContent>
</Modal>
);
};
@@ -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 (
<Modal
isOpen={popUp?.renewCertificate?.isOpen}
onOpenChange={(isOpen) => {
handlePopUpToggle("renewCertificate", isOpen);
}}
>
<ModalContent title={`Renew Certificate: ${certificateData?.commonName || ""}`}>
<div className="mb-6">
<p className="mb-4 text-sm text-mineshaft-300">
Are you sure you want to renew this certificate now?
</p>
</div>
<div className="flex items-center gap-3">
<Button
onClick={onRenewConfirm}
colorSchema="primary"
isLoading={isRenewing}
isDisabled={isRenewing}
>
<FontAwesomeIcon icon={faRedo} className="mr-2" />
Renew Now
</Button>
<Button
colorSchema="secondary"
variant="plain"
onClick={() => handlePopUpToggle("renewCertificate", false)}
>
Cancel
</Button>
</div>
</ModalContent>
</Modal>
);
};
@@ -16,7 +16,9 @@ import { usePopUp } from "@app/hooks/usePopUp";
import { CertificateCertModal } from "./CertificateCertModal"; import { CertificateCertModal } from "./CertificateCertModal";
import { CertificateImportModal } from "./CertificateImportModal"; import { CertificateImportModal } from "./CertificateImportModal";
import { CertificateIssuanceModal } from "./CertificateIssuanceModal"; import { CertificateIssuanceModal } from "./CertificateIssuanceModal";
import { CertificateManageRenewalModal } from "./CertificateManageRenewalModal";
import { CertificateModal } from "./CertificateModal"; import { CertificateModal } from "./CertificateModal";
import { CertificateRenewalModal } from "./CertificateRenewalModal";
import { CertificateRevocationModal } from "./CertificateRevocationModal"; import { CertificateRevocationModal } from "./CertificateRevocationModal";
import { CertificatesTable } from "./CertificatesTable"; import { CertificatesTable } from "./CertificatesTable";
@@ -33,7 +35,9 @@ export const CertificatesSection = () => {
"certificateImport", "certificateImport",
"certificateCert", "certificateCert",
"deleteCertificate", "deleteCertificate",
"revokeCertificate" "revokeCertificate",
"manageRenewal",
"renewCertificate"
] as const); ] as const);
const onRemoveCertificateSubmit = async (serialNumber: string) => { const onRemoveCertificateSubmit = async (serialNumber: string) => {
@@ -98,6 +102,8 @@ export const CertificatesSection = () => {
)} )}
<CertificateImportModal popUp={popUp} handlePopUpToggle={handlePopUpToggle} /> <CertificateImportModal popUp={popUp} handlePopUpToggle={handlePopUpToggle} />
<CertificateCertModal popUp={popUp} handlePopUpToggle={handlePopUpToggle} /> <CertificateCertModal popUp={popUp} handlePopUpToggle={handlePopUpToggle} />
<CertificateManageRenewalModal popUp={popUp} handlePopUpToggle={handlePopUpToggle} />
<CertificateRenewalModal popUp={popUp} handlePopUpToggle={handlePopUpToggle} />
<CertificateRevocationModal popUp={popUp} handlePopUpToggle={handlePopUpToggle} /> <CertificateRevocationModal popUp={popUp} handlePopUpToggle={handlePopUpToggle} />
<DeleteActionModal <DeleteActionModal
isOpen={popUp.deleteCertificate.isOpen} isOpen={popUp.deleteCertificate.isOpen}
@@ -5,12 +5,15 @@ import {
faEllipsis, faEllipsis,
faEye, faEye,
faFileExport, faFileExport,
faQuestionCircle,
faRedo,
faTrash faTrash
} from "@fortawesome/free-solid-svg-icons"; } from "@fortawesome/free-solid-svg-icons";
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome"; import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
import { format } from "date-fns"; import { format } from "date-fns";
import { twMerge } from "tailwind-merge"; import { twMerge } from "tailwind-merge";
import { createNotification } from "@app/components/notifications";
import { ProjectPermissionCan } from "@app/components/permissions"; import { ProjectPermissionCan } from "@app/components/permissions";
import { import {
Badge, Badge,
@@ -33,25 +36,136 @@ import {
import { import {
ProjectPermissionCertificateActions, ProjectPermissionCertificateActions,
ProjectPermissionSub, ProjectPermissionSub,
useProject useProject,
useSubscription
} from "@app/context"; } from "@app/context";
import { useListWorkspaceCertificates } from "@app/hooks/api"; import { useListWorkspaceCertificates, useUpdateRenewalConfig } from "@app/hooks/api";
import { caSupportsCapability } from "@app/hooks/api/ca/constants"; import { caSupportsCapability } from "@app/hooks/api/ca/constants";
import { CaCapability, CaType } from "@app/hooks/api/ca/enums"; import { CaCapability, CaType } from "@app/hooks/api/ca/enums";
import { useListCasByProjectId } from "@app/hooks/api/ca/queries"; import { useListCasByProjectId } from "@app/hooks/api/ca/queries";
import { CertStatus } from "@app/hooks/api/certificates/enums"; import { CertStatus } from "@app/hooks/api/certificates/enums";
import { TCertificate } from "@app/hooks/api/certificates/types";
import { UsePopUpState } from "@app/hooks/usePopUp"; import { UsePopUpState } from "@app/hooks/usePopUp";
import { getCertValidUntilBadgeDetails } from "./CertificatesTable.utils"; import { getCertValidUntilBadgeDetails } from "./CertificatesTable.utils";
const isExpiringWithinOneDay = (notAfter: string): boolean => {
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.renewedByCertificateId) {
return { text: "Renewed", variant: "instance" 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) {
return {
text: "Not Available",
variant: "instance" as const,
tooltip: "Renewal is not available for revoked certificates"
};
}
if (isExpired) {
return {
text: "Not Available",
variant: "instance" as const,
tooltip: "Renewal is not available for expired certificates"
};
}
if (hasNoProfile) {
return {
text: "Not Available",
variant: "instance" as const,
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"
};
}
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) {
return {
text: "Failed",
variant: "danger" as const,
tooltip: certificate.renewalError
};
}
if (!certificate.renewBeforeDays) {
return { text: "Auto-Renewal 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.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 };
}
return { text: `Renews in ${daysUntilRenewal}d`, variant: "success" as const };
};
type Props = { type Props = {
handlePopUpOpen: ( handlePopUpOpen: (
popUpName: keyof UsePopUpState< popUpName: keyof UsePopUpState<
["certificate", "deleteCertificate", "revokeCertificate", "certificateCert"] [
"certificate",
"deleteCertificate",
"revokeCertificate",
"certificateCert",
"manageRenewal",
"renewCertificate"
]
>, >,
data?: { data?: {
serialNumber?: string; serialNumber?: string;
commonName?: string; commonName?: string;
certificateId?: string;
profileId?: string;
renewBeforeDays?: number;
ttlDays?: number;
notAfter?: string;
renewalError?: string;
renewedFromCertificateId?: string;
renewedByCertificateId?: string;
} }
) => void; ) => void;
}; };
@@ -61,6 +175,7 @@ const PER_PAGE_INIT = 25;
export const CertificatesTable = ({ handlePopUpOpen }: Props) => { export const CertificatesTable = ({ handlePopUpOpen }: Props) => {
const [page, setPage] = useState(1); const [page, setPage] = useState(1);
const [perPage, setPerPage] = useState(PER_PAGE_INIT); const [perPage, setPerPage] = useState(PER_PAGE_INIT);
const { subscription } = useSubscription();
const { currentProject } = useProject(); const { currentProject } = useProject();
const { data, isPending } = useListWorkspaceCertificates({ const { data, isPending } = useListWorkspaceCertificates({
@@ -69,10 +184,11 @@ export const CertificatesTable = ({ handlePopUpOpen }: Props) => {
limit: perPage limit: perPage
}); });
// Fetch CA data to determine capabilities const { mutateAsync: updateRenewalConfig } = useUpdateRenewalConfig();
const isLegacyTemplatesEnabled = subscription.pkiLegacyTemplates;
const { data: caData } = useListCasByProjectId(currentProject?.id ?? ""); const { data: caData } = useListCasByProjectId(currentProject?.id ?? "");
// Create mapping from caId to CA type for capability checking
const caCapabilityMap = useMemo(() => { const caCapabilityMap = useMemo(() => {
if (!caData) return {}; if (!caData) return {};
@@ -83,6 +199,35 @@ export const CertificatesTable = ({ handlePopUpOpen }: Props) => {
return map; return map;
}, [caData]); }, [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,
enableAutoRenewal: false
});
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 ( return (
<TableContainer> <TableContainer>
<Table> <Table>
@@ -92,14 +237,24 @@ export const CertificatesTable = ({ handlePopUpOpen }: Props) => {
<Th>Status</Th> <Th>Status</Th>
<Th>Not Before</Th> <Th>Not Before</Th>
<Th>Not After</Th> <Th>Not After</Th>
<Th>Renewal Status</Th>
<Th /> <Th />
</Tr> </Tr>
</THead> </THead>
<TBody> <TBody>
{isPending && <TableSkeleton columns={3} innerKey="project-cas" />} {isPending && <TableSkeleton columns={5} innerKey="project-cas" />}
{!isPending && {!isPending &&
data?.certificates.map((certificate) => { data?.certificates.map((certificate) => {
const { variant, label } = getCertValidUntilBadgeDetails(certificate.notAfter); 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 ( return (
<Tr className="h-10" key={`certificate-${certificate.id}`}> <Tr className="h-10" key={`certificate-${certificate.id}`}>
<Td>{certificate.commonName}</Td> <Td>{certificate.commonName}</Td>
@@ -120,6 +275,25 @@ export const CertificatesTable = ({ handlePopUpOpen }: Props) => {
? format(new Date(certificate.notAfter), "yyyy-MM-dd") ? format(new Date(certificate.notAfter), "yyyy-MM-dd")
: "-"} : "-"}
</Td> </Td>
<Td>
{autoRenewalInfo &&
(autoRenewalInfo.tooltip ? (
<div className="flex items-center gap-2">
<Badge variant={autoRenewalInfo.variant}>
{autoRenewalInfo.text}
<Tooltip content={autoRenewalInfo.tooltip}>
<FontAwesomeIcon
icon={faQuestionCircle}
className="ml-1 cursor-help text-red-400 hover:text-red-300"
size="sm"
/>
</Tooltip>
</Badge>
</div>
) : (
<Badge variant={autoRenewalInfo.variant}>{autoRenewalInfo.text}</Badge>
))}
</Td>
<Td className="flex justify-end"> <Td className="flex justify-end">
<DropdownMenu> <DropdownMenu>
<DropdownMenuTrigger asChild className="rounded-lg"> <DropdownMenuTrigger asChild className="rounded-lg">
@@ -151,31 +325,172 @@ export const CertificatesTable = ({ handlePopUpOpen }: Props) => {
</DropdownMenuItem> </DropdownMenuItem>
)} )}
</ProjectPermissionCan> </ProjectPermissionCan>
<ProjectPermissionCan {isLegacyTemplatesEnabled && (
I={ProjectPermissionCertificateActions.Read} <ProjectPermissionCan
a={ProjectPermissionSub.Certificates} I={ProjectPermissionCertificateActions.Read}
> a={ProjectPermissionSub.Certificates}
{(isAllowed) => ( >
<DropdownMenuItem {(isAllowed) => (
className={twMerge( <DropdownMenuItem
!isAllowed && "pointer-events-none cursor-not-allowed opacity-50" className={twMerge(
)} !isAllowed && "pointer-events-none cursor-not-allowed opacity-50"
onClick={async () => )}
handlePopUpOpen("certificate", { onClick={async () =>
serialNumber: certificate.serialNumber handlePopUpOpen("certificate", {
}) serialNumber: certificate.serialNumber
} })
disabled={!isAllowed} }
icon={<FontAwesomeIcon icon={faEye} />} disabled={!isAllowed}
icon={<FontAwesomeIcon icon={faEye} />}
>
View Details
</DropdownMenuItem>
)}
</ProjectPermissionCan>
)}
{/* Manage auto renewal option - not shown for failed renewals */}
{(() => {
const canManageRenewal =
certificate.profileId &&
certificate.hasPrivateKey !== false &&
!certificate.renewedByCertificateId &&
!isRevoked &&
!isExpired &&
!hasFailed &&
!isExpiringWithinDay;
if (!canManageRenewal) return null;
return (
<ProjectPermissionCan
I={ProjectPermissionCertificateActions.Edit}
a={ProjectPermissionSub.Certificates}
> >
View Details {(isAllowed) => {
</DropdownMenuItem> return (
)} <DropdownMenuItem
</ProjectPermissionCan> className={twMerge(
!isAllowed &&
"pointer-events-none cursor-not-allowed opacity-50"
)}
onClick={async () => {
const notAfterDate = new Date(certificate.notAfter);
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,
commonName: certificate.commonName,
profileId: certificate.profileId,
renewBeforeDays: certificate.renewBeforeDays,
ttlDays,
notAfter: certificate.notAfter,
renewalError: certificate.renewalError,
renewedFromCertificateId:
certificate.renewedFromCertificateId,
renewedByCertificateId: certificate.renewedByCertificateId
});
}}
disabled={!isAllowed}
icon={<FontAwesomeIcon icon={faRedo} />}
>
{isAutoRenewalEnabled
? "Manage auto renewal"
: "Enable auto renewal"}
</DropdownMenuItem>
);
}}
</ProjectPermissionCan>
);
})()}
{/* Disable auto renewal option - only shown when auto renewal is active */}
{(() => {
const canDisableRenewal =
certificate.profileId &&
certificate.hasPrivateKey !== false &&
!certificate.renewedByCertificateId &&
!isRevoked &&
!isExpired &&
!isExpiringWithinDay &&
isAutoRenewalEnabled;
if (!canDisableRenewal) return null;
return (
<ProjectPermissionCan
I={ProjectPermissionCertificateActions.Edit}
a={ProjectPermissionSub.Certificates}
>
{(isAllowed) => (
<DropdownMenuItem
className={twMerge(
!isAllowed &&
"pointer-events-none cursor-not-allowed opacity-50"
)}
onClick={async () => {
await handleDisableAutoRenewal(
certificate.id,
certificate.commonName
);
}}
disabled={!isAllowed}
icon={<FontAwesomeIcon icon={faBan} />}
>
Disable auto renewal
</DropdownMenuItem>
)}
</ProjectPermissionCan>
);
})()}
{/* Manual renewal action for profile-issued certificates that are not revoked/expired (including failed ones) */}
{(() => {
const canRenew =
certificate.profileId &&
certificate.hasPrivateKey !== false &&
!certificate.renewedByCertificateId &&
!isRevoked &&
!isExpired;
if (!canRenew) return null;
return (
<ProjectPermissionCan
I={ProjectPermissionCertificateActions.Edit}
a={ProjectPermissionSub.Certificates}
>
{(isAllowed) => (
<DropdownMenuItem
className={twMerge(
!isAllowed &&
"pointer-events-none cursor-not-allowed opacity-50"
)}
onClick={async () => {
handlePopUpOpen("renewCertificate", {
certificateId: certificate.id,
commonName: certificate.commonName
});
}}
disabled={!isAllowed}
icon={<FontAwesomeIcon icon={faRedo} />}
>
Renew Now
</DropdownMenuItem>
)}
</ProjectPermissionCan>
);
})()}
{/* Only show revoke button if CA supports revocation */} {/* Only show revoke button if CA supports revocation */}
{(() => { {(() => {
const caType = caCapabilityMap[certificate.caId]; const caType = caCapabilityMap[certificate.caId];
// If caId not found in map, assume CA supports revocation to avoid hiding revoke option
const supportsRevocation = const supportsRevocation =
!caType || !caType ||
caSupportsCapability(caType, CaCapability.REVOKE_CERTIFICATES); caSupportsCapability(caType, CaCapability.REVOKE_CERTIFICATES);
@@ -11,6 +11,26 @@ import {
mapTemplateSignatureAlgorithmToApi mapTemplateSignatureAlgorithmToApi
} from "@app/pages/cert-manager/PoliciesPage/components/CertificateTemplatesV2Tab/shared/certificate-constants"; } 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 = { export type TemplateConstraints = {
allowedKeyUsages: string[]; allowedKeyUsages: string[];
allowedExtendedKeyUsages: string[]; allowedExtendedKeyUsages: string[];
@@ -118,7 +138,7 @@ export const useCertificateTemplate = (
// Set TTL if available // Set TTL if available
if (templateData.validity?.max) { if (templateData.validity?.max) {
setValue("ttl", templateData.validity.max); setValue("ttl", convertTemplateTtlToCertificateTtl(templateData.validity.max));
} }
// Handle SAN types // Handle SAN types
@@ -1,5 +1,7 @@
import { useEffect } from "react"; import { useEffect } from "react";
import { Controller, useForm } from "react-hook-form"; 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 { zodResolver } from "@hookform/resolvers/zod";
import { z } from "zod"; import { z } from "zod";
@@ -13,7 +15,8 @@ import {
ModalContent, ModalContent,
Select, Select,
SelectItem, SelectItem,
TextArea TextArea,
Tooltip
} from "@app/components/v2"; } from "@app/components/v2";
import { useProject } from "@app/context"; import { useProject } from "@app/context";
import { useListCasByProjectId } from "@app/hooks/api/ca/queries"; import { useListCasByProjectId } from "@app/hooks/api/ca/queries";
@@ -67,7 +70,7 @@ const createSchema = z
apiConfig: z apiConfig: z
.object({ .object({
autoRenew: z.boolean().optional(), autoRenew: z.boolean().optional(),
autoRenewDays: z.number().min(1).max(365).optional() renewBeforeDays: z.number().min(1).max(365).optional()
}) })
.optional() .optional()
}) })
@@ -115,7 +118,7 @@ const editSchema = z
apiConfig: z apiConfig: z
.object({ .object({
autoRenew: z.boolean().optional(), autoRenew: z.boolean().optional(),
autoRenewDays: z.number().min(1).max(365).optional() renewBeforeDays: z.number().min(1).max(365).optional()
}) })
.optional() .optional()
}) })
@@ -183,7 +186,7 @@ export const CreateProfileModal = ({ isOpen, onClose, profile, mode = "create" }
profile.enrollmentType === "api" profile.enrollmentType === "api"
? { ? {
autoRenew: profile.apiConfig?.autoRenew || false, autoRenew: profile.apiConfig?.autoRenew || false,
autoRenewDays: profile.apiConfig?.autoRenewDays || 30 renewBeforeDays: profile.apiConfig?.renewBeforeDays || 30
} }
: undefined : undefined
} }
@@ -195,7 +198,7 @@ export const CreateProfileModal = ({ isOpen, onClose, profile, mode = "create" }
certificateTemplateId: "", certificateTemplateId: "",
apiConfig: { apiConfig: {
autoRenew: false, autoRenew: false,
autoRenewDays: 30 renewBeforeDays: 30
} }
} }
}); });
@@ -225,7 +228,7 @@ export const CreateProfileModal = ({ isOpen, onClose, profile, mode = "create" }
profile.enrollmentType === "api" profile.enrollmentType === "api"
? { ? {
autoRenew: profile.apiConfig?.autoRenew || false, autoRenew: profile.apiConfig?.autoRenew || false,
autoRenewDays: profile.apiConfig?.autoRenewDays || 30 renewBeforeDays: profile.apiConfig?.renewBeforeDays || 30
} }
: undefined : undefined
}); });
@@ -389,7 +392,7 @@ export const CreateProfileModal = ({ isOpen, onClose, profile, mode = "create" }
} else { } else {
setValue("apiConfig", { setValue("apiConfig", {
autoRenew: false, autoRenew: false,
autoRenewDays: 30 renewBeforeDays: 30
}); });
setValue("estConfig", undefined); setValue("estConfig", undefined);
} }
@@ -433,7 +436,7 @@ export const CreateProfileModal = ({ isOpen, onClose, profile, mode = "create" }
setValue("estConfig", undefined); setValue("estConfig", undefined);
setValue("apiConfig", { setValue("apiConfig", {
autoRenew: false, autoRenew: false,
autoRenewDays: 30 renewBeforeDays: 30
}); });
} }
onChange(value); onChange(value);
@@ -535,9 +538,18 @@ export const CreateProfileModal = ({ isOpen, onClose, profile, mode = "create" }
name="apiConfig.autoRenew" name="apiConfig.autoRenew"
render={({ field: { value, onChange }, fieldState: { error } }) => ( render={({ field: { value, onChange }, fieldState: { error } }) => (
<FormControl isError={Boolean(error)} errorText={error?.message}> <FormControl isError={Boolean(error)} errorText={error?.message}>
<Checkbox id="autoRenew" isChecked={value} onCheckedChange={onChange}> <div className="flex items-center gap-2">
Enable Auto-Renewal <Checkbox id="autoRenew" isChecked={value} onCheckedChange={onChange}>
</Checkbox> Enable Auto-Renewal By Default
</Checkbox>
<Tooltip content="If enabled, certificates issued against this profile will auto-renew at specified days before expiration.">
<FontAwesomeIcon
icon={faQuestionCircle}
className="cursor-help text-mineshaft-400 hover:text-mineshaft-300"
size="sm"
/>
</Tooltip>
</div>
</FormControl> </FormControl>
)} )}
/> />
@@ -548,10 +560,10 @@ export const CreateProfileModal = ({ isOpen, onClose, profile, mode = "create" }
<div className="mb-4 space-y-4"> <div className="mb-4 space-y-4">
<Controller <Controller
control={control} control={control}
name="apiConfig.autoRenewDays" name="apiConfig.renewBeforeDays"
render={({ field, fieldState: { error } }) => ( render={({ field, fieldState: { error } }) => (
<FormControl <FormControl
label="Auto-Renewal Days" label="Auto-Renewal Days Before Expiration"
isError={Boolean(error)} isError={Boolean(error)}
errorText={error?.message} errorText={error?.message}
> >