mirror of
https://github.com/awatertrevi/infisical.git
synced 2026-10-10 16:28:40 +00:00
PKI: add support for auto-renewal option on API enrollment type
This commit is contained in:
@@ -0,0 +1,53 @@
|
|||||||
|
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.dropColumn("autoRenewDays");
|
||||||
|
t.integer("renewBeforeDays");
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!(await knex.schema.hasColumn(TableName.Certificate, "renewBeforeDays"))) {
|
||||||
|
await knex.schema.alterTable(TableName.Certificate, (t) => {
|
||||||
|
t.integer("renewBeforeDays").nullable();
|
||||||
|
t.uuid("renewedFromId").nullable();
|
||||||
|
t.uuid("renewedById").nullable();
|
||||||
|
t.text("renewalError").nullable();
|
||||||
|
t.string("keyAlgorithm").nullable();
|
||||||
|
t.string("signatureAlgorithm").nullable();
|
||||||
|
t.foreign("renewedFromId").references("id").inTable(TableName.Certificate).onDelete("SET NULL");
|
||||||
|
t.foreign("renewedById").references("id").inTable(TableName.Certificate).onDelete("SET NULL");
|
||||||
|
t.index("renewedFromId");
|
||||||
|
t.index("renewedById");
|
||||||
|
t.index("renewBeforeDays");
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function down(knex: Knex): Promise<void> {
|
||||||
|
if (await knex.schema.hasColumn(TableName.Certificate, "renewBeforeDays")) {
|
||||||
|
await knex.schema.alterTable(TableName.Certificate, (t) => {
|
||||||
|
t.dropForeign(["renewedFromId"]);
|
||||||
|
t.dropForeign(["renewedById"]);
|
||||||
|
t.dropIndex("renewedFromId");
|
||||||
|
t.dropIndex("renewedById");
|
||||||
|
t.dropIndex("renewBeforeDays");
|
||||||
|
t.dropColumn("renewBeforeDays");
|
||||||
|
t.dropColumn("renewedFromId");
|
||||||
|
t.dropColumn("renewedById");
|
||||||
|
t.dropColumn("renewalError");
|
||||||
|
t.dropColumn("keyAlgorithm");
|
||||||
|
t.dropColumn("signatureAlgorithm");
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
if (await knex.schema.hasColumn(TableName.PkiApiEnrollmentConfig, "renewBeforeDays")) {
|
||||||
|
await knex.schema.alterTable(TableName.PkiApiEnrollmentConfig, (t) => {
|
||||||
|
t.dropColumn("renewBeforeDays");
|
||||||
|
t.integer("autoRenewDays");
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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(),
|
||||||
|
renewedFromId: z.string().uuid().nullable().optional(),
|
||||||
|
renewedById: z.string().uuid().nullable().optional(),
|
||||||
|
renewalError: z.string().nullable().optional(),
|
||||||
|
keyAlgorithm: z.string().nullable().optional(),
|
||||||
|
signatureAlgorithm: z.string().nullable().optional()
|
||||||
});
|
});
|
||||||
|
|
||||||
export type TCertificates = z.infer<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()
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -337,6 +337,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",
|
||||||
@@ -364,6 +366,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",
|
||||||
@@ -2437,6 +2442,27 @@ interface AutomatedRenewPkiSubscriberCert {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
interface AutomatedRenewCertificate {
|
||||||
|
type: EventType.AUTOMATED_RENEW_CERTIFICATE;
|
||||||
|
metadata: {
|
||||||
|
certificateId: string;
|
||||||
|
commonName: string;
|
||||||
|
profileId: string;
|
||||||
|
renewBeforeDays: string;
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
interface AutomatedRenewCertificateFailed {
|
||||||
|
type: EventType.AUTOMATED_RENEW_CERTIFICATE_FAILED;
|
||||||
|
metadata: {
|
||||||
|
certificateId: string;
|
||||||
|
commonName: string;
|
||||||
|
profileId: string;
|
||||||
|
renewBeforeDays: string;
|
||||||
|
error: string;
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
interface SignPkiSubscriberCert {
|
interface SignPkiSubscriberCert {
|
||||||
type: EventType.SIGN_PKI_SUBSCRIBER_CERT;
|
type: EventType.SIGN_PKI_SUBSCRIBER_CERT;
|
||||||
metadata: {
|
metadata: {
|
||||||
@@ -2699,6 +2725,15 @@ interface OrderCertificateFromProfile {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
interface RenewCertificate {
|
||||||
|
type: EventType.RENEW_CERTIFICATE;
|
||||||
|
metadata: {
|
||||||
|
originalCertificateId: string;
|
||||||
|
newCertificateId: string;
|
||||||
|
profileName: string;
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
interface AttemptCreateSlackIntegration {
|
interface AttemptCreateSlackIntegration {
|
||||||
type: EventType.ATTEMPT_CREATE_SLACK_INTEGRATION;
|
type: EventType.ATTEMPT_CREATE_SLACK_INTEGRATION;
|
||||||
metadata: {
|
metadata: {
|
||||||
@@ -3963,6 +3998,21 @@ interface PamResourceDeleteEvent {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
interface UpdateCertificateRenewalConfigEvent {
|
||||||
|
type: EventType.UPDATE_CERTIFICATE_RENEWAL_CONFIG;
|
||||||
|
metadata: {
|
||||||
|
certificateId: string;
|
||||||
|
renewBeforeDays: string;
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
interface DisableCertificateRenewalConfigEvent {
|
||||||
|
type: EventType.DISABLE_CERTIFICATE_RENEWAL_CONFIG;
|
||||||
|
metadata: {
|
||||||
|
certificateId: string;
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
export type Event =
|
export type Event =
|
||||||
| GetSecretsEvent
|
| GetSecretsEvent
|
||||||
| GetSecretEvent
|
| GetSecretEvent
|
||||||
@@ -4168,6 +4218,7 @@ export type Event =
|
|||||||
| IssueCertificateFromProfile
|
| IssueCertificateFromProfile
|
||||||
| SignCertificateFromProfile
|
| SignCertificateFromProfile
|
||||||
| OrderCertificateFromProfile
|
| OrderCertificateFromProfile
|
||||||
|
| RenewCertificate
|
||||||
| GetAzureAdCsTemplatesEvent
|
| GetAzureAdCsTemplatesEvent
|
||||||
| AttemptCreateSlackIntegration
|
| AttemptCreateSlackIntegration
|
||||||
| AttemptReinstallSlackIntegration
|
| AttemptReinstallSlackIntegration
|
||||||
@@ -4323,4 +4374,8 @@ export type Event =
|
|||||||
| PamResourceGetEvent
|
| PamResourceGetEvent
|
||||||
| PamResourceCreateEvent
|
| PamResourceCreateEvent
|
||||||
| PamResourceUpdateEvent
|
| PamResourceUpdateEvent
|
||||||
| PamResourceDeleteEvent;
|
| PamResourceDeleteEvent
|
||||||
|
| UpdateCertificateRenewalConfigEvent
|
||||||
|
| DisableCertificateRenewalConfigEvent
|
||||||
|
| AutomatedRenewCertificate
|
||||||
|
| AutomatedRenewCertificateFailed;
|
||||||
|
|||||||
@@ -77,7 +77,8 @@ export enum QueueName {
|
|||||||
DailyReminders = "daily-reminders",
|
DailyReminders = "daily-reminders",
|
||||||
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"
|
||||||
}
|
}
|
||||||
|
|
||||||
export enum QueueJobs {
|
export enum QueueJobs {
|
||||||
@@ -126,7 +127,8 @@ export enum QueueJobs {
|
|||||||
DailyReminders = "daily-reminders",
|
DailyReminders = "daily-reminders",
|
||||||
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"
|
||||||
}
|
}
|
||||||
|
|
||||||
export type TQueueJobTypes = {
|
export type TQueueJobTypes = {
|
||||||
@@ -357,6 +359,10 @@ export type TQueueJobTypes = {
|
|||||||
name: QueueJobs.HealthAlert;
|
name: QueueJobs.HealthAlert;
|
||||||
payload: undefined;
|
payload: undefined;
|
||||||
};
|
};
|
||||||
|
[QueueName.CertificateV3AutoRenewal]: {
|
||||||
|
name: QueueJobs.CertificateV3DailyAutoRenewal;
|
||||||
|
payload: undefined;
|
||||||
|
};
|
||||||
};
|
};
|
||||||
|
|
||||||
const SECRET_SCANNING_JOBS = [
|
const SECRET_SCANNING_JOBS = [
|
||||||
|
|||||||
@@ -175,6 +175,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";
|
||||||
@@ -2122,6 +2123,16 @@ export const registerRoutes = async (
|
|||||||
permissionService
|
permissionService
|
||||||
});
|
});
|
||||||
|
|
||||||
|
const certificateV3Queue = certificateV3QueueServiceFactory({
|
||||||
|
queueService,
|
||||||
|
certificateDAL,
|
||||||
|
certificateAuthorityDAL,
|
||||||
|
certificateProfileDAL,
|
||||||
|
projectDAL,
|
||||||
|
certificateV3Service,
|
||||||
|
auditLogService
|
||||||
|
});
|
||||||
|
|
||||||
const certificateEstV3Service = certificateEstV3ServiceFactory({
|
const certificateEstV3Service = certificateEstV3ServiceFactory({
|
||||||
internalCertificateAuthorityService,
|
internalCertificateAuthorityService,
|
||||||
certificateTemplateV2Service,
|
certificateTemplateV2Service,
|
||||||
@@ -2281,6 +2292,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.startDailyAutoRenewalJob();
|
||||||
await kmsService.startService();
|
await kmsService.startService();
|
||||||
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()
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -343,4 +343,138 @@ 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
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
return data;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
server.route({
|
||||||
|
method: "PATCH",
|
||||||
|
url: "/:certificateId/config",
|
||||||
|
config: {
|
||||||
|
rateLimit: writeLimit
|
||||||
|
},
|
||||||
|
schema: {
|
||||||
|
hide: false,
|
||||||
|
tags: [ApiDocsTags.PkiCertificates],
|
||||||
|
params: z.object({
|
||||||
|
certificateId: z.string().uuid()
|
||||||
|
}),
|
||||||
|
body: z.object({
|
||||||
|
renewBeforeDays: z.number().int().min(1).max(30).optional(),
|
||||||
|
disableAutoRenewal: z.boolean().optional()
|
||||||
|
}),
|
||||||
|
response: {
|
||||||
|
200: z.object({
|
||||||
|
message: z.string(),
|
||||||
|
renewBeforeDays: z.number().optional()
|
||||||
|
})
|
||||||
|
}
|
||||||
|
},
|
||||||
|
onRequest: verifyAuth([AuthMode.JWT, AuthMode.IDENTITY_ACCESS_TOKEN]),
|
||||||
|
handler: async (req) => {
|
||||||
|
if (req.body.disableAutoRenewal === true) {
|
||||||
|
const data = await server.services.certificateV3.disableRenewalConfig({
|
||||||
|
actor: req.permission.type,
|
||||||
|
actorId: req.permission.id,
|
||||||
|
actorAuthMethod: req.permission.authMethod,
|
||||||
|
actorOrgId: req.permission.orgId,
|
||||||
|
certificateId: req.params.certificateId
|
||||||
|
});
|
||||||
|
|
||||||
|
await server.services.auditLog.createAuditLog({
|
||||||
|
...req.auditLogInfo,
|
||||||
|
projectId: data.projectId,
|
||||||
|
event: {
|
||||||
|
type: EventType.DISABLE_CERTIFICATE_RENEWAL_CONFIG,
|
||||||
|
metadata: {
|
||||||
|
certificateId: req.params.certificateId
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
return {
|
||||||
|
message: "Auto-renewal disabled successfully"
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
if (req.body.renewBeforeDays !== undefined) {
|
||||||
|
const data = await server.services.certificateV3.updateRenewalConfig({
|
||||||
|
actor: req.permission.type,
|
||||||
|
actorId: req.permission.id,
|
||||||
|
actorAuthMethod: req.permission.authMethod,
|
||||||
|
actorOrgId: req.permission.orgId,
|
||||||
|
certificateId: req.params.certificateId,
|
||||||
|
renewBeforeDays: req.body.renewBeforeDays
|
||||||
|
});
|
||||||
|
|
||||||
|
await server.services.auditLog.createAuditLog({
|
||||||
|
...req.auditLogInfo,
|
||||||
|
projectId: data.projectId,
|
||||||
|
event: {
|
||||||
|
type: EventType.UPDATE_CERTIFICATE_RENEWAL_CONFIG,
|
||||||
|
metadata: {
|
||||||
|
certificateId: req.params.certificateId,
|
||||||
|
renewBeforeDays: req.body.renewBeforeDays.toString()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
return {
|
||||||
|
message: "Certificate configuration updated successfully",
|
||||||
|
renewBeforeDays: data.renewBeforeDays
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
message: "No configuration changes requested"
|
||||||
|
};
|
||||||
|
}
|
||||||
|
});
|
||||||
};
|
};
|
||||||
|
|||||||
+22
-15
@@ -1180,7 +1180,8 @@ export const internalCertificateAuthorityServiceFactory = ({
|
|||||||
extendedKeyUsages,
|
extendedKeyUsages,
|
||||||
signatureAlgorithm,
|
signatureAlgorithm,
|
||||||
keyAlgorithm,
|
keyAlgorithm,
|
||||||
isFromProfile
|
isFromProfile,
|
||||||
|
internal = false
|
||||||
}: TIssueCertFromCaDTO) => {
|
}: TIssueCertFromCaDTO) => {
|
||||||
let ca: TCertificateAuthorityWithAssociatedCa | undefined;
|
let ca: TCertificateAuthorityWithAssociatedCa | undefined;
|
||||||
let certificateTemplate: TCertificateTemplates | undefined;
|
let certificateTemplate: TCertificateTemplates | undefined;
|
||||||
@@ -1210,19 +1211,21 @@ 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(
|
ForbiddenError.from(permission).throwUnlessCan(
|
||||||
ProjectPermissionCertificateActions.Create,
|
ProjectPermissionCertificateActions.Create,
|
||||||
ProjectPermissionSub.Certificates
|
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)
|
||||||
@@ -1488,7 +1491,9 @@ 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
|
tx
|
||||||
);
|
);
|
||||||
@@ -1917,7 +1922,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
@@ -139,6 +139,7 @@ export type TIssueCertFromCaDTO = {
|
|||||||
signatureAlgorithm?: CertSignatureAlgorithm;
|
signatureAlgorithm?: CertSignatureAlgorithm;
|
||||||
keyAlgorithm?: CertKeyAlgorithm;
|
keyAlgorithm?: CertKeyAlgorithm;
|
||||||
isFromProfile?: boolean;
|
isFromProfile?: boolean;
|
||||||
|
internal?: boolean;
|
||||||
} & Omit<TProjectPermission, "projectId">;
|
} & Omit<TProjectPermission, "projectId">;
|
||||||
|
|
||||||
export type TSignCertFromCaDTO =
|
export type TSignCertFromCaDTO =
|
||||||
|
|||||||
@@ -175,6 +175,14 @@ export enum CertSignatureAlgorithm {
|
|||||||
ECDSA_SHA512 = "ECDSA-SHA512"
|
ECDSA_SHA512 = "ECDSA-SHA512"
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export const CERTIFICATE_RENEWAL_CONFIG = {
|
||||||
|
MIN_RENEW_BEFORE_DAYS: 1,
|
||||||
|
MAX_RENEW_BEFORE_DAYS: 30,
|
||||||
|
QUEUE_BATCH_SIZE: 100,
|
||||||
|
DAILY_CRON_SCHEDULE: "0 0 * * *",
|
||||||
|
QUEUE_START_DELAY_MS: 5000
|
||||||
|
} as const;
|
||||||
|
|
||||||
export const SAN_TYPE_OPTIONS = Object.values(CertSubjectAlternativeNameType);
|
export const 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);
|
||||||
|
|||||||
@@ -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,254 @@
|
|||||||
|
/* eslint-disable no-await-in-loop */
|
||||||
|
import { EventType, TAuditLogServiceFactory } from "@app/ee/services/audit-log/audit-log-types";
|
||||||
|
import { logger } from "@app/lib/logger";
|
||||||
|
import { QueueJobs, QueueName, TQueueServiceFactory } from "@app/queue";
|
||||||
|
|
||||||
|
import { ActorType } from "../auth/auth-type";
|
||||||
|
import { TCertificateDALFactory } from "../certificate/certificate-dal";
|
||||||
|
import { CertStatus } from "../certificate/certificate-types";
|
||||||
|
import { TCertificateAuthorityDALFactory } from "../certificate-authority/certificate-authority-dal";
|
||||||
|
import { CERTIFICATE_RENEWAL_CONFIG } from "../certificate-common/certificate-constants";
|
||||||
|
import { TCertificateProfileDALFactory } from "../certificate-profile/certificate-profile-dal";
|
||||||
|
import { TProjectDALFactory } from "../project/project-dal";
|
||||||
|
import { TCertificateV3ServiceFactory } from "./certificate-v3-service";
|
||||||
|
|
||||||
|
type TCertificateV3QueueServiceFactoryDep = {
|
||||||
|
queueService: TQueueServiceFactory;
|
||||||
|
certificateDAL: TCertificateDALFactory;
|
||||||
|
certificateAuthorityDAL: Pick<TCertificateAuthorityDALFactory, "findByIdWithAssociatedCa">;
|
||||||
|
certificateProfileDAL: Pick<TCertificateProfileDALFactory, "findByIdWithConfigs">;
|
||||||
|
projectDAL: Pick<TProjectDALFactory, "findById">;
|
||||||
|
certificateV3Service: TCertificateV3ServiceFactory;
|
||||||
|
auditLogService: Pick<TAuditLogServiceFactory, "createAuditLog">;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const certificateV3QueueServiceFactory = ({
|
||||||
|
queueService,
|
||||||
|
certificateDAL,
|
||||||
|
certificateAuthorityDAL,
|
||||||
|
certificateProfileDAL,
|
||||||
|
projectDAL,
|
||||||
|
certificateV3Service,
|
||||||
|
auditLogService
|
||||||
|
}: TCertificateV3QueueServiceFactoryDep) => {
|
||||||
|
queueService.start(QueueName.CertificateV3AutoRenewal, async (job) => {
|
||||||
|
if (job.name === QueueJobs.CertificateV3DailyAutoRenewal) {
|
||||||
|
logger.info(`${QueueJobs.CertificateV3DailyAutoRenewal}: queue task started`);
|
||||||
|
|
||||||
|
const { QUEUE_BATCH_SIZE } = CERTIFICATE_RENEWAL_CONFIG;
|
||||||
|
let offset = 0;
|
||||||
|
let hasMore = true;
|
||||||
|
|
||||||
|
while (hasMore) {
|
||||||
|
const certificates = await certificateDAL.find(
|
||||||
|
{
|
||||||
|
$notNull: ["profileId"],
|
||||||
|
status: CertStatus.ACTIVE,
|
||||||
|
renewedById: null,
|
||||||
|
renewalError: null,
|
||||||
|
revokedAt: null
|
||||||
|
},
|
||||||
|
{
|
||||||
|
limit: QUEUE_BATCH_SIZE,
|
||||||
|
offset
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
if (certificates.length === 0) {
|
||||||
|
hasMore = false;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
|
||||||
|
await Promise.all(
|
||||||
|
certificates.map(async (certificate) => {
|
||||||
|
try {
|
||||||
|
if (!certificate.profileId || !certificate.notAfter) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const profile = await certificateProfileDAL.findByIdWithConfigs(certificate.profileId);
|
||||||
|
if (!profile) {
|
||||||
|
logger.warn(`Profile not found for certificate ${certificate.id}`);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const ca = await certificateAuthorityDAL.findByIdWithAssociatedCa(profile.caId);
|
||||||
|
if (!ca) {
|
||||||
|
logger.warn(`CA not found for certificate ${certificate.id}`);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const profileAutoRenewEnabled = profile.apiConfig?.autoRenew === true;
|
||||||
|
const certificateHasRenewalConfig =
|
||||||
|
certificate.renewBeforeDays != null && certificate.renewBeforeDays > 0;
|
||||||
|
|
||||||
|
if (!profileAutoRenewEnabled && !certificateHasRenewalConfig) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const now = new Date();
|
||||||
|
if (certificate.notAfter <= now) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const renewBeforeDays = certificate.renewBeforeDays || profile.apiConfig?.renewBeforeDays;
|
||||||
|
if (!renewBeforeDays) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const { MIN_RENEW_BEFORE_DAYS, MAX_RENEW_BEFORE_DAYS } = CERTIFICATE_RENEWAL_CONFIG;
|
||||||
|
if (renewBeforeDays < MIN_RENEW_BEFORE_DAYS || renewBeforeDays > MAX_RENEW_BEFORE_DAYS) {
|
||||||
|
logger.warn(`Invalid renewal threshold ${renewBeforeDays} for certificate ${certificate.id}`);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const expiryDate = new Date(certificate.notAfter);
|
||||||
|
const renewalDate = new Date(expiryDate.getTime() - renewBeforeDays * 24 * 60 * 60 * 1000);
|
||||||
|
|
||||||
|
const shouldRenew = renewalDate <= now;
|
||||||
|
|
||||||
|
if (shouldRenew) {
|
||||||
|
logger.info(`Auto-renewing certificate ${certificate.id} (common name: ${certificate.commonName})`);
|
||||||
|
|
||||||
|
const project = await projectDAL.findById(certificate.projectId);
|
||||||
|
if (!project) {
|
||||||
|
logger.error(`Project not found for certificate ${certificate.id}`);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
await certificateV3Service.renewCertificate({
|
||||||
|
actor: ActorType.PLATFORM,
|
||||||
|
actorId: "",
|
||||||
|
actorAuthMethod: null,
|
||||||
|
actorOrgId: project.orgId,
|
||||||
|
certificateId: certificate.id,
|
||||||
|
internal: true
|
||||||
|
});
|
||||||
|
|
||||||
|
await certificateDAL.updateById(certificate.id, {
|
||||||
|
renewalError: null
|
||||||
|
});
|
||||||
|
|
||||||
|
await auditLogService.createAuditLog({
|
||||||
|
projectId: certificate.projectId,
|
||||||
|
actor: {
|
||||||
|
type: ActorType.PLATFORM,
|
||||||
|
metadata: {}
|
||||||
|
},
|
||||||
|
event: {
|
||||||
|
type: EventType.AUTOMATED_RENEW_CERTIFICATE,
|
||||||
|
metadata: {
|
||||||
|
certificateId: certificate.id,
|
||||||
|
commonName: certificate.commonName || "",
|
||||||
|
profileId: certificate.profileId,
|
||||||
|
renewBeforeDays: certificate.renewBeforeDays?.toString() || ""
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
logger.info(`Successfully auto-renewed certificate ${certificate.id}`);
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
logger.error(
|
||||||
|
error,
|
||||||
|
`Failed to auto-renew certificate ${certificate.id} (common name: ${certificate.commonName})`
|
||||||
|
);
|
||||||
|
|
||||||
|
const errorMessage = error instanceof Error ? error.message : "Unknown error";
|
||||||
|
let categorizedError = errorMessage;
|
||||||
|
|
||||||
|
if (errorMessage.includes("Template validation failed")) {
|
||||||
|
categorizedError =
|
||||||
|
"Auto-renewal failed: certificate template policy has changed and this certificate no longer meets the requirements";
|
||||||
|
} else if (errorMessage.includes("Certificate Authority") && errorMessage.includes("not found")) {
|
||||||
|
categorizedError =
|
||||||
|
"Auto-renewal failed: Certificate Authority for this certificate is no longer available";
|
||||||
|
} else if (errorMessage.includes("Certificate Authority is") && errorMessage.includes("must be ACTIVE")) {
|
||||||
|
categorizedError = "Auto-renewal failed: Certificate Authority is currently inactive";
|
||||||
|
} else if (errorMessage.includes("would expire") && errorMessage.includes("after its issuing CA")) {
|
||||||
|
categorizedError = "Auto-renewal failed: certificate would outlive the Certificate Authority";
|
||||||
|
} else if (
|
||||||
|
errorMessage.includes("TTL") &&
|
||||||
|
errorMessage.includes("must be greater than renewal threshold")
|
||||||
|
) {
|
||||||
|
categorizedError =
|
||||||
|
"Auto-renewal failed: certificate validity period is too short for the renewal threshold";
|
||||||
|
} else if (errorMessage.includes("not eligible for renewal")) {
|
||||||
|
categorizedError = "Auto-renewal failed: certificate is not eligible for automatic renewal";
|
||||||
|
} else if (errorMessage.includes("Requested validity period exceeds maximum allowed duration")) {
|
||||||
|
categorizedError =
|
||||||
|
"Auto-renewal failed: certificate validity period exceeds the maximum allowed by the profile template";
|
||||||
|
} else if (errorMessage.includes("not allowed by template policy")) {
|
||||||
|
categorizedError =
|
||||||
|
"Auto-renewal failed: certificate settings are no longer allowed by the profile template";
|
||||||
|
} else {
|
||||||
|
categorizedError = `Auto-renewal failed: ${errorMessage}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
await certificateDAL.updateById(certificate.id, {
|
||||||
|
renewalError: categorizedError
|
||||||
|
});
|
||||||
|
} catch (updateError) {
|
||||||
|
logger.error(updateError, `Failed to update renewal error for certificate ${certificate.id}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
await auditLogService.createAuditLog({
|
||||||
|
projectId: certificate.projectId,
|
||||||
|
actor: {
|
||||||
|
type: ActorType.PLATFORM,
|
||||||
|
metadata: {}
|
||||||
|
},
|
||||||
|
event: {
|
||||||
|
type: EventType.AUTOMATED_RENEW_CERTIFICATE_FAILED,
|
||||||
|
metadata: {
|
||||||
|
certificateId: certificate.id,
|
||||||
|
commonName: certificate.commonName || "",
|
||||||
|
profileId: certificate.profileId || "",
|
||||||
|
renewBeforeDays: certificate.renewBeforeDays?.toString() || "",
|
||||||
|
error: categorizedError
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
} catch (auditError) {
|
||||||
|
logger.error(auditError, `Failed to create audit log for failed certificate renewal ${certificate.id}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})
|
||||||
|
);
|
||||||
|
|
||||||
|
offset += QUEUE_BATCH_SIZE;
|
||||||
|
}
|
||||||
|
|
||||||
|
logger.info(`${QueueJobs.CertificateV3DailyAutoRenewal}: queue task completed`);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
const startDailyAutoRenewalJob = async () => {
|
||||||
|
const { DAILY_CRON_SCHEDULE, QUEUE_START_DELAY_MS } = CERTIFICATE_RENEWAL_CONFIG;
|
||||||
|
|
||||||
|
await queueService.stopRepeatableJob(
|
||||||
|
QueueName.CertificateV3AutoRenewal,
|
||||||
|
QueueJobs.CertificateV3DailyAutoRenewal,
|
||||||
|
{ pattern: DAILY_CRON_SCHEDULE, utc: true },
|
||||||
|
QueueName.CertificateV3AutoRenewal
|
||||||
|
);
|
||||||
|
|
||||||
|
await queueService.queue(QueueName.CertificateV3AutoRenewal, QueueJobs.CertificateV3DailyAutoRenewal, undefined, {
|
||||||
|
delay: QUEUE_START_DELAY_MS,
|
||||||
|
jobId: QueueName.CertificateV3AutoRenewal,
|
||||||
|
repeat: { pattern: DAILY_CRON_SCHEDULE, utc: true }
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
queueService.listen(QueueName.CertificateV3AutoRenewal, "failed", (_, err) => {
|
||||||
|
logger.error(err, `${QueueName.CertificateV3AutoRenewal}: failed`);
|
||||||
|
});
|
||||||
|
|
||||||
|
return {
|
||||||
|
startDailyAutoRenewalJob
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
export type TCertificateV3QueueFactory = ReturnType<typeof certificateV3QueueServiceFactory>;
|
||||||
@@ -7,10 +7,11 @@ 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 { 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,
|
||||||
@@ -28,8 +29,9 @@ import { certificateV3ServiceFactory, TCertificateV3ServiceFactory } from "./cer
|
|||||||
describe("CertificateV3Service", () => {
|
describe("CertificateV3Service", () => {
|
||||||
let service: TCertificateV3ServiceFactory;
|
let service: TCertificateV3ServiceFactory;
|
||||||
|
|
||||||
const mockCertificateDAL: Pick<TCertificateDALFactory, "findOne" | "updateById"> = {
|
const mockCertificateDAL: Pick<TCertificateDALFactory, "findOne" | "findById" | "updateById"> = {
|
||||||
findOne: vi.fn(),
|
findOne: vi.fn(),
|
||||||
|
findById: vi.fn(),
|
||||||
updateById: vi.fn()
|
updateById: vi.fn()
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -1460,4 +1462,596 @@ 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,
|
||||||
|
renewedById: null,
|
||||||
|
profileId: "profile-123",
|
||||||
|
renewBeforeDays: 7,
|
||||||
|
caId: "ca-123",
|
||||||
|
pkiSubscriberId: null,
|
||||||
|
keyUsages: ["digital_signature", "key_agreement"],
|
||||||
|
extendedKeyUsages: ["server_auth"],
|
||||||
|
altNames: "test.example.com,api.example.com",
|
||||||
|
projectId: "project-123",
|
||||||
|
createdAt: new Date(),
|
||||||
|
updatedAt: new Date(),
|
||||||
|
certificateTemplateId: "template-123",
|
||||||
|
revocationReason: null,
|
||||||
|
caCertId: null,
|
||||||
|
renewedFromId: null,
|
||||||
|
renewalError: null,
|
||||||
|
keyAlgorithm: "RSA_2048",
|
||||||
|
signatureAlgorithm: "RSA-SHA256"
|
||||||
|
};
|
||||||
|
|
||||||
|
const mockProfile = {
|
||||||
|
id: "profile-123",
|
||||||
|
projectId: "project-123",
|
||||||
|
enrollmentType: EnrollmentType.API,
|
||||||
|
caId: "ca-123",
|
||||||
|
certificateTemplateId: "template-123",
|
||||||
|
apiConfig: {
|
||||||
|
id: "api-config-123",
|
||||||
|
autoRenew: true,
|
||||||
|
renewBeforeDays: 14
|
||||||
|
},
|
||||||
|
createdAt: new Date(),
|
||||||
|
updatedAt: new Date(),
|
||||||
|
slug: "test-profile",
|
||||||
|
description: "Test profile"
|
||||||
|
};
|
||||||
|
|
||||||
|
const mockCA = {
|
||||||
|
id: "ca-123",
|
||||||
|
projectId: "project-123",
|
||||||
|
status: CaStatus.ACTIVE,
|
||||||
|
createdAt: new Date(),
|
||||||
|
updatedAt: new Date(),
|
||||||
|
enableDirectIssuance: true,
|
||||||
|
name: "Test CA",
|
||||||
|
requireTemplateForIssuance: false,
|
||||||
|
externalCa: undefined,
|
||||||
|
parentCaId: null,
|
||||||
|
type: "ROOT",
|
||||||
|
friendlyName: "Test CA",
|
||||||
|
organization: "Test Org",
|
||||||
|
ou: "Test OU",
|
||||||
|
country: "US",
|
||||||
|
province: "CA",
|
||||||
|
locality: "SF",
|
||||||
|
commonName: "Test CA",
|
||||||
|
keyAlgorithm: "RSA_2048",
|
||||||
|
notAfter: "2025-01-01T00:00:00Z",
|
||||||
|
notBefore: "2024-01-01T00:00:00Z",
|
||||||
|
maxPathLength: -1,
|
||||||
|
activeCaCertId: "cert-123",
|
||||||
|
dn: "CN=Test CA,O=Test Org,OU=Test OU,C=US",
|
||||||
|
serialNumber: "123456789",
|
||||||
|
internalCa: {
|
||||||
|
id: "internal-ca-123",
|
||||||
|
parentCaId: null,
|
||||||
|
type: "ROOT",
|
||||||
|
friendlyName: "Test CA",
|
||||||
|
organization: "Test Org",
|
||||||
|
ou: "Test OU",
|
||||||
|
country: "US",
|
||||||
|
province: "CA",
|
||||||
|
locality: "SF",
|
||||||
|
commonName: "Test CA",
|
||||||
|
keyAlgorithm: "RSA_2048",
|
||||||
|
notAfter: "2025-01-01T00:00:00Z",
|
||||||
|
notBefore: "2024-01-01T00:00:00Z",
|
||||||
|
maxPathLength: -1,
|
||||||
|
activeCaCertId: "cert-123",
|
||||||
|
dn: "CN=Test CA,O=Test Org,OU=Test OU,C=US",
|
||||||
|
serialNumber: "123456789"
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const mockTemplate = {
|
||||||
|
id: "template-123",
|
||||||
|
projectId: "project-123",
|
||||||
|
name: "Test Template",
|
||||||
|
createdAt: new Date(),
|
||||||
|
updatedAt: new Date(),
|
||||||
|
algorithms: {
|
||||||
|
signature: ["SHA256-RSA", "SHA384-RSA"],
|
||||||
|
keyType: ["RSA_2048", "RSA_4096"]
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
vi.clearAllMocks();
|
||||||
|
|
||||||
|
// Mock current date to be within renewal window
|
||||||
|
vi.useFakeTimers();
|
||||||
|
vi.setSystemTime(new Date("2024-01-26")); // 6 days before cert expires, within renewal window
|
||||||
|
});
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
vi.useRealTimers();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should successfully renew eligible certificate", async () => {
|
||||||
|
vi.mocked(mockCertificateDAL.findById).mockResolvedValue(mockOriginalCert);
|
||||||
|
vi.mocked(mockCertificateProfileDAL.findByIdWithConfigs).mockResolvedValue(mockProfile);
|
||||||
|
vi.mocked(mockCertificateAuthorityDAL.findByIdWithAssociatedCa).mockResolvedValue(mockCA);
|
||||||
|
vi.mocked(mockCertificateTemplateV2Service.getTemplateV2ById).mockResolvedValue(mockTemplate);
|
||||||
|
vi.mocked(mockCertificateTemplateV2Service.validateCertificateRequest).mockResolvedValue({
|
||||||
|
isValid: true,
|
||||||
|
errors: [],
|
||||||
|
warnings: []
|
||||||
|
});
|
||||||
|
vi.mocked(mockInternalCaService.issueCertFromCa).mockResolvedValue({
|
||||||
|
certificate: "renewed-cert",
|
||||||
|
certificateChain: "renewed-chain",
|
||||||
|
issuingCaCertificate: "issuing-ca",
|
||||||
|
privateKey: "private-key",
|
||||||
|
serialNumber: "789012",
|
||||||
|
ca: mockCA
|
||||||
|
});
|
||||||
|
|
||||||
|
const newCert = { ...mockOriginalCert, id: "cert-456", serialNumber: "789012" };
|
||||||
|
vi.mocked(mockCertificateDAL.findOne).mockResolvedValue(newCert);
|
||||||
|
vi.mocked(mockCertificateDAL.updateById).mockResolvedValue(newCert);
|
||||||
|
|
||||||
|
const result = await service.renewCertificate({
|
||||||
|
certificateId: "cert-123",
|
||||||
|
...mockActor
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(result).toHaveProperty("certificate", "renewed-cert");
|
||||||
|
expect(result).toHaveProperty("certificateId", "cert-456");
|
||||||
|
expect(mockCertificateDAL.updateById).toHaveBeenCalledWith("cert-456", {
|
||||||
|
profileId: "profile-123",
|
||||||
|
renewBeforeDays: 14,
|
||||||
|
renewedFromId: "cert-123"
|
||||||
|
});
|
||||||
|
expect(mockCertificateDAL.updateById).toHaveBeenCalledWith("cert-123", {
|
||||||
|
renewedById: "cert-456",
|
||||||
|
renewalError: null
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should validate certificate against current template during renewal", async () => {
|
||||||
|
vi.mocked(mockCertificateDAL.findById).mockResolvedValue(mockOriginalCert);
|
||||||
|
vi.mocked(mockCertificateProfileDAL.findByIdWithConfigs).mockResolvedValue(mockProfile);
|
||||||
|
vi.mocked(mockCertificateAuthorityDAL.findByIdWithAssociatedCa).mockResolvedValue(mockCA);
|
||||||
|
vi.mocked(mockCertificateTemplateV2Service.getTemplateV2ById).mockResolvedValue(mockTemplate);
|
||||||
|
vi.mocked(mockCertificateTemplateV2Service.validateCertificateRequest).mockResolvedValue({
|
||||||
|
isValid: false,
|
||||||
|
errors: ["Subject alternative name not allowed"],
|
||||||
|
warnings: []
|
||||||
|
});
|
||||||
|
|
||||||
|
await expect(
|
||||||
|
service.renewCertificate({
|
||||||
|
certificateId: "cert-123",
|
||||||
|
...mockActor
|
||||||
|
})
|
||||||
|
).rejects.toThrow(BadRequestError);
|
||||||
|
|
||||||
|
await expect(
|
||||||
|
service.renewCertificate({
|
||||||
|
certificateId: "cert-123",
|
||||||
|
...mockActor
|
||||||
|
})
|
||||||
|
).rejects.toThrow(
|
||||||
|
"Certificate renewal failed because requested validity period exceeds maximum allowed duration by the profile template"
|
||||||
|
);
|
||||||
|
|
||||||
|
// Should store template validation error
|
||||||
|
expect(mockCertificateDAL.updateById).toHaveBeenCalledWith("cert-123", {
|
||||||
|
renewalError: "Template validation failed: Subject alternative name not allowed"
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should reject renewal if certificate is not from a profile", async () => {
|
||||||
|
const certWithoutProfile = { ...mockOriginalCert, profileId: null };
|
||||||
|
vi.mocked(mockCertificateDAL.findById).mockResolvedValue(certWithoutProfile);
|
||||||
|
|
||||||
|
await expect(
|
||||||
|
service.renewCertificate({
|
||||||
|
certificateId: "cert-123",
|
||||||
|
...mockActor
|
||||||
|
})
|
||||||
|
).rejects.toThrow(ForbiddenRequestError);
|
||||||
|
|
||||||
|
await expect(
|
||||||
|
service.renewCertificate({
|
||||||
|
certificateId: "cert-123",
|
||||||
|
...mockActor
|
||||||
|
})
|
||||||
|
).rejects.toThrow("Only certificates issued from a profile can be renewed");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should reject renewal if certificate is already renewed", async () => {
|
||||||
|
const alreadyRenewedCert = { ...mockOriginalCert, renewedById: "cert-456" };
|
||||||
|
vi.mocked(mockCertificateDAL.findById).mockResolvedValue(alreadyRenewedCert);
|
||||||
|
vi.mocked(mockCertificateProfileDAL.findByIdWithConfigs).mockResolvedValue(mockProfile);
|
||||||
|
vi.mocked(mockCertificateAuthorityDAL.findByIdWithAssociatedCa).mockResolvedValue(mockCA);
|
||||||
|
|
||||||
|
await expect(
|
||||||
|
service.renewCertificate({
|
||||||
|
certificateId: "cert-123",
|
||||||
|
...mockActor
|
||||||
|
})
|
||||||
|
).rejects.toThrow(BadRequestError);
|
||||||
|
|
||||||
|
await expect(
|
||||||
|
service.renewCertificate({
|
||||||
|
certificateId: "cert-123",
|
||||||
|
...mockActor
|
||||||
|
})
|
||||||
|
).rejects.toThrow("Certificate has already been renewed");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should reject renewal if certificate is expired", async () => {
|
||||||
|
const expiredCert = {
|
||||||
|
...mockOriginalCert,
|
||||||
|
notAfter: new Date("2024-01-20") // Expired 6 days ago
|
||||||
|
};
|
||||||
|
vi.mocked(mockCertificateDAL.findById).mockResolvedValue(expiredCert);
|
||||||
|
vi.mocked(mockCertificateProfileDAL.findByIdWithConfigs).mockResolvedValue(mockProfile);
|
||||||
|
vi.mocked(mockCertificateAuthorityDAL.findByIdWithAssociatedCa).mockResolvedValue(mockCA);
|
||||||
|
|
||||||
|
await expect(
|
||||||
|
service.renewCertificate({
|
||||||
|
certificateId: "cert-123",
|
||||||
|
...mockActor
|
||||||
|
})
|
||||||
|
).rejects.toThrow(BadRequestError);
|
||||||
|
|
||||||
|
await expect(
|
||||||
|
service.renewCertificate({
|
||||||
|
certificateId: "cert-123",
|
||||||
|
...mockActor
|
||||||
|
})
|
||||||
|
).rejects.toThrow("Certificate is already expired");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should reject renewal if certificate is revoked", async () => {
|
||||||
|
const revokedCert = {
|
||||||
|
...mockOriginalCert,
|
||||||
|
revokedAt: new Date("2024-01-15")
|
||||||
|
};
|
||||||
|
vi.mocked(mockCertificateDAL.findById).mockResolvedValue(revokedCert);
|
||||||
|
vi.mocked(mockCertificateProfileDAL.findByIdWithConfigs).mockResolvedValue(mockProfile);
|
||||||
|
vi.mocked(mockCertificateAuthorityDAL.findByIdWithAssociatedCa).mockResolvedValue(mockCA);
|
||||||
|
|
||||||
|
await expect(
|
||||||
|
service.renewCertificate({
|
||||||
|
certificateId: "cert-123",
|
||||||
|
...mockActor
|
||||||
|
})
|
||||||
|
).rejects.toThrow(BadRequestError);
|
||||||
|
|
||||||
|
await expect(
|
||||||
|
service.renewCertificate({
|
||||||
|
certificateId: "cert-123",
|
||||||
|
...mockActor
|
||||||
|
})
|
||||||
|
).rejects.toThrow("Certificate is revoked and cannot be renewed");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should reject renewal if CA is inactive", async () => {
|
||||||
|
const inactiveCA = { ...mockCA, status: CaStatus.DISABLED };
|
||||||
|
vi.mocked(mockCertificateDAL.findById).mockResolvedValue(mockOriginalCert);
|
||||||
|
vi.mocked(mockCertificateProfileDAL.findByIdWithConfigs).mockResolvedValue(mockProfile);
|
||||||
|
vi.mocked(mockCertificateAuthorityDAL.findByIdWithAssociatedCa).mockResolvedValue(inactiveCA);
|
||||||
|
|
||||||
|
await expect(
|
||||||
|
service.renewCertificate({
|
||||||
|
certificateId: "cert-123",
|
||||||
|
...mockActor
|
||||||
|
})
|
||||||
|
).rejects.toThrow(BadRequestError);
|
||||||
|
|
||||||
|
await expect(
|
||||||
|
service.renewCertificate({
|
||||||
|
certificateId: "cert-123",
|
||||||
|
...mockActor
|
||||||
|
})
|
||||||
|
).rejects.toThrow("Certificate is not eligible for renewal: Certificate Authority is disabled, must be active");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should reject renewal if new certificate would outlive CA", async () => {
|
||||||
|
const shortLivedCA = {
|
||||||
|
...mockCA,
|
||||||
|
internalCa: {
|
||||||
|
...mockCA.internalCa,
|
||||||
|
notAfter: "2024-01-28T00:00:00Z"
|
||||||
|
}
|
||||||
|
};
|
||||||
|
vi.mocked(mockCertificateDAL.findById).mockResolvedValue(mockOriginalCert);
|
||||||
|
vi.mocked(mockCertificateProfileDAL.findByIdWithConfigs).mockResolvedValue(mockProfile);
|
||||||
|
vi.mocked(mockCertificateAuthorityDAL.findByIdWithAssociatedCa).mockResolvedValue(shortLivedCA);
|
||||||
|
|
||||||
|
await expect(
|
||||||
|
service.renewCertificate({
|
||||||
|
certificateId: "cert-123",
|
||||||
|
...mockActor
|
||||||
|
})
|
||||||
|
).rejects.toThrow(BadRequestError);
|
||||||
|
|
||||||
|
await expect(
|
||||||
|
service.renewCertificate({
|
||||||
|
certificateId: "cert-123",
|
||||||
|
...mockActor
|
||||||
|
})
|
||||||
|
).rejects.toThrow("New certificate would expire");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should allow manual renewal outside window (manual renewal always bypasses window)", async () => {
|
||||||
|
vi.setSystemTime(new Date("2024-01-15")); // 17 days before expiry, outside 7-day window
|
||||||
|
|
||||||
|
vi.mocked(mockCertificateDAL.findById).mockResolvedValue(mockOriginalCert);
|
||||||
|
vi.mocked(mockCertificateProfileDAL.findByIdWithConfigs).mockResolvedValue(mockProfile);
|
||||||
|
vi.mocked(mockCertificateAuthorityDAL.findByIdWithAssociatedCa).mockResolvedValue(mockCA);
|
||||||
|
vi.mocked(mockCertificateTemplateV2Service.getTemplateV2ById).mockResolvedValue(mockTemplate);
|
||||||
|
vi.mocked(mockCertificateTemplateV2Service.validateCertificateRequest).mockResolvedValue({
|
||||||
|
isValid: true,
|
||||||
|
errors: [],
|
||||||
|
warnings: []
|
||||||
|
});
|
||||||
|
vi.mocked(mockInternalCaService.issueCertFromCa).mockResolvedValue({
|
||||||
|
certificate: "renewed-cert",
|
||||||
|
certificateChain: "renewed-chain",
|
||||||
|
issuingCaCertificate: "issuing-ca",
|
||||||
|
privateKey: "private-key",
|
||||||
|
serialNumber: "789012",
|
||||||
|
ca: mockCA
|
||||||
|
});
|
||||||
|
|
||||||
|
const newCert = { ...mockOriginalCert, id: "cert-456", serialNumber: "789012" };
|
||||||
|
vi.mocked(mockCertificateDAL.findOne).mockResolvedValue(newCert);
|
||||||
|
vi.mocked(mockCertificateDAL.updateById).mockResolvedValue(newCert);
|
||||||
|
|
||||||
|
const result = await service.renewCertificate({
|
||||||
|
certificateId: "cert-123",
|
||||||
|
...mockActor
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(result).toHaveProperty("certificate", "renewed-cert");
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("updateRenewalConfig", () => {
|
||||||
|
it("should update renewal configuration successfully", async () => {
|
||||||
|
const mockCert = {
|
||||||
|
id: "cert-123",
|
||||||
|
profileId: "profile-123",
|
||||||
|
renewedById: null,
|
||||||
|
notBefore: new Date("2026-01-01"),
|
||||||
|
notAfter: new Date("2026-02-01"),
|
||||||
|
projectId: "project-123",
|
||||||
|
status: CertStatus.ACTIVE,
|
||||||
|
revokedAt: null
|
||||||
|
};
|
||||||
|
|
||||||
|
const mockProfile = {
|
||||||
|
id: "profile-123",
|
||||||
|
enrollmentType: EnrollmentType.API,
|
||||||
|
projectId: "project-123"
|
||||||
|
};
|
||||||
|
|
||||||
|
vi.mocked(mockCertificateDAL.findById).mockResolvedValue(mockCert as any);
|
||||||
|
vi.mocked(mockCertificateProfileDAL.findByIdWithConfigs).mockResolvedValue(mockProfile as any);
|
||||||
|
vi.mocked(mockCertificateDAL.updateById).mockResolvedValue(mockCert as any);
|
||||||
|
|
||||||
|
const result = await service.updateRenewalConfig({
|
||||||
|
actor: ActorType.USER,
|
||||||
|
actorId: "user-123",
|
||||||
|
actorAuthMethod: AuthMethod.EMAIL,
|
||||||
|
actorOrgId: "org-123",
|
||||||
|
certificateId: "cert-123",
|
||||||
|
renewBeforeDays: 7
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(result).toEqual({
|
||||||
|
projectId: "project-123",
|
||||||
|
renewBeforeDays: 7
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(mockCertificateDAL.updateById).toHaveBeenCalledWith("cert-123", {
|
||||||
|
renewBeforeDays: 7
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should reject update if certificate is not from profile", async () => {
|
||||||
|
const mockCert = {
|
||||||
|
id: "cert-123",
|
||||||
|
profileId: null,
|
||||||
|
renewedById: null,
|
||||||
|
projectId: "project-123"
|
||||||
|
};
|
||||||
|
|
||||||
|
vi.mocked(mockCertificateDAL.findById).mockResolvedValue(mockCert as any);
|
||||||
|
|
||||||
|
await expect(
|
||||||
|
service.updateRenewalConfig({
|
||||||
|
actor: ActorType.USER,
|
||||||
|
actorId: "user-123",
|
||||||
|
actorAuthMethod: AuthMethod.EMAIL,
|
||||||
|
actorOrgId: "org-123",
|
||||||
|
certificateId: "cert-123",
|
||||||
|
renewBeforeDays: 7
|
||||||
|
})
|
||||||
|
).rejects.toThrow(BadRequestError);
|
||||||
|
|
||||||
|
await expect(
|
||||||
|
service.updateRenewalConfig({
|
||||||
|
actor: ActorType.USER,
|
||||||
|
actorId: "user-123",
|
||||||
|
actorAuthMethod: AuthMethod.EMAIL,
|
||||||
|
actorOrgId: "org-123",
|
||||||
|
certificateId: "cert-123",
|
||||||
|
renewBeforeDays: 7
|
||||||
|
})
|
||||||
|
).rejects.toThrow("Certificate is not eligible for auto-renewal: certificate was not issued from a profile");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should reject update if certificate is already renewed", async () => {
|
||||||
|
const mockCert = {
|
||||||
|
id: "cert-123",
|
||||||
|
profileId: "profile-123",
|
||||||
|
renewedById: "cert-456",
|
||||||
|
projectId: "project-123",
|
||||||
|
status: CertStatus.ACTIVE,
|
||||||
|
revokedAt: null,
|
||||||
|
notBefore: new Date("2026-01-01"),
|
||||||
|
notAfter: new Date("2026-02-01")
|
||||||
|
};
|
||||||
|
|
||||||
|
const mockProfile = {
|
||||||
|
id: "profile-123",
|
||||||
|
enrollmentType: EnrollmentType.API,
|
||||||
|
projectId: "project-123"
|
||||||
|
};
|
||||||
|
|
||||||
|
vi.mocked(mockCertificateDAL.findById).mockResolvedValue(mockCert as any);
|
||||||
|
vi.mocked(mockCertificateProfileDAL.findByIdWithConfigs).mockResolvedValue(mockProfile as any);
|
||||||
|
|
||||||
|
await expect(
|
||||||
|
service.updateRenewalConfig({
|
||||||
|
actor: ActorType.USER,
|
||||||
|
actorId: "user-123",
|
||||||
|
actorAuthMethod: AuthMethod.EMAIL,
|
||||||
|
actorOrgId: "org-123",
|
||||||
|
certificateId: "cert-123",
|
||||||
|
renewBeforeDays: 7
|
||||||
|
})
|
||||||
|
).rejects.toThrow(BadRequestError);
|
||||||
|
|
||||||
|
await expect(
|
||||||
|
service.updateRenewalConfig({
|
||||||
|
actor: ActorType.USER,
|
||||||
|
actorId: "user-123",
|
||||||
|
actorAuthMethod: AuthMethod.EMAIL,
|
||||||
|
actorOrgId: "org-123",
|
||||||
|
certificateId: "cert-123",
|
||||||
|
renewBeforeDays: 7
|
||||||
|
})
|
||||||
|
).rejects.toThrow("Certificate is not eligible for auto-renewal: certificate has already been renewed");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should reject update if renewBeforeDays >= certificate TTL", async () => {
|
||||||
|
const mockCert = {
|
||||||
|
id: "cert-123",
|
||||||
|
profileId: "profile-123",
|
||||||
|
renewedById: null,
|
||||||
|
notBefore: new Date("2026-01-01"),
|
||||||
|
notAfter: new Date("2026-01-08"),
|
||||||
|
projectId: "project-123",
|
||||||
|
status: CertStatus.ACTIVE,
|
||||||
|
revokedAt: null
|
||||||
|
};
|
||||||
|
|
||||||
|
const mockProfile = {
|
||||||
|
id: "profile-123",
|
||||||
|
enrollmentType: EnrollmentType.API,
|
||||||
|
projectId: "project-123"
|
||||||
|
};
|
||||||
|
|
||||||
|
vi.mocked(mockCertificateDAL.findById).mockResolvedValue(mockCert as any);
|
||||||
|
vi.mocked(mockCertificateProfileDAL.findByIdWithConfigs).mockResolvedValue(mockProfile as any);
|
||||||
|
|
||||||
|
await expect(
|
||||||
|
service.updateRenewalConfig({
|
||||||
|
actor: ActorType.USER,
|
||||||
|
actorId: "user-123",
|
||||||
|
actorAuthMethod: AuthMethod.EMAIL,
|
||||||
|
actorOrgId: "org-123",
|
||||||
|
certificateId: "cert-123",
|
||||||
|
renewBeforeDays: 8 // Greater than 7-day TTL
|
||||||
|
})
|
||||||
|
).rejects.toThrow(BadRequestError);
|
||||||
|
|
||||||
|
await expect(
|
||||||
|
service.updateRenewalConfig({
|
||||||
|
actor: ActorType.USER,
|
||||||
|
actorId: "user-123",
|
||||||
|
actorAuthMethod: AuthMethod.EMAIL,
|
||||||
|
actorOrgId: "org-123",
|
||||||
|
certificateId: "cert-123",
|
||||||
|
renewBeforeDays: 8
|
||||||
|
})
|
||||||
|
).rejects.toThrow("Invalid renewal configuration: renewal threshold exceeds certificate validity period");
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("disableRenewalConfig", () => {
|
||||||
|
it("should disable renewal configuration successfully", async () => {
|
||||||
|
const mockCert = {
|
||||||
|
id: "cert-123",
|
||||||
|
profileId: "profile-123",
|
||||||
|
projectId: "project-123"
|
||||||
|
};
|
||||||
|
|
||||||
|
const mockProfile = {
|
||||||
|
id: "profile-123",
|
||||||
|
enrollmentType: EnrollmentType.API,
|
||||||
|
projectId: "project-123"
|
||||||
|
};
|
||||||
|
|
||||||
|
vi.mocked(mockCertificateDAL.findById).mockResolvedValue(mockCert as any);
|
||||||
|
vi.mocked(mockCertificateProfileDAL.findByIdWithConfigs).mockResolvedValue(mockProfile as any);
|
||||||
|
vi.mocked(mockCertificateDAL.updateById).mockResolvedValue(mockCert as any);
|
||||||
|
|
||||||
|
const result = await service.disableRenewalConfig({
|
||||||
|
actor: ActorType.USER,
|
||||||
|
actorId: "user-123",
|
||||||
|
actorAuthMethod: AuthMethod.EMAIL,
|
||||||
|
actorOrgId: "org-123",
|
||||||
|
certificateId: "cert-123"
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(result).toEqual({
|
||||||
|
projectId: "project-123"
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(mockCertificateDAL.updateById).toHaveBeenCalledWith("cert-123", {
|
||||||
|
renewBeforeDays: null
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should reject disable if certificate is not from profile", async () => {
|
||||||
|
const mockCert = {
|
||||||
|
id: "cert-123",
|
||||||
|
profileId: null,
|
||||||
|
projectId: "project-123"
|
||||||
|
};
|
||||||
|
|
||||||
|
vi.mocked(mockCertificateDAL.findById).mockResolvedValue(mockCert as any);
|
||||||
|
|
||||||
|
await expect(
|
||||||
|
service.disableRenewalConfig({
|
||||||
|
actor: ActorType.USER,
|
||||||
|
actorId: "user-123",
|
||||||
|
actorAuthMethod: AuthMethod.EMAIL,
|
||||||
|
actorOrgId: "org-123",
|
||||||
|
certificateId: "cert-123"
|
||||||
|
})
|
||||||
|
).rejects.toThrow(BadRequestError);
|
||||||
|
|
||||||
|
await expect(
|
||||||
|
service.disableRenewalConfig({
|
||||||
|
actor: ActorType.USER,
|
||||||
|
actorId: "user-123",
|
||||||
|
actorAuthMethod: AuthMethod.EMAIL,
|
||||||
|
actorOrgId: "org-123",
|
||||||
|
certificateId: "cert-123"
|
||||||
|
})
|
||||||
|
).rejects.toThrow("Certificate is not eligible for auto-renewal: certificate was not issued from a profile");
|
||||||
|
});
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -1,9 +1,11 @@
|
|||||||
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";
|
||||||
@@ -11,15 +13,18 @@ import { BadRequestError, ForbiddenRequestError, NotFoundError } from "@app/lib/
|
|||||||
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 {
|
import {
|
||||||
|
CertExtendedKeyUsage,
|
||||||
CertificateOrderStatus,
|
CertificateOrderStatus,
|
||||||
CertKeyAlgorithm,
|
CertKeyAlgorithm,
|
||||||
CertSignatureAlgorithm
|
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";
|
||||||
@@ -30,7 +35,9 @@ import {
|
|||||||
bufferToString,
|
bufferToString,
|
||||||
buildCertificateSubjectFromTemplate,
|
buildCertificateSubjectFromTemplate,
|
||||||
buildSubjectAlternativeNamesFromTemplate,
|
buildSubjectAlternativeNamesFromTemplate,
|
||||||
|
convertExtendedKeyUsageArrayFromLegacy,
|
||||||
convertExtendedKeyUsageArrayToLegacy,
|
convertExtendedKeyUsageArrayToLegacy,
|
||||||
|
convertKeyUsageArrayFromLegacy,
|
||||||
convertKeyUsageArrayToLegacy,
|
convertKeyUsageArrayToLegacy,
|
||||||
mapEnumsForValidation,
|
mapEnumsForValidation,
|
||||||
normalizeDateForApi
|
normalizeDateForApi
|
||||||
@@ -38,13 +45,18 @@ 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">;
|
||||||
certificateAuthorityDAL: Pick<TCertificateAuthorityDALFactory, "findByIdWithAssociatedCa">;
|
certificateAuthorityDAL: Pick<TCertificateAuthorityDALFactory, "findByIdWithAssociatedCa">;
|
||||||
certificateProfileDAL: Pick<TCertificateProfileDALFactory, "findByIdWithConfigs">;
|
certificateProfileDAL: Pick<TCertificateProfileDALFactory, "findByIdWithConfigs">;
|
||||||
certificateTemplateV2Service: Pick<
|
certificateTemplateV2Service: Pick<
|
||||||
@@ -95,6 +107,77 @@ const validateProfileAndPermissions = async (
|
|||||||
return profile;
|
return profile;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const validateRenewalEligibility = (
|
||||||
|
certificate: {
|
||||||
|
id: string;
|
||||||
|
status: string;
|
||||||
|
notBefore: Date;
|
||||||
|
notAfter: Date;
|
||||||
|
revokedAt?: Date | null;
|
||||||
|
renewedById?: string | null;
|
||||||
|
profileId?: string | null;
|
||||||
|
caId?: string | null;
|
||||||
|
pkiSubscriberId?: string | null;
|
||||||
|
},
|
||||||
|
ca: TCertificateAuthorityWithAssociatedCa
|
||||||
|
) => {
|
||||||
|
const errors: string[] = [];
|
||||||
|
|
||||||
|
if (certificate.status !== CertStatus.ACTIVE) {
|
||||||
|
errors.push(`Certificate status is ${certificate.status}, must be ${CertStatus.ACTIVE}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
const now = new Date();
|
||||||
|
if (certificate.notAfter <= now) {
|
||||||
|
errors.push("Certificate is already expired");
|
||||||
|
}
|
||||||
|
|
||||||
|
if (certificate.revokedAt) {
|
||||||
|
errors.push("Certificate is revoked and cannot be renewed");
|
||||||
|
}
|
||||||
|
|
||||||
|
const caType = (ca.externalCa?.type as CaType) ?? CaType.INTERNAL;
|
||||||
|
const isInternalCa = caType === CaType.INTERNAL;
|
||||||
|
const isConnectedExternalCa = caType === CaType.ACME || caType === CaType.AZURE_AD_CS;
|
||||||
|
const isImportedCertificate = certificate.pkiSubscriberId != null && !certificate.profileId;
|
||||||
|
|
||||||
|
if (!isInternalCa && !isConnectedExternalCa) {
|
||||||
|
errors.push(`CA type ${String(caType)} does not support renewal`);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (isImportedCertificate) {
|
||||||
|
errors.push("Externally imported certificates cannot be renewed");
|
||||||
|
}
|
||||||
|
|
||||||
|
if (ca.status !== CaStatus.ACTIVE) {
|
||||||
|
errors.push(`Certificate Authority is ${ca.status}, must be ${CaStatus.ACTIVE}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (certificate.renewedById) {
|
||||||
|
errors.push("Certificate has already been renewed");
|
||||||
|
}
|
||||||
|
|
||||||
|
const certificateTtlInDays = Math.ceil(
|
||||||
|
(certificate.notAfter.getTime() - certificate.notBefore.getTime()) / (24 * 60 * 60 * 1000)
|
||||||
|
);
|
||||||
|
|
||||||
|
if (ca.internalCa?.notAfter) {
|
||||||
|
const caExpiryDate = new Date(ca.internalCa.notAfter);
|
||||||
|
const proposedCertExpiryDate = new Date(now.getTime() + certificateTtlInDays * 24 * 60 * 60 * 1000);
|
||||||
|
|
||||||
|
if (proposedCertExpiryDate > caExpiryDate) {
|
||||||
|
errors.push(
|
||||||
|
`New certificate would expire (${proposedCertExpiryDate.toISOString()}) after its issuing CA (${caExpiryDate.toISOString()})`
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
isEligible: errors.length === 0,
|
||||||
|
errors
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
const validateCaSupport = (ca: TCertificateAuthorityWithAssociatedCa, operation: string) => {
|
const 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) {
|
||||||
@@ -155,6 +238,63 @@ 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}` });
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
export const certificateV3ServiceFactory = ({
|
export const certificateV3ServiceFactory = ({
|
||||||
certificateDAL,
|
certificateDAL,
|
||||||
certificateAuthorityDAL,
|
certificateAuthorityDAL,
|
||||||
@@ -274,7 +414,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 certificateTtlInDays = parseTtlToDays(certificateRequest.validity.ttl);
|
||||||
|
const renewBeforeDays = calculateRenewalThreshold(profile.apiConfig?.renewBeforeDays, certificateTtlInDays);
|
||||||
|
|
||||||
|
const finalRenewBeforeDays =
|
||||||
|
renewBeforeDays && isValidRenewalTiming(renewBeforeDays, new Date(cert.notAfter)) ? renewBeforeDays : undefined;
|
||||||
|
|
||||||
|
await certificateDAL.updateById(cert.id, {
|
||||||
|
profileId,
|
||||||
|
renewBeforeDays: finalRenewBeforeDays
|
||||||
|
});
|
||||||
|
|
||||||
return {
|
return {
|
||||||
certificate: bufferToString(certificate),
|
certificate: bufferToString(certificate),
|
||||||
@@ -371,7 +520,16 @@ 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 certificateTtlInDays = parseTtlToDays(validity.ttl);
|
||||||
|
const renewBeforeDays = calculateRenewalThreshold(profile.apiConfig?.renewBeforeDays, certificateTtlInDays);
|
||||||
|
|
||||||
|
const finalRenewBeforeDays =
|
||||||
|
renewBeforeDays && isValidRenewalTiming(renewBeforeDays, new Date(cert.notAfter)) ? renewBeforeDays : undefined;
|
||||||
|
|
||||||
|
await certificateDAL.updateById(cert.id, {
|
||||||
|
profileId,
|
||||||
|
renewBeforeDays: finalRenewBeforeDays
|
||||||
|
});
|
||||||
|
|
||||||
const certificateString = extractCertificateFromBuffer(certificate as unknown as Buffer);
|
const certificateString = extractCertificateFromBuffer(certificate as unknown as Buffer);
|
||||||
const certificateChainString = extractCertificateFromBuffer(certificateChain as unknown as Buffer);
|
const certificateChainString = extractCertificateFromBuffer(certificateChain as unknown as Buffer);
|
||||||
@@ -479,9 +637,341 @@ export const certificateV3ServiceFactory = ({
|
|||||||
});
|
});
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const renewCertificate = async ({
|
||||||
|
certificateId,
|
||||||
|
actor,
|
||||||
|
actorId,
|
||||||
|
actorAuthMethod,
|
||||||
|
actorOrgId,
|
||||||
|
internal = false
|
||||||
|
}: TRenewCertificateDTO & { internal?: boolean }): Promise<TCertificateFromProfileResponse> => {
|
||||||
|
const originalCert = await certificateDAL.findById(certificateId);
|
||||||
|
if (!originalCert) {
|
||||||
|
throw new NotFoundError({ message: "Certificate not found" });
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!originalCert.profileId) {
|
||||||
|
throw new ForbiddenRequestError({
|
||||||
|
message: "Only certificates issued from a profile can be renewed"
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
const originalSignatureAlgorithm = originalCert.signatureAlgorithm as CertSignatureAlgorithm;
|
||||||
|
const originalKeyAlgorithm = originalCert.keyAlgorithm as CertKeyAlgorithm;
|
||||||
|
|
||||||
|
if (!originalSignatureAlgorithm || !originalKeyAlgorithm) {
|
||||||
|
throw new BadRequestError({
|
||||||
|
message:
|
||||||
|
"Original certificate does not have algorithm information stored. Cannot renew certificate issued before algorithm tracking was implemented."
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
const profile = await certificateProfileDAL.findByIdWithConfigs(originalCert.profileId);
|
||||||
|
if (!profile) {
|
||||||
|
throw new NotFoundError({ message: "Certificate profile not found" });
|
||||||
|
}
|
||||||
|
|
||||||
|
if (profile.enrollmentType !== "api") {
|
||||||
|
throw new ForbiddenRequestError({
|
||||||
|
message: "Certificate is not eligible for renewal: EST certificates cannot be renewed through this endpoint"
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
const ca = await certificateAuthorityDAL.findByIdWithAssociatedCa(profile.caId);
|
||||||
|
if (!ca) {
|
||||||
|
throw new NotFoundError({ message: "Certificate Authority not found" });
|
||||||
|
}
|
||||||
|
|
||||||
|
const eligibilityCheck = validateRenewalEligibility(originalCert, ca);
|
||||||
|
if (!eligibilityCheck.isEligible) {
|
||||||
|
throw new BadRequestError({
|
||||||
|
message: `Certificate is not eligible for renewal: ${eligibilityCheck.errors.join(", ")}`
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!internal) {
|
||||||
|
const { permission } = await permissionService.getProjectPermission({
|
||||||
|
actor,
|
||||||
|
actorId,
|
||||||
|
projectId: profile.projectId,
|
||||||
|
actorAuthMethod,
|
||||||
|
actorOrgId,
|
||||||
|
actionProjectType: ActionProjectType.CertificateManager
|
||||||
|
});
|
||||||
|
|
||||||
|
ForbiddenError.from(permission).throwUnlessCan(
|
||||||
|
ProjectPermissionCertificateProfileActions.IssueCert,
|
||||||
|
ProjectPermissionSub.CertificateProfiles
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
validateCaSupport(ca, "direct certificate issuance");
|
||||||
|
|
||||||
|
const template = await certificateTemplateV2Service.getTemplateV2ById({
|
||||||
|
actor,
|
||||||
|
actorId,
|
||||||
|
actorAuthMethod,
|
||||||
|
actorOrgId,
|
||||||
|
templateId: profile.certificateTemplateId,
|
||||||
|
internal
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!template) {
|
||||||
|
throw new NotFoundError({ message: "Certificate template not found for this profile" });
|
||||||
|
}
|
||||||
|
|
||||||
|
const originalTtlInDays = Math.ceil(
|
||||||
|
(new Date(originalCert.notAfter).getTime() - new Date(originalCert.notBefore).getTime()) / (1000 * 60 * 60 * 24)
|
||||||
|
);
|
||||||
|
const ttl = `${originalTtlInDays}d`;
|
||||||
|
|
||||||
|
const certificateRequest = {
|
||||||
|
commonName: originalCert.commonName || undefined,
|
||||||
|
keyUsages: convertKeyUsageArrayFromLegacy(parseKeyUsages(originalCert.keyUsages)),
|
||||||
|
extendedKeyUsages: convertExtendedKeyUsageArrayFromLegacy(parseExtendedKeyUsages(originalCert.extendedKeyUsages)),
|
||||||
|
subjectAlternativeNames: originalCert.altNames
|
||||||
|
? originalCert.altNames.split(",").map((san) => {
|
||||||
|
const trimmed = san.trim();
|
||||||
|
const isIp =
|
||||||
|
new RE2("^\\d{1,3}\\.\\d{1,3}\\.\\d{1,3}\\.\\d{1,3}$").test(trimmed) ||
|
||||||
|
new RE2("^([0-9a-fA-F]{1,4}:){7}[0-9a-fA-F]{1,4}$").test(trimmed);
|
||||||
|
return {
|
||||||
|
type: isIp ? CertSubjectAlternativeNameType.IP_ADDRESS : CertSubjectAlternativeNameType.DNS_NAME,
|
||||||
|
value: trimmed
|
||||||
|
};
|
||||||
|
})
|
||||||
|
: [],
|
||||||
|
validity: {
|
||||||
|
ttl
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const validationResult = await certificateTemplateV2Service.validateCertificateRequest(
|
||||||
|
profile.certificateTemplateId,
|
||||||
|
certificateRequest
|
||||||
|
);
|
||||||
|
|
||||||
|
if (!validationResult.isValid) {
|
||||||
|
await certificateDAL.updateById(originalCert.id, {
|
||||||
|
renewalError: `Template validation failed: ${validationResult.errors.join(", ")}`
|
||||||
|
});
|
||||||
|
|
||||||
|
throw new BadRequestError({
|
||||||
|
message: `Certificate renewal failed because requested validity period exceeds maximum allowed duration by the profile template: ${validationResult.errors.join(", ")}`
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
validateAlgorithmCompatibility(ca, template);
|
||||||
|
const notBefore = new Date();
|
||||||
|
const notAfter = new Date(Date.now() + parseTtlToDays(ttl) * 24 * 60 * 60 * 1000);
|
||||||
|
|
||||||
|
const { certificate, certificateChain, issuingCaCertificate, serialNumber } =
|
||||||
|
await internalCaService.issueCertFromCa({
|
||||||
|
caId: ca.id,
|
||||||
|
friendlyName: originalCert.friendlyName || originalCert.commonName || "Renewed Certificate",
|
||||||
|
commonName: originalCert.commonName || "",
|
||||||
|
altNames: originalCert.altNames || "",
|
||||||
|
ttl,
|
||||||
|
notBefore: normalizeDateForApi(notBefore),
|
||||||
|
notAfter: normalizeDateForApi(notAfter),
|
||||||
|
keyUsages: parseKeyUsages(originalCert.keyUsages),
|
||||||
|
extendedKeyUsages: parseExtendedKeyUsages(originalCert.extendedKeyUsages),
|
||||||
|
signatureAlgorithm: originalSignatureAlgorithm,
|
||||||
|
keyAlgorithm: originalKeyAlgorithm,
|
||||||
|
isFromProfile: true,
|
||||||
|
actor,
|
||||||
|
actorId,
|
||||||
|
actorAuthMethod,
|
||||||
|
actorOrgId,
|
||||||
|
internal
|
||||||
|
});
|
||||||
|
|
||||||
|
const newCert = await certificateDAL.findOne({ serialNumber, caId: ca.id });
|
||||||
|
if (!newCert) {
|
||||||
|
throw new NotFoundError({ message: "Certificate was signed but could not be found in database" });
|
||||||
|
}
|
||||||
|
|
||||||
|
const certificateTtlInDays = parseTtlToDays(ttl);
|
||||||
|
const finalRenewBeforeDays = calculateRenewalThreshold(profile.apiConfig?.renewBeforeDays, certificateTtlInDays);
|
||||||
|
|
||||||
|
await certificateDAL.updateById(newCert.id, {
|
||||||
|
profileId: originalCert.profileId,
|
||||||
|
renewBeforeDays: finalRenewBeforeDays,
|
||||||
|
renewedFromId: originalCert.id
|
||||||
|
});
|
||||||
|
|
||||||
|
await certificateDAL.updateById(originalCert.id, {
|
||||||
|
renewedById: newCert.id,
|
||||||
|
renewalError: null
|
||||||
|
});
|
||||||
|
|
||||||
|
const certificateString = extractCertificateFromBuffer(certificate as unknown as Buffer);
|
||||||
|
const certificateChainString = extractCertificateFromBuffer(certificateChain as unknown as Buffer);
|
||||||
|
|
||||||
|
return {
|
||||||
|
certificate: certificateString,
|
||||||
|
issuingCaCertificate: extractCertificateFromBuffer(issuingCaCertificate as unknown as Buffer),
|
||||||
|
certificateChain: certificateChainString,
|
||||||
|
serialNumber,
|
||||||
|
certificateId: newCert.id,
|
||||||
|
projectId: profile.projectId,
|
||||||
|
profileName: profile.slug
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
const updateRenewalConfig = async ({
|
||||||
|
certificateId,
|
||||||
|
renewBeforeDays,
|
||||||
|
actor,
|
||||||
|
actorId,
|
||||||
|
actorAuthMethod,
|
||||||
|
actorOrgId
|
||||||
|
}: TUpdateRenewalConfigDTO): Promise<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 !== "api") {
|
||||||
|
throw new ForbiddenRequestError({
|
||||||
|
message: "Certificate is not eligible for auto-renewal: EST certificates cannot be auto-renewed"
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
if (certificate.status !== CertStatus.ACTIVE) {
|
||||||
|
throw new BadRequestError({
|
||||||
|
message: `Certificate is not eligible for auto-renewal: certificate status is ${certificate.status}, must be active`
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
const now = new Date();
|
||||||
|
if (certificate.notAfter <= now) {
|
||||||
|
throw new BadRequestError({
|
||||||
|
message: "Certificate is not eligible for auto-renewal: certificate has expired"
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
if (certificate.revokedAt) {
|
||||||
|
throw new BadRequestError({
|
||||||
|
message: "Certificate is not eligible for auto-renewal: certificate has been revoked"
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
if (certificate.renewedById) {
|
||||||
|
throw new BadRequestError({
|
||||||
|
message: "Certificate is not eligible for auto-renewal: certificate has already been renewed"
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
const certificateTtlInDays = Math.ceil(
|
||||||
|
(new Date(certificate.notAfter).getTime() - new Date(certificate.notBefore).getTime()) / (24 * 60 * 60 * 1000)
|
||||||
|
);
|
||||||
|
|
||||||
|
if (renewBeforeDays >= certificateTtlInDays) {
|
||||||
|
throw new BadRequestError({
|
||||||
|
message: "Invalid renewal configuration: renewal threshold exceeds certificate validity period"
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!isValidRenewalTiming(renewBeforeDays, new Date(certificate.notAfter))) {
|
||||||
|
throw new BadRequestError({
|
||||||
|
message: "Invalid renewal configuration: renewal would be triggered immediately or in the past"
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
await certificateDAL.updateById(certificateId, {
|
||||||
|
renewBeforeDays
|
||||||
|
});
|
||||||
|
|
||||||
|
return {
|
||||||
|
projectId: certificate.projectId,
|
||||||
|
renewBeforeDays
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
const disableRenewalConfig = async ({
|
||||||
|
certificateId,
|
||||||
|
actor,
|
||||||
|
actorId,
|
||||||
|
actorAuthMethod,
|
||||||
|
actorOrgId
|
||||||
|
}: TDisableRenewalConfigDTO): Promise<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 !== "api") {
|
||||||
|
throw new ForbiddenRequestError({
|
||||||
|
message: "Certificate is not eligible for auto-renewal: EST certificates cannot be auto-renewed"
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
await certificateDAL.updateById(certificateId, {
|
||||||
|
renewBeforeDays: null
|
||||||
|
});
|
||||||
|
|
||||||
|
return {
|
||||||
|
projectId: certificate.projectId
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
return {
|
return {
|
||||||
issueCertificateFromProfile,
|
issueCertificateFromProfile,
|
||||||
signCertificateFromProfile,
|
signCertificateFromProfile,
|
||||||
orderCertificateFromProfile
|
orderCertificateFromProfile,
|
||||||
|
renewCertificate,
|
||||||
|
updateRenewalConfig,
|
||||||
|
disableRenewalConfig
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -97,3 +97,25 @@ 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;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type TDisableRenewalResponse = {
|
||||||
|
projectId: string;
|
||||||
|
};
|
||||||
|
|||||||
@@ -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;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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 & { disableAutoRenewal?: boolean }
|
||||||
|
>({
|
||||||
|
mutationFn: async ({ certificateId, renewBeforeDays, disableAutoRenewal }) => {
|
||||||
|
const { data } = await apiRequest.patch<{ message: string; renewBeforeDays?: number }>(
|
||||||
|
`/api/v3/certificates/${certificateId}/config`,
|
||||||
|
{ renewBeforeDays, disableAutoRenewal }
|
||||||
|
);
|
||||||
|
return data;
|
||||||
|
},
|
||||||
|
onSuccess: (_, { projectSlug }) => {
|
||||||
|
queryClient.invalidateQueries({
|
||||||
|
queryKey: projectKeys.forProjectCertificates(projectSlug)
|
||||||
|
});
|
||||||
|
queryClient.invalidateQueries({
|
||||||
|
queryKey: projectKeys.allProjectCertificates()
|
||||||
|
});
|
||||||
|
}
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|||||||
@@ -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,11 @@ export type TCertificate = {
|
|||||||
notAfter: string;
|
notAfter: string;
|
||||||
keyUsages: CertKeyUsage[];
|
keyUsages: CertKeyUsage[];
|
||||||
extendedKeyUsages: CertExtendedKeyUsage[];
|
extendedKeyUsages: CertExtendedKeyUsage[];
|
||||||
|
renewBeforeDays?: number;
|
||||||
|
renewedBy?: string;
|
||||||
|
renewedFromId?: string;
|
||||||
|
renewedById?: string;
|
||||||
|
renewalError?: string;
|
||||||
};
|
};
|
||||||
|
|
||||||
export type TDeleteCertDTO = {
|
export type TDeleteCertDTO = {
|
||||||
@@ -43,3 +49,23 @@ 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;
|
||||||
|
projectSlug: string;
|
||||||
|
};
|
||||||
|
|||||||
+274
@@ -0,0 +1,274 @@
|
|||||||
|
import { useEffect } from "react";
|
||||||
|
import { Controller, useForm } from "react-hook-form";
|
||||||
|
import { zodResolver } from "@hookform/resolvers/zod";
|
||||||
|
import { z } from "zod";
|
||||||
|
|
||||||
|
import { createNotification } from "@app/components/notifications";
|
||||||
|
import { Button, FormControl, Input, Modal, ModalContent } from "@app/components/v2";
|
||||||
|
import { useProject } from "@app/context";
|
||||||
|
import { useUpdateRenewalConfig } from "@app/hooks/api";
|
||||||
|
import { UsePopUpState } from "@app/hooks/usePopUp";
|
||||||
|
|
||||||
|
const DEFAULT_RENEWAL_BEFORE_DAYS = 20;
|
||||||
|
const MIN_RENEWAL_BEFORE_DAYS = 1;
|
||||||
|
const MAX_RENEWAL_BEFORE_DAYS = 30;
|
||||||
|
|
||||||
|
const formSchema = z
|
||||||
|
.object({
|
||||||
|
renewBeforeDays: z
|
||||||
|
.number()
|
||||||
|
.min(MIN_RENEWAL_BEFORE_DAYS, `Renewal days must be at least ${MIN_RENEWAL_BEFORE_DAYS}`)
|
||||||
|
.max(MAX_RENEWAL_BEFORE_DAYS, `Renewal days cannot exceed ${MAX_RENEWAL_BEFORE_DAYS}`)
|
||||||
|
})
|
||||||
|
.refine(() => {
|
||||||
|
return true;
|
||||||
|
}, "Invalid renewal configuration");
|
||||||
|
|
||||||
|
type FormData = z.infer<typeof formSchema>;
|
||||||
|
|
||||||
|
type Props = {
|
||||||
|
popUp: UsePopUpState<["manageRenewal"]>;
|
||||||
|
handlePopUpToggle: (popUpName: keyof UsePopUpState<["manageRenewal"]>, state?: boolean) => void;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const CertificateManageRenewalModal = ({ popUp, handlePopUpToggle }: Props) => {
|
||||||
|
const { currentProject } = useProject();
|
||||||
|
const { mutateAsync: updateRenewalConfig, isPending: isUpdatingConfig } =
|
||||||
|
useUpdateRenewalConfig();
|
||||||
|
|
||||||
|
const certificateData = popUp.manageRenewal.data as {
|
||||||
|
certificateId: string;
|
||||||
|
commonName: string;
|
||||||
|
profileId: string;
|
||||||
|
renewBeforeDays?: number;
|
||||||
|
ttlDays: number;
|
||||||
|
notAfter: string;
|
||||||
|
renewalError?: string;
|
||||||
|
renewedFromId?: string;
|
||||||
|
renewedById?: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
const isAutoRenewalEnabled = Boolean(
|
||||||
|
certificateData?.renewBeforeDays && certificateData.renewBeforeDays > 0
|
||||||
|
);
|
||||||
|
|
||||||
|
const hasRenewalError = Boolean(certificateData?.renewalError);
|
||||||
|
|
||||||
|
const {
|
||||||
|
control,
|
||||||
|
handleSubmit,
|
||||||
|
formState: { errors },
|
||||||
|
reset
|
||||||
|
} = useForm<FormData>({
|
||||||
|
resolver: zodResolver(formSchema),
|
||||||
|
defaultValues: {
|
||||||
|
renewBeforeDays: DEFAULT_RENEWAL_BEFORE_DAYS
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (popUp.manageRenewal.isOpen) {
|
||||||
|
reset({
|
||||||
|
renewBeforeDays: certificateData?.renewBeforeDays || DEFAULT_RENEWAL_BEFORE_DAYS
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}, [popUp.manageRenewal.isOpen, certificateData?.renewBeforeDays, reset]);
|
||||||
|
|
||||||
|
const onUpdateRenewal = async (data: FormData) => {
|
||||||
|
try {
|
||||||
|
if (!currentProject?.slug) {
|
||||||
|
createNotification({
|
||||||
|
text: "Unable to update auto-renewal: Project not found. Please refresh the page and try again.",
|
||||||
|
type: "error"
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (data.renewBeforeDays >= certificateData.ttlDays) {
|
||||||
|
createNotification({
|
||||||
|
text: `Renewal days (${data.renewBeforeDays}) must be less than certificate TTL (${certificateData.ttlDays} days)`,
|
||||||
|
type: "error"
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const expiryDate = new Date(certificateData.notAfter);
|
||||||
|
const renewalDate = new Date(
|
||||||
|
expiryDate.getTime() - data.renewBeforeDays * 24 * 60 * 60 * 1000
|
||||||
|
);
|
||||||
|
const tomorrow = new Date();
|
||||||
|
tomorrow.setDate(tomorrow.getDate() + 1);
|
||||||
|
tomorrow.setHours(0, 0, 0, 0);
|
||||||
|
|
||||||
|
if (renewalDate < tomorrow) {
|
||||||
|
createNotification({
|
||||||
|
text: "The renewal date cannot be set to today or any past date. Renewals can only be scheduled from tomorrow onwards.",
|
||||||
|
type: "error"
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
await updateRenewalConfig({
|
||||||
|
certificateId: certificateData.certificateId,
|
||||||
|
renewBeforeDays: data.renewBeforeDays,
|
||||||
|
projectSlug: currentProject.slug
|
||||||
|
});
|
||||||
|
|
||||||
|
createNotification({
|
||||||
|
text: isAutoRenewalEnabled
|
||||||
|
? "Auto-renewal configuration updated successfully"
|
||||||
|
: "Auto-renewal enabled successfully",
|
||||||
|
type: "success"
|
||||||
|
});
|
||||||
|
|
||||||
|
handlePopUpToggle("manageRenewal", false);
|
||||||
|
} catch (err) {
|
||||||
|
console.error(err);
|
||||||
|
createNotification({
|
||||||
|
text: isAutoRenewalEnabled
|
||||||
|
? "Failed to update auto-renewal configuration. Please check your inputs and try again."
|
||||||
|
: "Failed to enable auto-renewal. Please check your inputs and try again.",
|
||||||
|
type: "error"
|
||||||
|
});
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const isLoading = isUpdatingConfig;
|
||||||
|
|
||||||
|
const getModalTitle = () => {
|
||||||
|
if (hasRenewalError) {
|
||||||
|
return `Fix Auto-Renewal: ${certificateData?.commonName || ""}`;
|
||||||
|
}
|
||||||
|
if (isAutoRenewalEnabled) {
|
||||||
|
return `Manage Auto-Renewal for ${certificateData?.commonName || ""}`;
|
||||||
|
}
|
||||||
|
return `Enable Auto-Renewal for ${certificateData?.commonName || ""}`;
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Modal
|
||||||
|
isOpen={popUp?.manageRenewal?.isOpen}
|
||||||
|
onOpenChange={(isOpen) => {
|
||||||
|
handlePopUpToggle("manageRenewal", isOpen);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<ModalContent title={getModalTitle()}>
|
||||||
|
{/* Show renewal error if present */}
|
||||||
|
{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>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Configuration form - shown for all cases except when enabled and no error */}
|
||||||
|
{(!isAutoRenewalEnabled || hasRenewalError) && (
|
||||||
|
<form onSubmit={handleSubmit(onUpdateRenewal)}>
|
||||||
|
<FormControl
|
||||||
|
label="Renewal Days Before Expiration"
|
||||||
|
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={() => handlePopUpToggle("manageRenewal", false)}
|
||||||
|
>
|
||||||
|
Cancel
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
type="submit"
|
||||||
|
colorSchema="primary"
|
||||||
|
isLoading={isUpdatingConfig}
|
||||||
|
isDisabled={isLoading}
|
||||||
|
>
|
||||||
|
{isAutoRenewalEnabled ? "Update Configuration" : "Enable Auto-Renewal"}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Show edit form for enabled auto-renewal without errors */}
|
||||||
|
{isAutoRenewalEnabled && !hasRenewalError && (
|
||||||
|
<form onSubmit={handleSubmit(onUpdateRenewal)}>
|
||||||
|
<FormControl
|
||||||
|
label="Renewal Days Before Expiration"
|
||||||
|
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={() => handlePopUpToggle("manageRenewal", false)}
|
||||||
|
>
|
||||||
|
Cancel
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
type="submit"
|
||||||
|
colorSchema="primary"
|
||||||
|
isLoading={isUpdatingConfig}
|
||||||
|
isDisabled={isLoading}
|
||||||
|
>
|
||||||
|
Update Configuration
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
)}
|
||||||
|
</ModalContent>
|
||||||
|
</Modal>
|
||||||
|
);
|
||||||
|
};
|
||||||
+177
@@ -0,0 +1,177 @@
|
|||||||
|
import { Controller, useForm } from "react-hook-form";
|
||||||
|
import { zodResolver } from "@hookform/resolvers/zod";
|
||||||
|
import { z } from "zod";
|
||||||
|
|
||||||
|
import { createNotification } from "@app/components/notifications";
|
||||||
|
import { Button, FormControl, Input, Modal, ModalContent } from "@app/components/v2";
|
||||||
|
import { useProject } from "@app/context";
|
||||||
|
import { useUpdateRenewalConfig } from "@app/hooks/api";
|
||||||
|
import { UsePopUpState } from "@app/hooks/usePopUp";
|
||||||
|
|
||||||
|
const formSchema = z.object({
|
||||||
|
renewBeforeDays: z
|
||||||
|
.number()
|
||||||
|
.min(1, "Renewal days must be at least 1")
|
||||||
|
.max(365, "Renewal days cannot exceed 365")
|
||||||
|
});
|
||||||
|
|
||||||
|
type FormData = z.infer<typeof formSchema>;
|
||||||
|
|
||||||
|
type Props = {
|
||||||
|
popUp: UsePopUpState<["configureRenewal"]>;
|
||||||
|
handlePopUpToggle: (
|
||||||
|
popUpName: keyof UsePopUpState<["configureRenewal"]>,
|
||||||
|
state?: boolean
|
||||||
|
) => void;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const CertificateRenewalConfigModal = ({ popUp, handlePopUpToggle }: Props) => {
|
||||||
|
const { currentProject } = useProject();
|
||||||
|
const { mutateAsync: updateRenewalConfig, isPending: isSubmitting } = useUpdateRenewalConfig();
|
||||||
|
|
||||||
|
const certificateData = popUp.configureRenewal.data as {
|
||||||
|
certificateId: string;
|
||||||
|
commonName: string;
|
||||||
|
profileId: string;
|
||||||
|
renewBeforeDays?: number;
|
||||||
|
ttlDays: number;
|
||||||
|
};
|
||||||
|
|
||||||
|
const {
|
||||||
|
control,
|
||||||
|
handleSubmit,
|
||||||
|
formState: { errors },
|
||||||
|
watch
|
||||||
|
} = useForm<FormData>({
|
||||||
|
resolver: zodResolver(formSchema),
|
||||||
|
defaultValues: {
|
||||||
|
renewBeforeDays: certificateData?.renewBeforeDays || 7
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
const renewBeforeDays = watch("renewBeforeDays");
|
||||||
|
|
||||||
|
const onSubmit = async (data: FormData) => {
|
||||||
|
try {
|
||||||
|
if (data.renewBeforeDays >= certificateData.ttlDays) {
|
||||||
|
createNotification({
|
||||||
|
text: `Renewal days (${data.renewBeforeDays}) must be less than certificate TTL (${certificateData.ttlDays} days)`,
|
||||||
|
type: "error"
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!currentProject?.slug) {
|
||||||
|
createNotification({
|
||||||
|
text: "Project not found",
|
||||||
|
type: "error"
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
await updateRenewalConfig({
|
||||||
|
certificateId: certificateData.certificateId,
|
||||||
|
renewBeforeDays: data.renewBeforeDays,
|
||||||
|
projectSlug: currentProject.slug
|
||||||
|
});
|
||||||
|
|
||||||
|
createNotification({
|
||||||
|
text: "Successfully updated auto-renewal configuration",
|
||||||
|
type: "success"
|
||||||
|
});
|
||||||
|
|
||||||
|
handlePopUpToggle("configureRenewal", false);
|
||||||
|
} catch (err) {
|
||||||
|
console.error(err);
|
||||||
|
createNotification({
|
||||||
|
text: "Failed to update auto-renewal configuration",
|
||||||
|
type: "error"
|
||||||
|
});
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<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 || 0)
|
||||||
|
? "⚠️ 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>
|
||||||
|
);
|
||||||
|
};
|
||||||
+94
@@ -0,0 +1,94 @@
|
|||||||
|
import { createNotification } from "@app/components/notifications";
|
||||||
|
import { Button, Modal, ModalContent } from "@app/components/v2";
|
||||||
|
import { useProject } from "@app/context";
|
||||||
|
import { useUpdateRenewalConfig } from "@app/hooks/api";
|
||||||
|
import { UsePopUpState } from "@app/hooks/usePopUp";
|
||||||
|
|
||||||
|
type Props = {
|
||||||
|
popUp: UsePopUpState<["disableRenewal"]>;
|
||||||
|
handlePopUpToggle: (popUpName: keyof UsePopUpState<["disableRenewal"]>, state?: boolean) => void;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const CertificateRenewalDisableModal = ({ popUp, handlePopUpToggle }: Props) => {
|
||||||
|
const { currentProject } = useProject();
|
||||||
|
const { mutateAsync: updateRenewalConfig, isPending: isSubmitting } = useUpdateRenewalConfig();
|
||||||
|
|
||||||
|
const certificateData = popUp.disableRenewal.data as {
|
||||||
|
certificateId: string;
|
||||||
|
commonName: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
const onDisableConfirm = async () => {
|
||||||
|
try {
|
||||||
|
if (!currentProject?.slug) {
|
||||||
|
createNotification({
|
||||||
|
text: "Project not found",
|
||||||
|
type: "error"
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
await updateRenewalConfig({
|
||||||
|
certificateId: certificateData.certificateId,
|
||||||
|
projectSlug: currentProject.slug,
|
||||||
|
disableAutoRenewal: true
|
||||||
|
});
|
||||||
|
|
||||||
|
createNotification({
|
||||||
|
text: "Successfully disabled auto-renewal",
|
||||||
|
type: "success"
|
||||||
|
});
|
||||||
|
|
||||||
|
handlePopUpToggle("disableRenewal", false);
|
||||||
|
} catch (err) {
|
||||||
|
console.error(err);
|
||||||
|
createNotification({
|
||||||
|
text: "Failed to disable auto-renewal",
|
||||||
|
type: "error"
|
||||||
|
});
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<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>
|
||||||
|
);
|
||||||
|
};
|
||||||
+80
@@ -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,
|
||||||
@@ -35,23 +38,93 @@ import {
|
|||||||
ProjectPermissionSub,
|
ProjectPermissionSub,
|
||||||
useProject
|
useProject
|
||||||
} 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.renewedById) {
|
||||||
|
return { text: "Renewed", variant: "success" as const };
|
||||||
|
}
|
||||||
|
|
||||||
|
const isRevoked = certificate.status === CertStatus.REVOKED;
|
||||||
|
const isExpired = new Date(certificate.notAfter) < new Date();
|
||||||
|
const hasNoProfile = !certificate.profileId;
|
||||||
|
const isExpiringWithinDay = isExpiringWithinOneDay(certificate.notAfter);
|
||||||
|
|
||||||
|
if (isRevoked || isExpired || hasNoProfile || isExpiringWithinDay) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (certificate.renewalError) {
|
||||||
|
return {
|
||||||
|
text: "Failed",
|
||||||
|
variant: "danger" as const,
|
||||||
|
tooltip: certificate.renewalError
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!certificate.renewBeforeDays) {
|
||||||
|
return { text: "Disabled", variant: "primary" as const };
|
||||||
|
}
|
||||||
|
|
||||||
|
const notAfterDate = new Date(certificate.notAfter);
|
||||||
|
const renewalDate = new Date(
|
||||||
|
notAfterDate.getTime() - certificate.renewBeforeDays * 24 * 60 * 60 * 1000
|
||||||
|
);
|
||||||
|
const now = new Date();
|
||||||
|
|
||||||
|
if (renewalDate <= now) {
|
||||||
|
return { text: "Due Now", variant: "danger" as const };
|
||||||
|
}
|
||||||
|
|
||||||
|
const daysUntilRenewal = Math.ceil(
|
||||||
|
(renewalDate.getTime() - now.getTime()) / (24 * 60 * 60 * 1000)
|
||||||
|
);
|
||||||
|
|
||||||
|
if (daysUntilRenewal <= 7) {
|
||||||
|
return { text: `Renews in ${daysUntilRenewal}d`, variant: "primary" as const };
|
||||||
|
}
|
||||||
|
|
||||||
|
return { text: `Renews in ${daysUntilRenewal}d`, variant: "success" as const };
|
||||||
|
};
|
||||||
|
|
||||||
type Props = {
|
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;
|
||||||
|
renewedFromId?: string;
|
||||||
|
renewedById?: string;
|
||||||
}
|
}
|
||||||
) => void;
|
) => void;
|
||||||
};
|
};
|
||||||
@@ -69,10 +142,10 @@ export const CertificatesTable = ({ handlePopUpOpen }: Props) => {
|
|||||||
limit: perPage
|
limit: perPage
|
||||||
});
|
});
|
||||||
|
|
||||||
// Fetch CA data to determine capabilities
|
const { mutateAsync: updateRenewalConfig } = useUpdateRenewalConfig();
|
||||||
|
|
||||||
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 +156,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,
|
||||||
|
disableAutoRenewal: true
|
||||||
|
});
|
||||||
|
|
||||||
|
createNotification({
|
||||||
|
text: `Auto-renewal disabled for ${commonName}`,
|
||||||
|
type: "success"
|
||||||
|
});
|
||||||
|
} catch (err) {
|
||||||
|
console.error(err);
|
||||||
|
createNotification({
|
||||||
|
text: "Failed to disable auto-renewal. Please try again or contact support if the issue persists.",
|
||||||
|
type: "error"
|
||||||
|
});
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<TableContainer>
|
<TableContainer>
|
||||||
<Table>
|
<Table>
|
||||||
@@ -92,14 +194,16 @@ 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>Auto Renewal</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);
|
||||||
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 +224,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">
|
||||||
@@ -172,10 +295,154 @@ export const CertificatesTable = ({ handlePopUpOpen }: Props) => {
|
|||||||
</DropdownMenuItem>
|
</DropdownMenuItem>
|
||||||
)}
|
)}
|
||||||
</ProjectPermissionCan>
|
</ProjectPermissionCan>
|
||||||
|
{/* Manage auto renewal option - not shown for failed renewals */}
|
||||||
|
{(() => {
|
||||||
|
const isRevoked = certificate.status === CertStatus.REVOKED;
|
||||||
|
const isExpired = new Date(certificate.notAfter) < new Date();
|
||||||
|
const hasFailed = Boolean(certificate.renewalError);
|
||||||
|
const isExpiringWithinDay = isExpiringWithinOneDay(certificate.notAfter);
|
||||||
|
const canManageRenewal =
|
||||||
|
certificate.profileId &&
|
||||||
|
!certificate.renewedById &&
|
||||||
|
!isRevoked &&
|
||||||
|
!isExpired &&
|
||||||
|
!hasFailed &&
|
||||||
|
!isExpiringWithinDay;
|
||||||
|
|
||||||
|
if (!canManageRenewal) return null;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<ProjectPermissionCan
|
||||||
|
I={ProjectPermissionCertificateActions.Edit}
|
||||||
|
a={ProjectPermissionSub.Certificates}
|
||||||
|
>
|
||||||
|
{(isAllowed) => {
|
||||||
|
const isAutoRenewalEnabled = Boolean(
|
||||||
|
certificate.renewBeforeDays && certificate.renewBeforeDays > 0
|
||||||
|
);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<DropdownMenuItem
|
||||||
|
className={twMerge(
|
||||||
|
!isAllowed &&
|
||||||
|
"pointer-events-none cursor-not-allowed opacity-50"
|
||||||
|
)}
|
||||||
|
onClick={async () => {
|
||||||
|
const notAfterDate = new Date(certificate.notAfter);
|
||||||
|
const notBeforeDate = new Date(certificate.notBefore);
|
||||||
|
const ttlDays = Math.ceil(
|
||||||
|
(notAfterDate.getTime() - notBeforeDate.getTime()) /
|
||||||
|
(24 * 60 * 60 * 1000)
|
||||||
|
);
|
||||||
|
handlePopUpOpen("manageRenewal", {
|
||||||
|
certificateId: certificate.id,
|
||||||
|
commonName: certificate.commonName,
|
||||||
|
profileId: certificate.profileId,
|
||||||
|
renewBeforeDays: certificate.renewBeforeDays,
|
||||||
|
ttlDays,
|
||||||
|
notAfter: certificate.notAfter,
|
||||||
|
renewalError: certificate.renewalError,
|
||||||
|
renewedFromId: certificate.renewedFromId,
|
||||||
|
renewedById: certificate.renewedById
|
||||||
|
});
|
||||||
|
}}
|
||||||
|
disabled={!isAllowed}
|
||||||
|
icon={<FontAwesomeIcon icon={faRedo} />}
|
||||||
|
>
|
||||||
|
{isAutoRenewalEnabled
|
||||||
|
? "Manage auto renewal"
|
||||||
|
: "Enable auto renewal"}
|
||||||
|
</DropdownMenuItem>
|
||||||
|
);
|
||||||
|
}}
|
||||||
|
</ProjectPermissionCan>
|
||||||
|
);
|
||||||
|
})()}
|
||||||
|
{/* Disable auto renewal option - only shown when auto renewal is active */}
|
||||||
|
{(() => {
|
||||||
|
const isRevoked = certificate.status === CertStatus.REVOKED;
|
||||||
|
const isExpired = new Date(certificate.notAfter) < new Date();
|
||||||
|
const isExpiringWithinDay = isExpiringWithinOneDay(certificate.notAfter);
|
||||||
|
const isAutoRenewalEnabled = Boolean(
|
||||||
|
certificate.renewBeforeDays && certificate.renewBeforeDays > 0
|
||||||
|
);
|
||||||
|
const canDisableRenewal =
|
||||||
|
certificate.profileId &&
|
||||||
|
!certificate.renewedById &&
|
||||||
|
!isRevoked &&
|
||||||
|
!isExpired &&
|
||||||
|
!isExpiringWithinDay &&
|
||||||
|
isAutoRenewalEnabled;
|
||||||
|
|
||||||
|
if (!canDisableRenewal) return null;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<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 isRevoked = certificate.status === CertStatus.REVOKED;
|
||||||
|
const isExpired = new Date(certificate.notAfter) < new Date();
|
||||||
|
const canRenew =
|
||||||
|
certificate.profileId &&
|
||||||
|
!certificate.renewedById &&
|
||||||
|
!isRevoked &&
|
||||||
|
!isExpired;
|
||||||
|
|
||||||
|
if (!canRenew) return null;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<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);
|
||||||
|
|||||||
+25
-13
@@ -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}
|
||||||
>
|
>
|
||||||
|
|||||||
Reference in New Issue
Block a user