diff --git a/backend/src/@types/fastify.d.ts b/backend/src/@types/fastify.d.ts index be484b5fda..30bdc71123 100644 --- a/backend/src/@types/fastify.d.ts +++ b/backend/src/@types/fastify.d.ts @@ -101,6 +101,7 @@ import { TOfflineUsageReportServiceFactory } from "@app/services/offline-usage-r import { TOrgServiceFactory } from "@app/services/org/org-service"; import { TOrgAdminServiceFactory } from "@app/services/org-admin/org-admin-service"; import { TPkiAlertServiceFactory } from "@app/services/pki-alert/pki-alert-service"; +import { TPkiAlertV2ServiceFactory } from "@app/services/pki-alert-v2/pki-alert-v2-service"; import { TPkiCollectionServiceFactory } from "@app/services/pki-collection/pki-collection-service"; import { TPkiSubscriberServiceFactory } from "@app/services/pki-subscriber/pki-subscriber-service"; import { TPkiSyncServiceFactory } from "@app/services/pki-sync/pki-sync-service"; @@ -355,6 +356,7 @@ declare module "fastify" { role: TRoleServiceFactory; convertor: TConvertorServiceFactory; subOrganization: TSubOrgServiceFactory; + pkiAlertV2: TPkiAlertV2ServiceFactory; }; // this is exclusive use for middlewares in which we need to inject data // everywhere else access using service layer diff --git a/backend/src/@types/knex.d.ts b/backend/src/@types/knex.d.ts index 580da47495..603df5f6ca 100644 --- a/backend/src/@types/knex.d.ts +++ b/backend/src/@types/knex.d.ts @@ -284,9 +284,21 @@ import { TPkiAcmeOrders, TPkiAcmeOrdersInsert, TPkiAcmeOrdersUpdate, + TPkiAlertChannels, + TPkiAlertChannelsInsert, + TPkiAlertChannelsUpdate, + TPkiAlertHistory, + TPkiAlertHistoryCertificate, + TPkiAlertHistoryCertificateInsert, + TPkiAlertHistoryCertificateUpdate, + TPkiAlertHistoryInsert, + TPkiAlertHistoryUpdate, TPkiAlerts, TPkiAlertsInsert, TPkiAlertsUpdate, + TPkiAlertsV2, + TPkiAlertsV2Insert, + TPkiAlertsV2Update, TPkiApiEnrollmentConfigs, TPkiApiEnrollmentConfigsInsert, TPkiApiEnrollmentConfigsUpdate, @@ -769,6 +781,22 @@ declare module "knex/types/tables" { TCertificateSecretsUpdate >; [TableName.PkiAlert]: KnexOriginal.CompositeTableType; + [TableName.PkiAlertsV2]: KnexOriginal.CompositeTableType; + [TableName.PkiAlertChannels]: KnexOriginal.CompositeTableType< + TPkiAlertChannels, + TPkiAlertChannelsInsert, + TPkiAlertChannelsUpdate + >; + [TableName.PkiAlertHistory]: KnexOriginal.CompositeTableType< + TPkiAlertHistory, + TPkiAlertHistoryInsert, + TPkiAlertHistoryUpdate + >; + [TableName.PkiAlertHistoryCertificate]: KnexOriginal.CompositeTableType< + TPkiAlertHistoryCertificate, + TPkiAlertHistoryCertificateInsert, + TPkiAlertHistoryCertificateUpdate + >; [TableName.PkiCollection]: KnexOriginal.CompositeTableType< TPkiCollections, TPkiCollectionsInsert, diff --git a/backend/src/db/migrations/20251103120000_add-pki-alerts-v2.ts b/backend/src/db/migrations/20251103120000_add-pki-alerts-v2.ts new file mode 100644 index 0000000000..d542c2278c --- /dev/null +++ b/backend/src/db/migrations/20251103120000_add-pki-alerts-v2.ts @@ -0,0 +1,87 @@ +import { Knex } from "knex"; + +import { TableName } from "../schemas"; + +export async function up(knex: Knex): Promise { + if (!(await knex.schema.hasTable(TableName.PkiAlertsV2))) { + await knex.schema.createTable(TableName.PkiAlertsV2, (t) => { + t.uuid("id", { primaryKey: true }).defaultTo(knex.fn.uuid()); + t.string("name").notNullable(); + t.text("description").nullable(); + t.string("eventType").notNullable(); + t.string("alertBefore").nullable(); + t.jsonb("filters").nullable(); + t.boolean("enabled").defaultTo(true); + t.string("projectId").notNullable(); + t.timestamps(true, true, true); + + t.foreign("projectId").references("id").inTable(TableName.Project).onDelete("CASCADE"); + t.index("projectId"); + t.unique(["name", "projectId"]); + }); + } + + if (!(await knex.schema.hasTable(TableName.PkiAlertChannels))) { + await knex.schema.createTable(TableName.PkiAlertChannels, (t) => { + t.uuid("id", { primaryKey: true }).defaultTo(knex.fn.uuid()); + t.uuid("alertId").notNullable(); + t.string("channelType").notNullable(); + t.jsonb("config").notNullable(); + t.boolean("enabled").defaultTo(true); + t.timestamps(true, true, true); + + t.foreign("alertId").references("id").inTable(TableName.PkiAlertsV2).onDelete("CASCADE"); + t.index("alertId"); + t.index("channelType"); + t.index("enabled"); + }); + } + + if (!(await knex.schema.hasTable(TableName.PkiAlertHistory))) { + await knex.schema.createTable(TableName.PkiAlertHistory, (t) => { + t.uuid("id", { primaryKey: true }).defaultTo(knex.fn.uuid()); + t.uuid("alertId").notNullable(); + t.timestamp("triggeredAt").defaultTo(knex.fn.now()); + t.boolean("hasNotificationSent").defaultTo(false); + t.text("notificationError").nullable(); + t.timestamps(true, true, true); + + t.foreign("alertId").references("id").inTable(TableName.PkiAlertsV2).onDelete("CASCADE"); + t.index("alertId"); + t.index("triggeredAt"); + }); + } + + if (!(await knex.schema.hasTable(TableName.PkiAlertHistoryCertificate))) { + await knex.schema.createTable(TableName.PkiAlertHistoryCertificate, (t) => { + t.uuid("id", { primaryKey: true }).defaultTo(knex.fn.uuid()); + t.uuid("alertHistoryId").notNullable(); + t.uuid("certificateId").notNullable(); + t.timestamps(true, true, true); + + t.foreign("alertHistoryId").references("id").inTable(TableName.PkiAlertHistory).onDelete("CASCADE"); + t.foreign("certificateId").references("id").inTable(TableName.Certificate).onDelete("CASCADE"); + t.index("alertHistoryId"); + t.index("certificateId"); + t.unique(["alertHistoryId", "certificateId"]); + }); + } +} + +export async function down(knex: Knex): Promise { + if (await knex.schema.hasTable(TableName.PkiAlertHistoryCertificate)) { + await knex.schema.dropTable(TableName.PkiAlertHistoryCertificate); + } + + if (await knex.schema.hasTable(TableName.PkiAlertHistory)) { + await knex.schema.dropTable(TableName.PkiAlertHistory); + } + + if (await knex.schema.hasTable(TableName.PkiAlertChannels)) { + await knex.schema.dropTable(TableName.PkiAlertChannels); + } + + if (await knex.schema.hasTable(TableName.PkiAlertsV2)) { + await knex.schema.dropTable(TableName.PkiAlertsV2); + } +} diff --git a/backend/src/db/schemas/index.ts b/backend/src/db/schemas/index.ts index 3513b95a73..78dcb1980c 100644 --- a/backend/src/db/schemas/index.ts +++ b/backend/src/db/schemas/index.ts @@ -98,7 +98,11 @@ export * from "./pki-acme-challenges"; export * from "./pki-acme-enrollment-configs"; export * from "./pki-acme-order-auths"; export * from "./pki-acme-orders"; +export * from "./pki-alert-channels"; +export * from "./pki-alert-history"; +export * from "./pki-alert-history-certificate"; export * from "./pki-alerts"; +export * from "./pki-alerts-v2"; export * from "./pki-api-enrollment-configs"; export * from "./pki-certificate-profiles"; export * from "./pki-certificate-templates-v2"; diff --git a/backend/src/db/schemas/models.ts b/backend/src/db/schemas/models.ts index 1170138a38..444a6bd97d 100644 --- a/backend/src/db/schemas/models.ts +++ b/backend/src/db/schemas/models.ts @@ -30,6 +30,10 @@ export enum TableName { PkiAcmeEnrollmentConfig = "pki_acme_enrollment_configs", PkiSubscriber = "pki_subscribers", PkiAlert = "pki_alerts", + PkiAlertsV2 = "pki_alerts_v2", + PkiAlertChannels = "pki_alert_channels", + PkiAlertHistory = "pki_alert_history", + PkiAlertHistoryCertificate = "pki_alert_history_certificate", PkiCollection = "pki_collections", PkiCollectionItem = "pki_collection_items", Groups = "groups", diff --git a/backend/src/db/schemas/pki-alert-channels.ts b/backend/src/db/schemas/pki-alert-channels.ts new file mode 100644 index 0000000000..1101a96071 --- /dev/null +++ b/backend/src/db/schemas/pki-alert-channels.ts @@ -0,0 +1,22 @@ +// Code generated by automation script, DO NOT EDIT. +// Automated by pulling database and generating zod schema +// To update. Just run npm run generate:schema +// Written by akhilmhdh. + +import { z } from "zod"; + +import { TImmutableDBKeys } from "./models"; + +export const PkiAlertChannelsSchema = z.object({ + id: z.string().uuid(), + alertId: z.string().uuid(), + channelType: z.string(), + config: z.unknown(), + enabled: z.boolean().default(true).nullable().optional(), + createdAt: z.date(), + updatedAt: z.date() +}); + +export type TPkiAlertChannels = z.infer; +export type TPkiAlertChannelsInsert = Omit, TImmutableDBKeys>; +export type TPkiAlertChannelsUpdate = Partial, TImmutableDBKeys>>; diff --git a/backend/src/db/schemas/pki-alert-history-certificate.ts b/backend/src/db/schemas/pki-alert-history-certificate.ts new file mode 100644 index 0000000000..f953b9a484 --- /dev/null +++ b/backend/src/db/schemas/pki-alert-history-certificate.ts @@ -0,0 +1,25 @@ +// Code generated by automation script, DO NOT EDIT. +// Automated by pulling database and generating zod schema +// To update. Just run npm run generate:schema +// Written by akhilmhdh. + +import { z } from "zod"; + +import { TImmutableDBKeys } from "./models"; + +export const PkiAlertHistoryCertificateSchema = z.object({ + id: z.string().uuid(), + alertHistoryId: z.string().uuid(), + certificateId: z.string().uuid(), + createdAt: z.date(), + updatedAt: z.date() +}); + +export type TPkiAlertHistoryCertificate = z.infer; +export type TPkiAlertHistoryCertificateInsert = Omit< + z.input, + TImmutableDBKeys +>; +export type TPkiAlertHistoryCertificateUpdate = Partial< + Omit, TImmutableDBKeys> +>; diff --git a/backend/src/db/schemas/pki-alert-history.ts b/backend/src/db/schemas/pki-alert-history.ts new file mode 100644 index 0000000000..20af7b7574 --- /dev/null +++ b/backend/src/db/schemas/pki-alert-history.ts @@ -0,0 +1,22 @@ +// Code generated by automation script, DO NOT EDIT. +// Automated by pulling database and generating zod schema +// To update. Just run npm run generate:schema +// Written by akhilmhdh. + +import { z } from "zod"; + +import { TImmutableDBKeys } from "./models"; + +export const PkiAlertHistorySchema = z.object({ + id: z.string().uuid(), + alertId: z.string().uuid(), + triggeredAt: z.date().nullable().optional(), + hasNotificationSent: z.boolean().default(false).nullable().optional(), + notificationError: z.string().nullable().optional(), + createdAt: z.date(), + updatedAt: z.date() +}); + +export type TPkiAlertHistory = z.infer; +export type TPkiAlertHistoryInsert = Omit, TImmutableDBKeys>; +export type TPkiAlertHistoryUpdate = Partial, TImmutableDBKeys>>; diff --git a/backend/src/db/schemas/pki-alerts-v2.ts b/backend/src/db/schemas/pki-alerts-v2.ts new file mode 100644 index 0000000000..cbb28220c5 --- /dev/null +++ b/backend/src/db/schemas/pki-alerts-v2.ts @@ -0,0 +1,25 @@ +// Code generated by automation script, DO NOT EDIT. +// Automated by pulling database and generating zod schema +// To update. Just run npm run generate:schema +// Written by akhilmhdh. + +import { z } from "zod"; + +import { TImmutableDBKeys } from "./models"; + +export const PkiAlertsV2Schema = z.object({ + id: z.string().uuid(), + name: z.string(), + description: z.string().nullable().optional(), + eventType: z.string(), + alertBefore: z.string().nullable().optional(), + filters: z.unknown().nullable().optional(), + enabled: z.boolean().default(true).nullable().optional(), + projectId: z.string(), + createdAt: z.date(), + updatedAt: z.date() +}); + +export type TPkiAlertsV2 = z.infer; +export type TPkiAlertsV2Insert = Omit, TImmutableDBKeys>; +export type TPkiAlertsV2Update = Partial, TImmutableDBKeys>>; diff --git a/backend/src/ee/services/audit-log/audit-log-types.ts b/backend/src/ee/services/audit-log/audit-log-types.ts index c6ac6ff3b5..0b45544e3f 100644 --- a/backend/src/ee/services/audit-log/audit-log-types.ts +++ b/backend/src/ee/services/audit-log/audit-log-types.ts @@ -36,6 +36,7 @@ import { CertExtendedKeyUsage, CertKeyAlgorithm, CertKeyUsage } from "@app/servi import { CaStatus } from "@app/services/certificate-authority/certificate-authority-enums"; import { TIdentityTrustedIp } from "@app/services/identity/identity-types"; import { TAllowedFields } from "@app/services/identity-ldap-auth/identity-ldap-auth-types"; +import { PkiAlertEventType } from "@app/services/pki-alert-v2/pki-alert-v2-types"; import { PkiItemType } from "@app/services/pki-collection/pki-collection-types"; import { SecretSync, SecretSyncImportBehavior } from "@app/services/secret-sync/secret-sync-enums"; import { @@ -2318,10 +2319,11 @@ interface CreatePkiAlert { type: EventType.CREATE_PKI_ALERT; metadata: { pkiAlertId: string; - pkiCollectionId: string; + pkiCollectionId?: string; name: string; - alertBeforeDays: number; - recipientEmails: string; + alertBefore: string; + eventType: PkiAlertEventType; + recipientEmails?: string; }; } interface GetPkiAlert { @@ -2337,7 +2339,8 @@ interface UpdatePkiAlert { pkiAlertId: string; pkiCollectionId?: string; name?: string; - alertBeforeDays?: number; + alertBefore?: string; + eventType?: PkiAlertEventType; recipientEmails?: string; }; } diff --git a/backend/src/queue/queue-service.ts b/backend/src/queue/queue-service.ts index 3e6b1dd190..8cf8555f99 100644 --- a/backend/src/queue/queue-service.ts +++ b/backend/src/queue/queue-service.ts @@ -51,6 +51,7 @@ export enum QueueName { AuditLogPrune = "audit-log-prune", DailyResourceCleanUp = "daily-resource-cleanup", DailyExpiringPkiItemAlert = "daily-expiring-pki-item-alert", + DailyPkiAlertV2Processing = "daily-pki-alert-v2-processing", PkiSyncCleanup = "pki-sync-cleanup", PkiSubscriber = "pki-subscriber", TelemetryInstanceStats = "telemtry-self-hosted-stats", @@ -90,6 +91,7 @@ export enum QueueJobs { AuditLogPrune = "audit-log-prune-job", DailyResourceCleanUp = "daily-resource-cleanup-job", DailyExpiringPkiItemAlert = "daily-expiring-pki-item-alert", + DailyPkiAlertV2Processing = "daily-pki-alert-v2-processing", PkiSyncCleanup = "pki-sync-cleanup-job", SecWebhook = "secret-webhook-trigger", TelemetryInstanceStats = "telemetry-self-hosted-stats", @@ -159,6 +161,10 @@ export type TQueueJobTypes = { name: QueueJobs.DailyExpiringPkiItemAlert; payload: undefined; }; + [QueueName.DailyPkiAlertV2Processing]: { + name: QueueJobs.DailyPkiAlertV2Processing; + payload: undefined; + }; [QueueName.PkiSyncCleanup]: { name: QueueJobs.PkiSyncCleanup; payload: undefined; diff --git a/backend/src/server/routes/index.ts b/backend/src/server/routes/index.ts index 961c0a940f..70146c8543 100644 --- a/backend/src/server/routes/index.ts +++ b/backend/src/server/routes/index.ts @@ -272,6 +272,11 @@ import { pamAccountRotationServiceFactory } from "@app/services/pam-account-rota import { dailyExpiringPkiItemAlertQueueServiceFactory } from "@app/services/pki-alert/expiring-pki-item-alert-queue"; import { pkiAlertDALFactory } from "@app/services/pki-alert/pki-alert-dal"; import { pkiAlertServiceFactory } from "@app/services/pki-alert/pki-alert-service"; +import { pkiAlertChannelDALFactory } from "@app/services/pki-alert-v2/pki-alert-channel-dal"; +import { pkiAlertHistoryDALFactory } from "@app/services/pki-alert-v2/pki-alert-history-dal"; +import { pkiAlertV2DALFactory } from "@app/services/pki-alert-v2/pki-alert-v2-dal"; +import { pkiAlertV2QueueServiceFactory } from "@app/services/pki-alert-v2/pki-alert-v2-queue"; +import { pkiAlertV2ServiceFactory } from "@app/services/pki-alert-v2/pki-alert-v2-service"; import { pkiCollectionDALFactory } from "@app/services/pki-collection/pki-collection-dal"; import { pkiCollectionItemDALFactory } from "@app/services/pki-collection/pki-collection-item-dal"; import { pkiCollectionServiceFactory } from "@app/services/pki-collection/pki-collection-service"; @@ -555,6 +560,9 @@ export const registerRoutes = async ( const additionalPrivilegeDAL = additionalPrivilegeDALFactory(db); const membershipRoleDAL = membershipRoleDALFactory(db); const roleDAL = roleDALFactory(db); + const pkiAlertHistoryDAL = pkiAlertHistoryDALFactory(db); + const pkiAlertChannelDAL = pkiAlertChannelDALFactory(db); + const pkiAlertV2DAL = pkiAlertV2DALFactory(db); const vaultExternalMigrationConfigDAL = vaultExternalMigrationConfigDALFactory(db); @@ -1818,6 +1826,21 @@ export const registerRoutes = async ( groupDAL }); + const pkiAlertV2Service = pkiAlertV2ServiceFactory({ + pkiAlertV2DAL, + pkiAlertChannelDAL, + pkiAlertHistoryDAL, + permissionService, + smtpService + }); + + const pkiAlertV2Queue = pkiAlertV2QueueServiceFactory({ + queueService, + pkiAlertV2Service, + pkiAlertV2DAL, + pkiAlertHistoryDAL + }); + const dynamicSecretProviders = buildDynamicSecretProviders({ gatewayService, gatewayV2Service @@ -2391,6 +2414,7 @@ export const registerRoutes = async ( await dailyReminderQueueService.startSecretReminderMigrationJob(); await dailyExpiringPkiItemAlert.startSendingAlerts(); await pkiSubscriberQueue.startDailyAutoRenewalJob(); + await pkiAlertV2Queue.init(); await certificateV3Queue.init(); await kmsService.startService(hsmStatus); await microsoftTeamsService.start(); @@ -2523,7 +2547,8 @@ export const registerRoutes = async ( role: roleService, additionalPrivilege: additionalPrivilegeService, identityProject: identityProjectService, - convertor: convertorService + convertor: convertorService, + pkiAlertV2: pkiAlertV2Service }); const cronJobs: CronJob[] = []; diff --git a/backend/src/server/routes/v1/pki-alert-router.ts b/backend/src/server/routes/v1/pki-alert-router.ts index 43ce91e887..fde1799817 100644 --- a/backend/src/server/routes/v1/pki-alert-router.ts +++ b/backend/src/server/routes/v1/pki-alert-router.ts @@ -6,6 +6,7 @@ import { ALERTS, ApiDocsTags } from "@app/lib/api-docs"; import { readLimit, writeLimit } from "@app/server/config/rateLimiter"; import { verifyAuth } from "@app/server/plugins/auth/verify-auth"; import { AuthMode } from "@app/services/auth/auth-type"; +import { PkiAlertEventType } from "@app/services/pki-alert-v2/pki-alert-v2-types"; export const registerPkiAlertRouter = async (server: FastifyZodProvider) => { server.route({ @@ -52,7 +53,8 @@ export const registerPkiAlertRouter = async (server: FastifyZodProvider) => { pkiAlertId: alert.id, pkiCollectionId: alert.pkiCollectionId, name: alert.name, - alertBeforeDays: alert.alertBeforeDays, + alertBefore: alert.alertBeforeDays.toString(), + eventType: PkiAlertEventType.EXPIRATION, recipientEmails: alert.recipientEmails } } @@ -152,7 +154,8 @@ export const registerPkiAlertRouter = async (server: FastifyZodProvider) => { pkiAlertId: alert.id, pkiCollectionId: alert.pkiCollectionId, name: alert.name, - alertBeforeDays: alert.alertBeforeDays, + alertBefore: alert.alertBeforeDays.toString(), + eventType: PkiAlertEventType.EXPIRATION, recipientEmails: alert.recipientEmails } } diff --git a/backend/src/server/routes/v2/index.ts b/backend/src/server/routes/v2/index.ts index db4ebb1763..d3d91a3baa 100644 --- a/backend/src/server/routes/v2/index.ts +++ b/backend/src/server/routes/v2/index.ts @@ -8,6 +8,7 @@ import { registerIdentityOrgRouter } from "./identity-org-router"; import { registerMfaRouter } from "./mfa-router"; import { registerOrgRouter } from "./organization-router"; import { registerPasswordRouter } from "./password-router"; +import { registerPkiAlertRouter } from "./pki-alert-router"; import { registerPkiTemplatesRouter } from "./pki-templates-router"; import { registerSecretFolderRouter } from "./secret-folder-router"; import { registerSecretImportRouter } from "./secret-import-router"; @@ -26,6 +27,7 @@ export const registerV2Routes = async (server: FastifyZodProvider) => { async (pkiRouter) => { await pkiRouter.register(registerCaRouter, { prefix: "/ca" }); await pkiRouter.register(registerPkiTemplatesRouter, { prefix: "/certificate-templates" }); + await pkiRouter.register(registerPkiAlertRouter, { prefix: "/alerts" }); }, { prefix: "/pki" } ); diff --git a/backend/src/server/routes/v2/pki-alert-router.ts b/backend/src/server/routes/v2/pki-alert-router.ts new file mode 100644 index 0000000000..459b5cc6c5 --- /dev/null +++ b/backend/src/server/routes/v2/pki-alert-router.ts @@ -0,0 +1,443 @@ +import { z } from "zod"; + +import { EventType } from "@app/ee/services/audit-log/audit-log-types"; +import { ApiDocsTags } from "@app/lib/api-docs"; +import { readLimit, writeLimit } from "@app/server/config/rateLimiter"; +import { verifyAuth } from "@app/server/plugins/auth/verify-auth"; +import { AuthMode } from "@app/services/auth/auth-type"; +import { + CreatePkiAlertV2Schema, + createSecureAlertBeforeValidator, + PkiAlertChannelType, + PkiAlertEventType, + PkiFilterRuleSchema, + UpdatePkiAlertV2Schema +} from "@app/services/pki-alert-v2/pki-alert-v2-types"; + +export const registerPkiAlertRouter = async (server: FastifyZodProvider) => { + server.route({ + method: "POST", + url: "/", + config: { + rateLimit: writeLimit + }, + onRequest: verifyAuth([AuthMode.JWT, AuthMode.IDENTITY_ACCESS_TOKEN]), + schema: { + description: "Create a new PKI alert", + tags: [ApiDocsTags.PkiAlerting], + body: CreatePkiAlertV2Schema.extend({ + projectId: z.string().uuid().describe("Project ID") + }), + response: { + 200: z.object({ + alert: z.object({ + id: z.string().uuid(), + name: z.string(), + description: z.string().nullable(), + eventType: z.nativeEnum(PkiAlertEventType), + alertBefore: z.string(), + filters: z.array(PkiFilterRuleSchema), + enabled: z.boolean(), + projectId: z.string().uuid(), + channels: z.array( + z.object({ + id: z.string().uuid(), + channelType: z.nativeEnum(PkiAlertChannelType), + config: z.record(z.any()), + enabled: z.boolean(), + createdAt: z.date(), + updatedAt: z.date() + }) + ), + createdAt: z.date(), + updatedAt: z.date() + }) + }) + } + }, + handler: async (req) => { + const alert = await server.services.pkiAlertV2.createAlert({ + actor: req.permission.type, + actorId: req.permission.id, + actorAuthMethod: req.permission.authMethod, + actorOrgId: req.permission.orgId, + ...req.body + }); + + await server.services.auditLog.createAuditLog({ + ...req.auditLogInfo, + projectId: req.body.projectId, + event: { + type: EventType.CREATE_PKI_ALERT, + metadata: { + pkiAlertId: alert.id, + name: alert.name, + eventType: alert.eventType, + alertBefore: alert.alertBefore + } + } + }); + + return { alert }; + } + }); + + server.route({ + method: "GET", + url: "/", + config: { + rateLimit: readLimit + }, + onRequest: verifyAuth([AuthMode.JWT, AuthMode.IDENTITY_ACCESS_TOKEN]), + schema: { + description: "List PKI alerts for a project", + tags: [ApiDocsTags.PkiAlerting], + querystring: z.object({ + projectId: z.string().uuid(), + search: z.string().optional(), + eventType: z.nativeEnum(PkiAlertEventType).optional(), + enabled: z.coerce.boolean().optional(), + limit: z.coerce.number().min(1).max(100).default(20), + offset: z.coerce.number().min(0).default(0) + }), + response: { + 200: z.object({ + alerts: z.array( + z.object({ + id: z.string().uuid(), + name: z.string(), + description: z.string().nullable(), + eventType: z.nativeEnum(PkiAlertEventType), + alertBefore: z.string(), + filters: z.array(PkiFilterRuleSchema), + enabled: z.boolean(), + channels: z.array( + z.object({ + id: z.string().uuid(), + channelType: z.nativeEnum(PkiAlertChannelType), + config: z.record(z.any()), + enabled: z.boolean(), + createdAt: z.date(), + updatedAt: z.date() + }) + ), + createdAt: z.date(), + updatedAt: z.date() + }) + ), + total: z.number() + }) + } + }, + handler: async (req) => { + const alerts = await server.services.pkiAlertV2.listAlerts({ + actor: req.permission.type, + actorId: req.permission.id, + actorAuthMethod: req.permission.authMethod, + actorOrgId: req.permission.orgId, + ...req.query + }); + + return alerts; + } + }); + + server.route({ + method: "GET", + url: "/:alertId", + config: { + rateLimit: readLimit + }, + onRequest: verifyAuth([AuthMode.JWT, AuthMode.IDENTITY_ACCESS_TOKEN]), + schema: { + description: "Get a PKI alert by ID", + tags: [ApiDocsTags.PkiAlerting], + params: z.object({ + alertId: z.string().uuid().describe("Alert ID") + }), + response: { + 200: z.object({ + alert: z.object({ + id: z.string().uuid(), + name: z.string(), + description: z.string().nullable(), + eventType: z.nativeEnum(PkiAlertEventType), + alertBefore: z.string(), + filters: z.array(PkiFilterRuleSchema), + enabled: z.boolean(), + projectId: z.string().uuid(), + channels: z.array( + z.object({ + id: z.string().uuid(), + channelType: z.nativeEnum(PkiAlertChannelType), + config: z.record(z.any()), + enabled: z.boolean(), + createdAt: z.date(), + updatedAt: z.date() + }) + ), + createdAt: z.date(), + updatedAt: z.date() + }) + }) + } + }, + handler: async (req) => { + const alert = await server.services.pkiAlertV2.getAlertById({ + alertId: req.params.alertId, + actor: req.permission.type, + actorId: req.permission.id, + actorAuthMethod: req.permission.authMethod, + actorOrgId: req.permission.orgId + }); + + await server.services.auditLog.createAuditLog({ + ...req.auditLogInfo, + projectId: alert.projectId, + event: { + type: EventType.GET_PKI_ALERT, + metadata: { + pkiAlertId: alert.id + } + } + }); + + return { alert }; + } + }); + + server.route({ + method: "PATCH", + url: "/:alertId", + config: { + rateLimit: writeLimit + }, + onRequest: verifyAuth([AuthMode.JWT, AuthMode.IDENTITY_ACCESS_TOKEN]), + schema: { + description: "Update a PKI alert", + tags: [ApiDocsTags.PkiAlerting], + params: z.object({ + alertId: z.string().uuid().describe("Alert ID") + }), + body: UpdatePkiAlertV2Schema, + response: { + 200: z.object({ + alert: z.object({ + id: z.string().uuid(), + name: z.string(), + description: z.string().nullable(), + eventType: z.nativeEnum(PkiAlertEventType), + alertBefore: z.string(), + filters: z.array(PkiFilterRuleSchema), + enabled: z.boolean(), + projectId: z.string().uuid(), + channels: z.array( + z.object({ + id: z.string().uuid(), + channelType: z.nativeEnum(PkiAlertChannelType), + config: z.record(z.any()), + enabled: z.boolean(), + createdAt: z.date(), + updatedAt: z.date() + }) + ), + createdAt: z.date(), + updatedAt: z.date() + }) + }) + } + }, + handler: async (req) => { + const alert = await server.services.pkiAlertV2.updateAlert({ + alertId: req.params.alertId, + actor: req.permission.type, + actorId: req.permission.id, + actorAuthMethod: req.permission.authMethod, + actorOrgId: req.permission.orgId, + ...req.body + }); + + await server.services.auditLog.createAuditLog({ + ...req.auditLogInfo, + projectId: alert.projectId, + event: { + type: EventType.UPDATE_PKI_ALERT, + metadata: { + pkiAlertId: alert.id, + name: alert.name, + eventType: alert.eventType, + alertBefore: alert.alertBefore + } + } + }); + + return { alert }; + } + }); + + server.route({ + method: "DELETE", + url: "/:alertId", + config: { + rateLimit: writeLimit + }, + onRequest: verifyAuth([AuthMode.JWT, AuthMode.IDENTITY_ACCESS_TOKEN]), + schema: { + description: "Delete a PKI alert", + tags: [ApiDocsTags.PkiAlerting], + params: z.object({ + alertId: z.string().uuid().describe("Alert ID") + }), + response: { + 200: z.object({ + alert: z.object({ + id: z.string().uuid(), + name: z.string(), + description: z.string().nullable(), + eventType: z.nativeEnum(PkiAlertEventType), + alertBefore: z.string(), + filters: z.array(PkiFilterRuleSchema), + enabled: z.boolean(), + projectId: z.string().uuid(), + channels: z.array( + z.object({ + id: z.string().uuid(), + channelType: z.nativeEnum(PkiAlertChannelType), + config: z.record(z.any()), + enabled: z.boolean(), + createdAt: z.date(), + updatedAt: z.date() + }) + ), + createdAt: z.date(), + updatedAt: z.date() + }) + }) + } + }, + handler: async (req) => { + const alert = await server.services.pkiAlertV2.deleteAlert({ + alertId: req.params.alertId, + actor: req.permission.type, + actorId: req.permission.id, + actorAuthMethod: req.permission.authMethod, + actorOrgId: req.permission.orgId + }); + + await server.services.auditLog.createAuditLog({ + ...req.auditLogInfo, + projectId: alert.projectId, + event: { + type: EventType.DELETE_PKI_ALERT, + metadata: { + pkiAlertId: alert.id + } + } + }); + + return { alert }; + } + }); + + server.route({ + method: "GET", + url: "/:alertId/certificates", + config: { + rateLimit: readLimit + }, + onRequest: verifyAuth([AuthMode.JWT, AuthMode.IDENTITY_ACCESS_TOKEN]), + schema: { + description: "List certificates that match an alert's filter rules", + tags: [ApiDocsTags.PkiAlerting], + params: z.object({ + alertId: z.string().uuid().describe("Alert ID") + }), + querystring: z.object({ + limit: z.coerce.number().min(1).max(100).default(20), + offset: z.coerce.number().min(0).default(0) + }), + response: { + 200: z.object({ + certificates: z.array( + z.object({ + id: z.string().uuid(), + serialNumber: z.string(), + commonName: z.string(), + san: z.array(z.string()), + profileName: z.string().nullable(), + enrollmentType: z.string().nullable(), + notBefore: z.date(), + notAfter: z.date(), + status: z.string() + }) + ), + total: z.number() + }) + } + }, + handler: async (req) => { + const result = await server.services.pkiAlertV2.listMatchingCertificates({ + alertId: req.params.alertId, + actor: req.permission.type, + actorId: req.permission.id, + actorAuthMethod: req.permission.authMethod, + actorOrgId: req.permission.orgId, + ...req.query + }); + + return result; + } + }); + + server.route({ + method: "POST", + url: "/preview/certificates", + config: { + rateLimit: writeLimit + }, + onRequest: verifyAuth([AuthMode.JWT, AuthMode.IDENTITY_ACCESS_TOKEN]), + schema: { + description: "Preview certificates that would match the given filter rules", + tags: [ApiDocsTags.PkiAlerting], + body: z.object({ + projectId: z.string().uuid().describe("Project ID"), + filters: z.array(PkiFilterRuleSchema), + alertBefore: z + .string() + .refine(createSecureAlertBeforeValidator(), "Must be in format like '30d', '1w', '3m', '1y'") + .describe("Alert timing (e.g., '30d', '1w')"), + limit: z.coerce.number().min(1).max(100).default(20), + offset: z.coerce.number().min(0).default(0) + }), + response: { + 200: z.object({ + certificates: z.array( + z.object({ + id: z.string().uuid(), + serialNumber: z.string(), + commonName: z.string(), + san: z.array(z.string()), + profileName: z.string().nullable(), + enrollmentType: z.string().nullable(), + notBefore: z.date(), + notAfter: z.date(), + status: z.string() + }) + ), + total: z.number() + }) + } + }, + handler: async (req) => { + const result = await server.services.pkiAlertV2.listCurrentMatchingCertificates({ + actor: req.permission.type, + actorId: req.permission.id, + actorAuthMethod: req.permission.authMethod, + actorOrgId: req.permission.orgId, + ...req.body + }); + + return result; + } + }); +}; diff --git a/backend/src/services/pki-alert-v2/pki-alert-channel-dal.ts b/backend/src/services/pki-alert-v2/pki-alert-channel-dal.ts new file mode 100644 index 0000000000..b16432a346 --- /dev/null +++ b/backend/src/services/pki-alert-v2/pki-alert-channel-dal.ts @@ -0,0 +1,61 @@ +import { Knex } from "knex"; + +import { TDbClient } from "@app/db"; +import { TableName, TPkiAlertChannels, TPkiAlertChannelsInsert } from "@app/db/schemas"; +import { DatabaseError } from "@app/lib/errors"; +import { ormify, selectAllTableCols } from "@app/lib/knex"; + +export type TPkiAlertChannelDALFactory = ReturnType; + +export const pkiAlertChannelDALFactory = (db: TDbClient) => { + const pkiAlertChannelOrm = ormify(db, TableName.PkiAlertChannels); + + const insertMany = async (data: TPkiAlertChannelsInsert[], tx?: Knex): Promise => { + try { + if (!data.length) return []; + + const serializedData = data.map((item) => ({ + ...item, + config: item.config ? JSON.stringify(item.config) : null + })); + + const res = await (tx || db)(TableName.PkiAlertChannels).insert(serializedData).returning("*"); + + return res as TPkiAlertChannels[]; + } catch (error) { + throw new DatabaseError({ error, name: "InsertMany" }); + } + }; + + const findByAlertId = async (alertId: string, tx?: Knex): Promise => { + try { + const channels = await (tx || db.replicaNode())(TableName.PkiAlertChannels) + .where(`${TableName.PkiAlertChannels}.alertId`, alertId) + .select(selectAllTableCols(TableName.PkiAlertChannels)) + .orderBy(`${TableName.PkiAlertChannels}.createdAt`, "asc"); + + return channels as TPkiAlertChannels[]; + } catch (error) { + throw new DatabaseError({ error, name: "FindByAlertId" }); + } + }; + + const deleteByAlertId = async (alertId: string, tx?: Knex): Promise => { + try { + const deletedCount = await (tx || db)(TableName.PkiAlertChannels) + .where(`${TableName.PkiAlertChannels}.alertId`, alertId) + .del(); + + return deletedCount; + } catch (error) { + throw new DatabaseError({ error, name: "DeleteByAlertId" }); + } + }; + + return { + ...pkiAlertChannelOrm, + insertMany, + findByAlertId, + deleteByAlertId + }; +}; diff --git a/backend/src/services/pki-alert-v2/pki-alert-history-dal.ts b/backend/src/services/pki-alert-v2/pki-alert-history-dal.ts new file mode 100644 index 0000000000..ceb2820c42 --- /dev/null +++ b/backend/src/services/pki-alert-v2/pki-alert-history-dal.ts @@ -0,0 +1,110 @@ +import { TDbClient } from "@app/db"; +import { TableName, TPkiAlertHistory } from "@app/db/schemas"; +import { DatabaseError } from "@app/lib/errors"; +import { ormify, selectAllTableCols } from "@app/lib/knex"; + +export type TPkiAlertHistoryDALFactory = ReturnType; + +export const pkiAlertHistoryDALFactory = (db: TDbClient) => { + const pkiAlertHistoryOrm = ormify(db, TableName.PkiAlertHistory); + + const createWithCertificates = async ( + alertId: string, + certificateIds: string[], + options?: { + hasNotificationSent?: boolean; + notificationError?: string; + } + ): Promise => { + try { + return await db.transaction(async (tx) => { + const historyRecords = await tx(TableName.PkiAlertHistory) + .insert({ + alertId, + hasNotificationSent: options?.hasNotificationSent || false, + notificationError: options?.notificationError + }) + .returning("*"); + + const historyRecord = historyRecords[0]; + + if (certificateIds.length > 0) { + const certificateAssociations = certificateIds.map((certificateId) => ({ + alertHistoryId: historyRecord.id, + certificateId + })); + + await tx(TableName.PkiAlertHistoryCertificate).insert(certificateAssociations); + } + + return historyRecord; + }); + } catch (error) { + throw new DatabaseError({ error, name: "CreateWithCertificates" }); + } + }; + + const findByAlertId = async ( + alertId: string, + options?: { + limit?: number; + offset?: number; + } + ): Promise => { + try { + let query = db + .replicaNode() + .select(selectAllTableCols(TableName.PkiAlertHistory)) + .from(TableName.PkiAlertHistory) + .where(`${TableName.PkiAlertHistory}.alertId`, alertId) + .orderBy(`${TableName.PkiAlertHistory}.triggeredAt`, "desc"); + + if (options?.limit) { + query = query.limit(options.limit); + } + + if (options?.offset) { + query = query.offset(options.offset); + } + + const results = await query; + return results as TPkiAlertHistory[]; + } catch (error) { + throw new DatabaseError({ error, name: "FindByAlertId" }); + } + }; + + const findRecentlyAlertedCertificates = async ( + alertId: string, + certificateIds: string[], + withinHours = 24 + ): Promise => { + try { + if (certificateIds.length === 0) return []; + + const cutoffDate = new Date(); + cutoffDate.setHours(cutoffDate.getHours() - withinHours); + + const results = (await db + .replicaNode() + .select("cert.certificateId") + .from(`${TableName.PkiAlertHistory} as hist`) + .join(`${TableName.PkiAlertHistoryCertificate} as cert`, "hist.id", "cert.alertHistoryId") + .where("hist.alertId", alertId) + .where("hist.hasNotificationSent", true) + .where("hist.triggeredAt", ">=", cutoffDate) + .whereIn("cert.certificateId", certificateIds)) as Array<{ certificateId: string }>; + + return results.map((row) => row.certificateId); + } catch (error) { + throw new DatabaseError({ error, name: "FindRecentlyAlertedCertificates" }); + } + }; + + return { + ...pkiAlertHistoryOrm, + createWithCertificates, + findByAlertId, + findRecentlyAlertedCertificates + }; +}; diff --git a/backend/src/services/pki-alert-v2/pki-alert-v2-dal.ts b/backend/src/services/pki-alert-v2/pki-alert-v2-dal.ts new file mode 100644 index 0000000000..d10c1689b5 --- /dev/null +++ b/backend/src/services/pki-alert-v2/pki-alert-v2-dal.ts @@ -0,0 +1,556 @@ +import { Knex } from "knex"; + +import { TDbClient } from "@app/db"; +import { TableName, TPkiAlertsV2, TPkiAlertsV2Insert, TPkiAlertsV2Update } from "@app/db/schemas"; +import { DatabaseError } from "@app/lib/errors"; +import { ormify, selectAllTableCols } from "@app/lib/knex"; + +import { + applyCaFilters, + applyCertificateFilters, + requiresProfileJoin, + sanitizeLikeInput, + shouldIncludeCAs +} from "./pki-alert-v2-filter-utils"; +import { CertificateOrigin, TCertificatePreview, TPkiFilterRule } from "./pki-alert-v2-types"; + +export type TPkiAlertV2DALFactory = ReturnType; + +export const pkiAlertV2DALFactory = (db: TDbClient) => { + const pkiAlertV2Orm = ormify(db, TableName.PkiAlertsV2); + + const create = async (data: TPkiAlertsV2Insert, tx?: Knex): Promise => { + try { + const serializedData = { + ...data, + filters: data.filters ? JSON.stringify(data.filters) : null + }; + const [res] = await (tx || db)(TableName.PkiAlertsV2).insert(serializedData).returning("*"); + + return res; + } catch (error) { + throw new DatabaseError({ error, name: "Create" }); + } + }; + + const updateById = async (id: string, data: TPkiAlertsV2Update, tx?: Knex): Promise => { + try { + const serializedData: Record = { + ...data, + filters: data.filters !== undefined ? JSON.stringify(data.filters) : undefined + }; + Object.keys(serializedData).forEach((key) => { + if (serializedData[key] === undefined) { + delete serializedData[key]; + } + }); + + const [res] = await (tx || db)(TableName.PkiAlertsV2).where({ id }).update(serializedData).returning("*"); + + return res; + } catch (error) { + throw new DatabaseError({ error, name: "UpdateById" }); + } + }; + + const findById = async (id: string, tx?: Knex): Promise => { + try { + const [res] = await (tx || db.replicaNode())(TableName.PkiAlertsV2).where({ id }).select("*"); + + if (!res) return null; + + return res; + } catch (error) { + throw new DatabaseError({ error, name: "FindById" }); + } + }; + + type TChannelResult = { + id: string; + alertId: string; + channelType: string; + config: unknown; + enabled: boolean; + createdAt: Date; + updatedAt: Date; + }; + + type TAlertWithChannels = TPkiAlertsV2 & { + channels: TChannelResult[]; + }; + + const findByIdWithChannels = async (alertId: string, tx?: Knex): Promise => { + try { + const [alert] = (await (tx || db.replicaNode()) + .select(selectAllTableCols(TableName.PkiAlertsV2)) + .from(TableName.PkiAlertsV2) + .where(`${TableName.PkiAlertsV2}.id`, alertId)) as TPkiAlertsV2[]; + + if (!alert) return null; + + const channels = (await (tx || db.replicaNode()) + .select(selectAllTableCols(TableName.PkiAlertChannels)) + .from(TableName.PkiAlertChannels) + .where(`${TableName.PkiAlertChannels}.alertId`, alertId)) as TChannelResult[]; + + return { + ...alert, + channels: channels || [] + } as TAlertWithChannels; + } catch (error) { + throw new DatabaseError({ error, name: "FindByIdWithChannels" }); + } + }; + + const findByProjectIdWithCount = async ( + projectId: string, + filters?: { + search?: string; + eventType?: string; + enabled?: boolean; + limit?: number; + offset?: number; + }, + tx?: Knex + ): Promise<{ alerts: TAlertWithChannels[]; total: number }> => { + try { + let countQuery = (tx || db.replicaNode()) + .count("* as count") + .from(TableName.PkiAlertsV2) + .where(`${TableName.PkiAlertsV2}.projectId`, projectId); + + if (filters?.search) { + countQuery = countQuery.whereILike(`${TableName.PkiAlertsV2}.name`, `%${sanitizeLikeInput(filters.search)}%`); + } + + if (filters?.eventType) { + countQuery = countQuery.where(`${TableName.PkiAlertsV2}.eventType`, filters.eventType); + } + + if (filters?.enabled !== undefined) { + countQuery = countQuery.where(`${TableName.PkiAlertsV2}.enabled`, filters.enabled); + } + + let alertQuery = (tx || db.replicaNode()) + .select(selectAllTableCols(TableName.PkiAlertsV2)) + .from(TableName.PkiAlertsV2) + .where(`${TableName.PkiAlertsV2}.projectId`, projectId); + + if (filters?.search) { + alertQuery = alertQuery.whereILike(`${TableName.PkiAlertsV2}.name`, `%${sanitizeLikeInput(filters.search)}%`); + } + + if (filters?.eventType) { + alertQuery = alertQuery.where(`${TableName.PkiAlertsV2}.eventType`, filters.eventType); + } + + if (filters?.enabled !== undefined) { + alertQuery = alertQuery.where(`${TableName.PkiAlertsV2}.enabled`, filters.enabled); + } + + alertQuery = alertQuery.orderBy(`${TableName.PkiAlertsV2}.createdAt`, "desc"); + + if (filters?.limit) { + alertQuery = alertQuery.limit(filters.limit); + } + + if (filters?.offset) { + alertQuery = alertQuery.offset(filters.offset); + } + + const [countResult, alerts] = await Promise.all([countQuery, alertQuery]); + + const total = parseInt((countResult[0] as { count: string }).count, 10); + + const alertIds = (alerts as TPkiAlertsV2[]).map((alert) => alert.id); + const channels = (await (tx || db.replicaNode()) + .select(selectAllTableCols(TableName.PkiAlertChannels)) + .from(TableName.PkiAlertChannels) + .whereIn(`${TableName.PkiAlertChannels}.alertId`, alertIds)) as TChannelResult[]; + + const channelsByAlertId = channels.reduce( + (acc, channel) => { + if (!acc[channel.alertId]) { + acc[channel.alertId] = []; + } + acc[channel.alertId].push(channel); + return acc; + }, + {} as Record + ); + + const alertsWithChannels: TAlertWithChannels[] = (alerts as TPkiAlertsV2[]).map((alert) => ({ + ...alert, + channels: channelsByAlertId[alert.id] || [] + })); + + return { alerts: alertsWithChannels, total }; + } catch (error) { + throw new DatabaseError({ error, name: "FindByProjectIdWithCount" }); + } + }; + + const findByProjectId = async ( + projectId: string, + filters?: { + search?: string; + eventType?: string; + enabled?: boolean; + limit?: number; + offset?: number; + }, + tx?: Knex + ): Promise => { + const result = await findByProjectIdWithCount(projectId, filters, tx); + return result.alerts; + }; + + const countByProjectId = async ( + projectId: string, + filters?: { + search?: string; + eventType?: string; + enabled?: boolean; + }, + tx?: Knex + ): Promise => { + try { + let query = (tx || db.replicaNode()) + .count("* as count") + .from(TableName.PkiAlertsV2) + .where(`${TableName.PkiAlertsV2}.projectId`, projectId); + + if (filters?.search) { + query = query.whereILike(`${TableName.PkiAlertsV2}.name`, `%${sanitizeLikeInput(filters.search)}%`); + } + + if (filters?.eventType) { + query = query.where(`${TableName.PkiAlertsV2}.eventType`, filters.eventType); + } + + if (filters?.enabled !== undefined) { + query = query.where(`${TableName.PkiAlertsV2}.enabled`, filters.enabled); + } + + const result = await query; + return parseInt((result[0] as { count: string }).count, 10); + } catch (error) { + throw new DatabaseError({ error, name: "CountByProjectId" }); + } + }; + + const getDistinctProjectIds = async ( + filters?: { + enabled?: boolean; + }, + tx?: Knex + ): Promise => { + try { + let query = (tx || db.replicaNode()).distinct(`${TableName.PkiAlertsV2}.projectId`).from(TableName.PkiAlertsV2); + + if (filters?.enabled !== undefined) { + query = query.where(`${TableName.PkiAlertsV2}.enabled`, filters.enabled); + } + + const result = await query; + return result.map((row: { projectId: string }) => row.projectId); + } catch (error) { + throw new DatabaseError({ error, name: "GetDistinctProjectIds" }); + } + }; + + const findMatchingCertificates = async ( + projectId: string, + filters: TPkiFilterRule[] = [], + options?: { + limit?: number; + offset?: number; + alertBefore?: string; + showFutureMatches?: boolean; + showCurrentMatches?: boolean; + showPreview?: boolean; + excludeAlerted?: boolean; + alertId?: string; + }, + tx?: Knex + ): Promise<{ certificates: TCertificatePreview[]; total: number }> => { + try { + const includeCAs = shouldIncludeCAs(filters); + const needsProfileJoin = requiresProfileJoin(filters); + const limit = options?.limit || 10; + const offset = options?.offset || 0; + + let caTotalCount = 0; + let certTotalCount = 0; + + if (includeCAs) { + let caCountQuery = (tx || db.replicaNode()) + .count("* as count") + .from(TableName.CertificateAuthority) + .innerJoin( + `${TableName.InternalCertificateAuthority} as ica`, + `${TableName.CertificateAuthority}.id`, + `ica.caId` + ); + + caCountQuery = applyCaFilters(caCountQuery, filters, projectId) as typeof caCountQuery; + + if (options?.alertBefore) { + if (options.showFutureMatches) { + caCountQuery = caCountQuery + .whereRaw(`ica."notAfter" > NOW() + ?::interval`, [options.alertBefore]) + .whereRaw(`ica."notAfter" > NOW()`); + } else if (options.showCurrentMatches) { + caCountQuery = caCountQuery + .whereRaw(`ica."notAfter" > NOW()`) + .whereRaw(`ica."notAfter" <= NOW() + ?::interval`, [options.alertBefore]); + } else { + caCountQuery = caCountQuery + .whereRaw(`ica."notAfter" > NOW()`) + .whereRaw(`ica."notAfter" <= NOW() + ?::interval`, [options.alertBefore]); + } + } + + const caCountResult = await caCountQuery; + caTotalCount = parseInt((caCountResult[0] as { count: string }).count, 10); + } + + let certCountQuery = (tx || db.replicaNode()).count("* as count").from(TableName.Certificate); + certCountQuery = applyCertificateFilters(certCountQuery, filters, projectId) as typeof certCountQuery; + + if (options?.showPreview) { + certCountQuery = certCountQuery + .whereRaw(`"${TableName.Certificate}"."notAfter" > NOW()`) + .whereNot(`${TableName.Certificate}.status`, "revoked"); + } else if (options?.alertBefore) { + if (options.showFutureMatches) { + certCountQuery = certCountQuery + .whereRaw(`"${TableName.Certificate}"."notAfter" > NOW() + ?::interval`, [options.alertBefore]) + .whereRaw(`"${TableName.Certificate}"."notAfter" > NOW()`) + .whereNot(`${TableName.Certificate}.status`, "revoked"); + } else if (options.showCurrentMatches) { + certCountQuery = certCountQuery + .whereRaw(`"${TableName.Certificate}"."notAfter" > NOW()`) + .whereRaw(`"${TableName.Certificate}"."notAfter" <= NOW() + ?::interval`, [options.alertBefore]) + .whereNot(`${TableName.Certificate}.status`, "revoked"); + } else { + certCountQuery = certCountQuery + .whereRaw(`"${TableName.Certificate}"."notAfter" > NOW()`) + .whereRaw(`"${TableName.Certificate}"."notAfter" <= NOW() + ?::interval`, [options.alertBefore]) + .whereNot(`${TableName.Certificate}.status`, "revoked"); + } + } + + if (options?.excludeAlerted && options?.alertId) { + certCountQuery = certCountQuery.whereNotExists( + (tx || db.replicaNode()) + .select("*") + .from(TableName.PkiAlertHistory) + .join( + TableName.PkiAlertHistoryCertificate, + `${TableName.PkiAlertHistory}.id`, + `${TableName.PkiAlertHistoryCertificate}.alertHistoryId` + ) + .where(`${TableName.PkiAlertHistory}.alertId`, options.alertId) + .whereRaw(`"${TableName.PkiAlertHistoryCertificate}"."certificateId" = "${TableName.Certificate}".id`) + ); + } + + const certCountResult = await certCountQuery; + certTotalCount = parseInt((certCountResult[0] as { count: string }).count, 10); + + const totalCount = caTotalCount + certTotalCount; + let results: TCertificatePreview[] = []; + + const fetchCertificates = async (certLimit: number, certOffset: number) => { + const selectColumns = [ + `${TableName.Certificate}.id`, + `${TableName.Certificate}.serialNumber`, + `${TableName.Certificate}.commonName`, + `${TableName.Certificate}.altNames as san`, + `${TableName.Certificate}.notBefore`, + `${TableName.Certificate}.notAfter`, + `${TableName.Certificate}.status`, + `${TableName.Certificate}.profileId`, + `${TableName.Certificate}.pkiSubscriberId` + ]; + + if (needsProfileJoin) { + selectColumns.push("profile.name as profileName"); + } + + let certificateQuery = (tx || db.replicaNode()).select(selectColumns).from(TableName.Certificate); + + certificateQuery = applyCertificateFilters(certificateQuery, filters, projectId) as typeof certificateQuery; + + if (options?.showPreview) { + certificateQuery = certificateQuery + .whereRaw(`"${TableName.Certificate}"."notAfter" > NOW()`) + .whereNot(`${TableName.Certificate}.status`, "revoked"); + } else if (options?.alertBefore) { + if (options.showFutureMatches) { + certificateQuery = certificateQuery + .whereRaw(`"${TableName.Certificate}"."notAfter" > NOW() + ?::interval`, [options.alertBefore]) + .whereRaw(`"${TableName.Certificate}"."notAfter" > NOW()`) + .whereNot(`${TableName.Certificate}.status`, "revoked"); + } else if (options.showCurrentMatches) { + certificateQuery = certificateQuery + .whereRaw(`"${TableName.Certificate}"."notAfter" > NOW()`) + .whereRaw(`"${TableName.Certificate}"."notAfter" <= NOW() + ?::interval`, [options.alertBefore]) + .whereNot(`${TableName.Certificate}.status`, "revoked"); + } else { + certificateQuery = certificateQuery + .whereRaw(`"${TableName.Certificate}"."notAfter" > NOW()`) + .whereRaw(`"${TableName.Certificate}"."notAfter" <= NOW() + ?::interval`, [options.alertBefore]) + .whereNot(`${TableName.Certificate}.status`, "revoked"); + } + } + + if (options?.excludeAlerted && options?.alertId) { + certificateQuery = certificateQuery.whereNotExists( + (tx || db.replicaNode()) + .select("*") + .from(TableName.PkiAlertHistory) + .join( + TableName.PkiAlertHistoryCertificate, + `${TableName.PkiAlertHistory}.id`, + `${TableName.PkiAlertHistoryCertificate}.alertHistoryId` + ) + .where(`${TableName.PkiAlertHistory}.alertId`, options.alertId) + .whereRaw(`"${TableName.PkiAlertHistoryCertificate}"."certificateId" = "${TableName.Certificate}".id`) + ); + } + + certificateQuery = certificateQuery + .orderBy(`${TableName.Certificate}.notAfter`, "asc") + .limit(certLimit) + .offset(certOffset); + + const certificates = await certificateQuery; + const formattedCertificates: TCertificatePreview[] = ( + certificates as Array<{ + id: string; + serialNumber: string; + commonName: string; + san: string[] | null; + profileId: string | null; + pkiSubscriberId: string | null; + profileName?: string | null; + notBefore: Date; + notAfter: Date; + status: string; + }> + ).map((cert) => { + let enrollmentType = CertificateOrigin.UNKNOWN; + if (cert.profileId) { + enrollmentType = CertificateOrigin.PROFILE; + } else if (cert.pkiSubscriberId) { + enrollmentType = CertificateOrigin.IMPORT; + } + + return { + id: cert.id, + serialNumber: cert.serialNumber, + commonName: cert.commonName, + san: Array.isArray(cert.san) ? cert.san : [], + profileName: cert.profileName || null, + enrollmentType, + notBefore: cert.notBefore, + notAfter: cert.notAfter, + status: cert.status + }; + }); + + results = [...results, ...formattedCertificates]; + }; + + if (offset < caTotalCount) { + const caLimit = Math.min(limit, caTotalCount - offset); + const caOffset = offset; + + let caQuery = (tx || db.replicaNode()) + .select( + `${TableName.CertificateAuthority}.id`, + `ica.serialNumber`, + `ica.commonName`, + `ica.notBefore`, + `ica.notAfter` + ) + .from(TableName.CertificateAuthority) + .innerJoin( + `${TableName.InternalCertificateAuthority} as ica`, + `${TableName.CertificateAuthority}.id`, + `ica.caId` + ); + + caQuery = applyCaFilters(caQuery, filters, projectId) as typeof caQuery; + + if (options?.alertBefore) { + if (options.showFutureMatches) { + caQuery = caQuery + .whereRaw(`ica."notAfter" > NOW() + ?::interval`, [options.alertBefore]) + .whereRaw(`ica."notAfter" > NOW()`); + } else { + caQuery = caQuery + .whereRaw(`ica."notAfter" > NOW()`) + .whereRaw(`ica."notAfter" <= NOW() + ?::interval`, [options.alertBefore]); + } + } + + caQuery = caQuery.orderBy(`ica.notAfter`, "asc").limit(caLimit).offset(caOffset); + + const cas = await caQuery; + const formattedCAs: TCertificatePreview[] = ( + cas as Array<{ + id: string; + serialNumber: string; + commonName: string; + notBefore: Date; + notAfter: Date; + }> + ).map((ca) => ({ + id: ca.id, + serialNumber: ca.serialNumber, + commonName: ca.commonName, + san: [], + profileName: null, + enrollmentType: CertificateOrigin.CA, + notBefore: ca.notBefore, + notAfter: ca.notAfter, + status: "active" + })); + + results = [...results, ...formattedCAs]; + + const remainingLimit = limit - caLimit; + if (remainingLimit > 0 && certTotalCount > 0) { + const certOffset = 0; + await fetchCertificates(remainingLimit, certOffset); + } + } else { + const certOffset = offset - caTotalCount; + await fetchCertificates(limit, certOffset); + } + + return { + certificates: results, + total: totalCount + }; + } catch (error) { + throw new DatabaseError({ error, name: "FindMatchingCertificates" }); + } + }; + + return { + ...pkiAlertV2Orm, + create, + updateById, + findById, + findByIdWithChannels, + findByProjectId, + findByProjectIdWithCount, + countByProjectId, + getDistinctProjectIds, + findMatchingCertificates + }; +}; diff --git a/backend/src/services/pki-alert-v2/pki-alert-v2-filter-utils.ts b/backend/src/services/pki-alert-v2/pki-alert-v2-filter-utils.ts new file mode 100644 index 0000000000..cb7f9cf72d --- /dev/null +++ b/backend/src/services/pki-alert-v2/pki-alert-v2-filter-utils.ts @@ -0,0 +1,358 @@ +import { Knex } from "knex"; +import RE2 from "re2"; + +import { TableName } from "@app/db/schemas"; +import { logger } from "@app/lib/logger"; + +import { PkiFilterField, PkiFilterOperator, TPkiFilterRule } from "./pki-alert-v2-types"; + +export const sanitizeLikeInput = (input: string): string => { + const allowedCharsRegex = new RE2("^[a-zA-Z0-9\\s\\-_\\.@\\*]+$"); + if (!allowedCharsRegex.test(input)) { + throw new Error( + "Invalid characters in input. Only alphanumeric characters, spaces, hyphens, underscores, dots, @ and * are allowed." + ); + } + + const backslashRegex = new RE2("\\\\", "g"); + const percentRegex = new RE2("%", "g"); + const underscoreRegex = new RE2("_", "g"); + const quoteRegex = new RE2("'", "g"); + + return input + .replace(backslashRegex, "\\\\\\\\") + .replace(percentRegex, "\\%") + .replace(underscoreRegex, "\\_") + .replace(quoteRegex, "''"); +}; + +export const parseTimeToPostgresInterval = (duration: string): string => { + if (duration.length > 32) { + throw new Error(`Invalid duration format: ${duration}. Use format like '30d', '1w', '3m', '1y'`); + } + + const durationRegex = new RE2("^(\\d+)([dwmy])$"); + const match = durationRegex.exec(duration); + + if (!match) { + throw new Error(`Invalid duration format: ${duration}. Use format like '30d', '1w', '3m', '1y'`); + } + + const [, value, unit] = match; + const amount = parseInt(value, 10); + + if (amount <= 0 || amount > 9999) { + throw new Error(`Duration value out of range: ${duration}. Must be between 1 and 9999.`); + } + + const unitMap = { + d: "days", + w: "weeks", + m: "months", + y: "years" + }; + + return `${amount} ${unitMap[unit as keyof typeof unitMap]}`; +}; + +export const parseTimeToDays = (timeStr: string): number => { + const alertBeforeRegex = new RE2("^(\\d+)([dwmy])$"); + const match = alertBeforeRegex.exec(timeStr); + if (!match) { + return 0; + } + + const [, value, unit] = match; + const amount = parseInt(value, 10); + + if (amount <= 0 || amount > 9999) { + return 0; + } + + switch (unit) { + case "d": + return amount; + case "w": + return amount * 7; + case "m": + return amount * 30; + case "y": + return amount * 365; + default: + return 0; + } +}; + +const applyProfileNameFilter = (query: Knex.QueryBuilder, filter: TPkiFilterRule): Knex.QueryBuilder => { + const { value } = filter; + + switch (filter.operator) { + case PkiFilterOperator.EQUALS: + return query.where("profile.slug", value as string); + + case PkiFilterOperator.MATCHES: + if (Array.isArray(value)) { + return query.whereIn("profile.slug", value); + } + return query.whereILike("profile.slug", `%${sanitizeLikeInput(String(value))}%`); + + case PkiFilterOperator.CONTAINS: + if (Array.isArray(value)) { + return query.where((builder) => { + value.forEach((v, index) => { + const sanitizedValue = sanitizeLikeInput(String(v)); + if (index === 0) { + void builder.whereILike("profile.slug", `%${sanitizedValue}%`); + } else { + void builder.orWhereILike("profile.slug", `%${sanitizedValue}%`); + } + }); + }); + } + return query.whereILike("profile.slug", `%${sanitizeLikeInput(String(value))}%`); + + case PkiFilterOperator.STARTS_WITH: + return query.whereILike("profile.slug", `${sanitizeLikeInput(String(value))}%`); + + case PkiFilterOperator.ENDS_WITH: + return query.whereILike("profile.slug", `%${sanitizeLikeInput(String(value))}`); + + default: + logger.warn(`Unsupported operator for profile_name: ${String(filter.operator)}`); + return query; + } +}; + +const applyCommonNameFilter = (query: Knex.QueryBuilder, filter: TPkiFilterRule): Knex.QueryBuilder => { + const { value } = filter; + const columnName = `${TableName.Certificate}.commonName`; + + switch (filter.operator) { + case PkiFilterOperator.EQUALS: + return query.where(columnName, value as string); + + case PkiFilterOperator.MATCHES: + if (Array.isArray(value)) { + return query.whereIn(columnName, value); + } + return query.whereILike(columnName, `%${sanitizeLikeInput(String(value))}%`); + + case PkiFilterOperator.CONTAINS: + if (Array.isArray(value)) { + return query.where((builder) => { + value.forEach((v, index) => { + const sanitizedValue = sanitizeLikeInput(String(v)); + if (index === 0) { + void builder.whereILike(columnName, `%${sanitizedValue}%`); + } else { + void builder.orWhereILike(columnName, `%${sanitizedValue}%`); + } + }); + }); + } + return query.whereILike(columnName, `%${sanitizeLikeInput(String(value))}%`); + + case PkiFilterOperator.STARTS_WITH: + return query.whereILike(columnName, `${sanitizeLikeInput(String(value))}%`); + + case PkiFilterOperator.ENDS_WITH: + return query.whereILike(columnName, `%${sanitizeLikeInput(String(value))}`); + + default: + logger.warn(`Unsupported operator for common_name: ${String(filter.operator)}`); + return query; + } +}; + +const applySanFilter = (query: Knex.QueryBuilder, filter: TPkiFilterRule): Knex.QueryBuilder => { + const { value } = filter; + const columnName = `${TableName.Certificate}.altNames`; + + switch (filter.operator) { + case PkiFilterOperator.EQUALS: + return query.whereJsonSupersetOf(columnName, [value as string]); + + case PkiFilterOperator.MATCHES: + if (Array.isArray(value)) { + return query.where((builder) => { + value.forEach((v, index) => { + const sanitizedValue = `%"${String(v)}"%`; + if (index === 0) { + void builder.whereRaw(`??."altNames"::text ILIKE ?`, [TableName.Certificate, sanitizedValue]); + } else { + void builder.orWhereRaw(`??."altNames"::text ILIKE ?`, [TableName.Certificate, sanitizedValue]); + } + }); + }); + } + { + const sanitizedValue = `%"${String(value)}"%`; + return query.whereRaw(`??."altNames"::text ILIKE ?`, [TableName.Certificate, sanitizedValue]); + } + + case PkiFilterOperator.CONTAINS: + return applySanFilter(query, { ...filter, operator: PkiFilterOperator.MATCHES }); + + case PkiFilterOperator.STARTS_WITH: { + const startsWithValue = `%"${String(value)}%`; + return query.whereRaw(`??."altNames"::text ILIKE ?`, [TableName.Certificate, startsWithValue]); + } + + case PkiFilterOperator.ENDS_WITH: { + const endsWithValue = `%${String(value)}"%`; + return query.whereRaw(`??."altNames"::text ILIKE ?`, [TableName.Certificate, endsWithValue]); + } + + default: + logger.warn(`Unsupported operator for SAN: ${String(filter.operator)}`); + return query; + } +}; + +export const shouldIncludeCAs = (filters: TPkiFilterRule[]): boolean => { + return filters.some((filter) => filter.field === PkiFilterField.INCLUDE_CAS && filter.value === true); +}; + +const applyCaCommonNameFilter = (query: Knex.QueryBuilder, filter: TPkiFilterRule): Knex.QueryBuilder => { + const { value } = filter; + const columnName = "ica.commonName"; + + switch (filter.operator) { + case PkiFilterOperator.EQUALS: + return query.where(columnName, value as string); + + case PkiFilterOperator.MATCHES: + if (Array.isArray(value)) { + return query.whereIn(columnName, value); + } + return query.whereILike(columnName, `%${sanitizeLikeInput(String(value))}%`); + + case PkiFilterOperator.CONTAINS: + if (Array.isArray(value)) { + return query.where((builder) => { + value.forEach((v, index) => { + if (index === 0) { + void builder.whereILike(columnName, `%${sanitizeLikeInput(String(v))}%`); + } else { + void builder.orWhereILike(columnName, `%${sanitizeLikeInput(String(v))}%`); + } + }); + }); + } + return query.whereILike(columnName, `%${sanitizeLikeInput(String(value))}%`); + + case PkiFilterOperator.STARTS_WITH: + return query.whereILike(columnName, `${sanitizeLikeInput(String(value))}%`); + + case PkiFilterOperator.ENDS_WITH: + return query.whereILike(columnName, `%${sanitizeLikeInput(String(value))}`); + + default: + logger.warn(`Unsupported operator for CA common_name: ${String(filter.operator)}`); + return query; + } +}; + +export const applyCaFilters = ( + query: Knex.QueryBuilder, + filters: TPkiFilterRule[], + projectId: string +): Knex.QueryBuilder => { + let filteredQuery = query.where(`${TableName.CertificateAuthority}.projectId`, projectId).whereNotNull("ica.caId"); // Only include CAs that have internal CA data + + filters.forEach((filter) => { + switch (filter.field) { + case PkiFilterField.COMMON_NAME: + filteredQuery = applyCaCommonNameFilter(filteredQuery, filter); + break; + + default: + break; + } + }); + + return filteredQuery; +}; + +export const validateFilterRules = (filters: TPkiFilterRule[]): void => { + for (const filter of filters) { + if (!Object.values(PkiFilterField).includes(filter.field)) { + throw new Error(`Invalid filter field: ${filter.field}`); + } + + if (!Object.values(PkiFilterOperator).includes(filter.operator)) { + throw new Error(`Invalid filter operator: ${filter.operator}`); + } + + switch (filter.field) { + case PkiFilterField.INCLUDE_CAS: + if (typeof filter.value !== "boolean") { + throw new Error("include_cas filter value must be boolean"); + } + break; + + case PkiFilterField.PROFILE_NAME: + case PkiFilterField.COMMON_NAME: + case PkiFilterField.SAN: + if (filter.operator === PkiFilterOperator.CONTAINS || filter.operator === PkiFilterOperator.MATCHES) { + if (!Array.isArray(filter.value) && typeof filter.value !== "string") { + throw new Error( + `${filter.field} filter value must be string or array of strings for ${filter.operator} operator` + ); + } + } else if (typeof filter.value !== "string") { + throw new Error(`${filter.field} filter value must be string for ${filter.operator} operator`); + } + break; + + default: + break; + } + } +}; + +export const requiresProfileJoin = (filters: TPkiFilterRule[]): boolean => { + return filters.some((filter) => filter.field === PkiFilterField.PROFILE_NAME); +}; + +export const applyCertificateFilters = ( + query: Knex.QueryBuilder, + filters: TPkiFilterRule[], + projectId: string +): Knex.QueryBuilder => { + let filteredQuery = query.where(`${TableName.Certificate}.projectId`, projectId); + + const needsProfileJoin = requiresProfileJoin(filters); + if (needsProfileJoin) { + filteredQuery = filteredQuery.leftJoin( + `${TableName.PkiCertificateProfile} as profile`, + `${TableName.Certificate}.profileId`, + "profile.id" + ); + } + + filters.forEach((filter) => { + switch (filter.field) { + case PkiFilterField.PROFILE_NAME: + filteredQuery = applyProfileNameFilter(filteredQuery, filter); + break; + + case PkiFilterField.COMMON_NAME: + filteredQuery = applyCommonNameFilter(filteredQuery, filter); + break; + + case PkiFilterField.SAN: + filteredQuery = applySanFilter(filteredQuery, filter); + break; + + case PkiFilterField.INCLUDE_CAS: + break; + + default: + logger.warn(`Unknown filter field: ${String(filter.field)}`); + break; + } + }); + + return filteredQuery; +}; diff --git a/backend/src/services/pki-alert-v2/pki-alert-v2-queue.ts b/backend/src/services/pki-alert-v2/pki-alert-v2-queue.ts new file mode 100644 index 0000000000..81b4bc9ef2 --- /dev/null +++ b/backend/src/services/pki-alert-v2/pki-alert-v2-queue.ts @@ -0,0 +1,220 @@ +/* eslint-disable no-await-in-loop */ + +import { getConfig } from "@app/lib/config/env"; +import { logger } from "@app/lib/logger"; +import { QueueJobs, QueueName, TQueueServiceFactory } from "@app/queue"; + +import { TPkiAlertHistoryDALFactory } from "./pki-alert-history-dal"; +import { TPkiAlertV2DALFactory } from "./pki-alert-v2-dal"; +import { parseTimeToDays, parseTimeToPostgresInterval } from "./pki-alert-v2-filter-utils"; +import { TPkiAlertV2ServiceFactory } from "./pki-alert-v2-service"; +import { CertificateOrigin, PkiAlertEventType, TPkiFilterRule } from "./pki-alert-v2-types"; + +type TPkiAlertV2QueueServiceFactoryDep = { + queueService: TQueueServiceFactory; + pkiAlertV2Service: Pick; + pkiAlertV2DAL: Pick; + pkiAlertHistoryDAL: Pick; +}; + +export type TPkiAlertV2QueueServiceFactory = ReturnType; + +export const pkiAlertV2QueueServiceFactory = ({ + queueService, + pkiAlertV2Service, + pkiAlertV2DAL, + pkiAlertHistoryDAL +}: TPkiAlertV2QueueServiceFactoryDep) => { + const appCfg = getConfig(); + const calculateDeduplicationWindow = (alertBefore: string): number => { + const alertDays = parseTimeToDays(alertBefore); + + if (alertDays === 0) { + return 24; + } + + if (alertDays <= 1) { + return 8; + } + if (alertDays <= 7) { + return 24; + } + if (alertDays <= 30) { + return 48; + } + if (alertDays <= 90) { + return 168; + } + return 720; + }; + + const getAllProjectsWithAlerts = async (): Promise => { + try { + const projectIds = await pkiAlertV2DAL.getDistinctProjectIds({ enabled: true }); + + logger.info(`Found ${projectIds.length} projects with PKI alerts`); + return projectIds; + } catch (error) { + logger.error(error, "Failed to get projects with alerts"); + return []; + } + }; + + const evaluateAlert = async ( + alert: { + id: string; + name: string; + eventType: string; + alertBefore: string; + filters: TPkiFilterRule[]; + }, + projectId: string + ): Promise<{ shouldNotify: boolean; certificateIds: string[] }> => { + if (alert.eventType !== PkiAlertEventType.EXPIRATION) { + return { shouldNotify: false, certificateIds: [] }; + } + + try { + const result = await pkiAlertV2DAL.findMatchingCertificates(projectId, alert.filters, { + limit: 1000, + alertBefore: parseTimeToPostgresInterval(alert.alertBefore), + showCurrentMatches: true + }); + + if (result.certificates.length === 0) { + return { shouldNotify: false, certificateIds: [] }; + } + + const allCertificateIds = result.certificates + .filter((cert) => cert.enrollmentType !== CertificateOrigin.CA) + .map((cert) => cert.id); + + const deduplicationHours = calculateDeduplicationWindow(alert.alertBefore); + const recentlyAlertedIds = await pkiAlertHistoryDAL.findRecentlyAlertedCertificates( + alert.id, + allCertificateIds, + deduplicationHours + ); + + const certificateIds = allCertificateIds.filter((certId) => !recentlyAlertedIds.includes(certId)); + + if (certificateIds.length === 0) { + logger.debug( + `All ${allCertificateIds.length} matching certificates for alert ${alert.id} were already alerted within the last ${deduplicationHours} hours` + ); + return { shouldNotify: false, certificateIds: [] }; + } + + logger.debug( + `Alert ${alert.id}: Found ${allCertificateIds.length} expiring certificates, ${recentlyAlertedIds.length} already alerted recently, ${certificateIds.length} new to alert` + ); + + return { + shouldNotify: true, + certificateIds + }; + } catch (error) { + logger.error(error, `Failed to evaluate alert ${alert.id}`); + return { shouldNotify: false, certificateIds: [] }; + } + }; + + const processProjectAlerts = async ( + projectId: string + ): Promise<{ alertsProcessed: number; notificationsSent: number }> => { + logger.info(`Processing alerts for project: ${projectId}`); + + const alerts = await pkiAlertV2DAL.findByProjectId(projectId, { + enabled: true, + limit: 1000 + }); + + let alertsProcessed = 0; + let notificationsSent = 0; + + for (const alert of alerts) { + const typedAlert = alert as { + id: string; + name: string; + eventType: string; + alertBefore: string; + filters: TPkiFilterRule[]; + }; + try { + const { shouldNotify, certificateIds } = await evaluateAlert(typedAlert, projectId); + + if (shouldNotify && certificateIds.length > 0) { + await pkiAlertV2Service.sendAlertNotifications(typedAlert.id, certificateIds); + notificationsSent += 1; + logger.info( + `Sent notification for alert ${typedAlert.id} (${typedAlert.name}) with ${certificateIds.length} certificates` + ); + } + + alertsProcessed += 1; + } catch (error) { + logger.error(error, `Failed to process alert ${typedAlert.id} (${typedAlert.name})`); + } + } + + logger.info( + `Completed processing ${alertsProcessed} alerts for project ${projectId}, sent ${notificationsSent} notifications` + ); + + return { alertsProcessed, notificationsSent }; + }; + + const processDailyAlerts = async () => { + logger.info("Starting daily PKI alert processing..."); + + const allProjects = await getAllProjectsWithAlerts(); + + let totalAlertsProcessed = 0; + let totalNotificationsSent = 0; + + for (const projectId of allProjects) { + try { + const { alertsProcessed, notificationsSent } = await processProjectAlerts(projectId); + totalAlertsProcessed += alertsProcessed; + totalNotificationsSent += notificationsSent; + } catch (error) { + logger.error(error, `Failed to process alerts for project ${projectId}`); + } + } + + logger.info( + `Daily PKI alert processing completed. Processed ${totalAlertsProcessed} alerts, sent ${totalNotificationsSent} notifications.` + ); + }; + + const init = async () => { + if (appCfg.isSecondaryInstance) { + return; + } + + await queueService.startPg( + QueueJobs.DailyPkiAlertV2Processing, + async () => { + try { + logger.info(`${QueueJobs.DailyPkiAlertV2Processing}: queue task started`); + await processDailyAlerts(); + logger.info(`${QueueJobs.DailyPkiAlertV2Processing}: queue task completed successfully`); + } catch (error) { + logger.error(error, `${QueueJobs.DailyPkiAlertV2Processing}: queue task failed`); + throw error; + } + }, + { + batchSize: 1, + workerCount: 1, + pollingIntervalSeconds: 60 + } + ); + + await queueService.schedulePg(QueueJobs.DailyPkiAlertV2Processing, "0 0 * * *", undefined, { tz: "UTC" }); + }; + + return { + init + }; +}; diff --git a/backend/src/services/pki-alert-v2/pki-alert-v2-service.ts b/backend/src/services/pki-alert-v2/pki-alert-v2-service.ts new file mode 100644 index 0000000000..2deb18c62f --- /dev/null +++ b/backend/src/services/pki-alert-v2/pki-alert-v2-service.ts @@ -0,0 +1,507 @@ +import { ForbiddenError } from "@casl/ability"; + +import { ActionProjectType } from "@app/db/schemas"; +import { TPermissionServiceFactory } from "@app/ee/services/permission/permission-service-types"; +import { ProjectPermissionActions, ProjectPermissionSub } from "@app/ee/services/permission/project-permission"; +import { BadRequestError, NotFoundError } from "@app/lib/errors"; +import { logger } from "@app/lib/logger"; +import { SmtpTemplates, TSmtpService } from "@app/services/smtp/smtp-service"; + +import { TPkiAlertChannelDALFactory } from "./pki-alert-channel-dal"; +import { TPkiAlertHistoryDALFactory } from "./pki-alert-history-dal"; +import { TPkiAlertV2DALFactory } from "./pki-alert-v2-dal"; +import { parseTimeToDays, parseTimeToPostgresInterval } from "./pki-alert-v2-filter-utils"; +import { + CertificateOrigin, + PkiAlertChannelType, + PkiAlertEventType, + TAlertV2Response, + TChannelConfig, + TCreateAlertV2DTO, + TDeleteAlertV2DTO, + TEmailChannelConfig, + TGetAlertV2DTO, + TListAlertsV2DTO, + TListAlertsV2Response, + TListCurrentMatchingCertificatesDTO, + TListMatchingCertificatesDTO, + TListMatchingCertificatesResponse, + TPkiFilterRule, + TUpdateAlertV2DTO +} from "./pki-alert-v2-types"; + +type TPkiAlertV2ServiceFactoryDep = { + pkiAlertV2DAL: Pick< + TPkiAlertV2DALFactory, + | "create" + | "findById" + | "findByIdWithChannels" + | "updateById" + | "deleteById" + | "findByProjectId" + | "findByProjectIdWithCount" + | "countByProjectId" + | "findMatchingCertificates" + | "transaction" + >; + pkiAlertChannelDAL: Pick; + pkiAlertHistoryDAL: Pick; + permissionService: Pick; + smtpService: Pick; +}; + +export type TPkiAlertV2ServiceFactory = ReturnType; + +export const pkiAlertV2ServiceFactory = ({ + pkiAlertV2DAL, + pkiAlertChannelDAL, + pkiAlertHistoryDAL, + permissionService, + smtpService +}: TPkiAlertV2ServiceFactoryDep) => { + type TAlertWithChannels = { + id: string; + name: string; + description: string; + eventType: string; + alertBefore: string; + filters: TPkiFilterRule[]; + enabled: boolean; + projectId: string; + createdAt: Date; + updatedAt: Date; + channels?: Array<{ + id: string; + channelType: string; + config: unknown; + enabled: boolean; + createdAt: Date; + updatedAt: Date; + }>; + }; + + const formatAlertResponse = (alert: TAlertWithChannels): TAlertV2Response => { + return { + id: alert.id, + name: alert.name, + description: alert.description, + eventType: alert.eventType as PkiAlertEventType, + alertBefore: alert.alertBefore, + filters: alert.filters, + enabled: alert.enabled, + projectId: alert.projectId, + channels: (alert.channels || []).map((channel) => ({ + id: channel.id, + channelType: channel.channelType as PkiAlertChannelType, + config: channel.config as TChannelConfig, + enabled: channel.enabled, + createdAt: channel.createdAt, + updatedAt: channel.updatedAt + })), + createdAt: alert.createdAt, + updatedAt: alert.updatedAt + }; + }; + + const createAlert = async ({ + projectId, + name, + description, + eventType, + alertBefore, + filters, + enabled = true, + channels, + actorId, + actorAuthMethod, + actor, + actorOrgId + }: TCreateAlertV2DTO): Promise => { + const { permission } = await permissionService.getProjectPermission({ + actor, + actorId, + projectId, + actorAuthMethod, + actorOrgId, + actionProjectType: ActionProjectType.CertificateManager + }); + + ForbiddenError.from(permission).throwUnlessCan(ProjectPermissionActions.Create, ProjectPermissionSub.PkiAlerts); + + try { + parseTimeToPostgresInterval(alertBefore); + } catch (error) { + throw new BadRequestError({ message: "Invalid alertBefore format. Use format like '30d', '1w', '3m', '1y'" }); + } + + return pkiAlertV2DAL.transaction(async (tx) => { + const alert = await pkiAlertV2DAL.create( + { + projectId, + name, + description, + eventType, + alertBefore, + filters, + enabled + }, + tx + ); + + const channelInserts = channels.map((channel) => ({ + alertId: alert.id, + channelType: channel.channelType, + config: channel.config, + enabled: channel.enabled + })); + + await pkiAlertChannelDAL.insertMany(channelInserts, tx); + + const completeAlert = await pkiAlertV2DAL.findByIdWithChannels(alert.id, tx); + if (!completeAlert) { + throw new NotFoundError({ message: "Failed to retrieve created alert" }); + } + + return formatAlertResponse(completeAlert as TAlertWithChannels); + }); + }; + + const getAlertById = async ({ + alertId, + actorId, + actorAuthMethod, + actor, + actorOrgId + }: TGetAlertV2DTO): Promise => { + const alert = await pkiAlertV2DAL.findByIdWithChannels(alertId); + if (!alert) throw new NotFoundError({ message: `Alert with ID '${alertId}' not found` }); + + const { permission } = await permissionService.getProjectPermission({ + actor, + actorId, + projectId: (alert as { projectId: string }).projectId, + actorAuthMethod, + actorOrgId, + actionProjectType: ActionProjectType.CertificateManager + }); + + ForbiddenError.from(permission).throwUnlessCan(ProjectPermissionActions.Read, ProjectPermissionSub.PkiAlerts); + + return formatAlertResponse(alert as TAlertWithChannels); + }; + + const listAlerts = async ({ + projectId, + search, + eventType, + enabled, + limit = 20, + offset = 0, + actorId, + actorAuthMethod, + actor, + actorOrgId + }: TListAlertsV2DTO): Promise => { + const { permission } = await permissionService.getProjectPermission({ + actor, + actorId, + projectId, + actorAuthMethod, + actorOrgId, + actionProjectType: ActionProjectType.CertificateManager + }); + + ForbiddenError.from(permission).throwUnlessCan(ProjectPermissionActions.Read, ProjectPermissionSub.PkiAlerts); + + const filters = { search, eventType, enabled, limit, offset }; + + const { alerts, total } = await pkiAlertV2DAL.findByProjectIdWithCount(projectId, filters); + + return { + alerts: alerts.map((alert) => formatAlertResponse(alert as TAlertWithChannels)), + total + }; + }; + + const updateAlert = async ({ + alertId, + name, + description, + eventType, + alertBefore, + filters, + enabled, + channels, + actorId, + actorAuthMethod, + actor, + actorOrgId + }: TUpdateAlertV2DTO): Promise => { + let alert = await pkiAlertV2DAL.findById(alertId); + if (!alert) throw new NotFoundError({ message: `Alert with ID '${alertId}' not found` }); + + const { permission } = await permissionService.getProjectPermission({ + actor, + actorId, + projectId: (alert as { projectId: string }).projectId, + actorAuthMethod, + actorOrgId, + actionProjectType: ActionProjectType.CertificateManager + }); + + ForbiddenError.from(permission).throwUnlessCan(ProjectPermissionActions.Edit, ProjectPermissionSub.PkiAlerts); + + if (alertBefore) { + try { + parseTimeToPostgresInterval(alertBefore); + } catch (error) { + throw new BadRequestError({ message: "Invalid alertBefore format. Use format like '30d', '1w', '3m', '1y'" }); + } + } + + const updateData: { + name?: string; + description?: string; + eventType?: PkiAlertEventType; + alertBefore?: string; + filters?: TPkiFilterRule[]; + enabled?: boolean; + } = {}; + if (name !== undefined) updateData.name = name; + if (description !== undefined) updateData.description = description; + if (eventType !== undefined) updateData.eventType = eventType; + if (alertBefore !== undefined) updateData.alertBefore = alertBefore; + if (filters !== undefined) updateData.filters = filters; + if (enabled !== undefined) updateData.enabled = enabled; + + return pkiAlertV2DAL.transaction(async (tx) => { + alert = await pkiAlertV2DAL.updateById(alertId, updateData, tx); + + if (channels) { + await pkiAlertChannelDAL.deleteByAlertId(alertId, tx); + + const channelInserts = channels.map((channel) => ({ + alertId, + channelType: channel.channelType, + config: channel.config, + enabled: channel.enabled + })); + + await pkiAlertChannelDAL.insertMany(channelInserts, tx); + } + + const completeAlert = await pkiAlertV2DAL.findByIdWithChannels(alertId, tx); + if (!completeAlert) { + throw new NotFoundError({ message: "Failed to retrieve updated alert" }); + } + + return formatAlertResponse(completeAlert as TAlertWithChannels); + }); + }; + + const deleteAlert = async ({ + alertId, + actorId, + actorAuthMethod, + actor, + actorOrgId + }: TDeleteAlertV2DTO): Promise => { + const alert = await pkiAlertV2DAL.findByIdWithChannels(alertId); + if (!alert) throw new NotFoundError({ message: `Alert with ID '${alertId}' not found` }); + + const { permission } = await permissionService.getProjectPermission({ + actor, + actorId, + projectId: (alert as { projectId: string }).projectId, + actorAuthMethod, + actorOrgId, + actionProjectType: ActionProjectType.CertificateManager + }); + + ForbiddenError.from(permission).throwUnlessCan(ProjectPermissionActions.Delete, ProjectPermissionSub.PkiAlerts); + + const formattedAlert = formatAlertResponse(alert as TAlertWithChannels); + await pkiAlertV2DAL.deleteById(alertId); + + return formattedAlert; + }; + + const listMatchingCertificates = async ({ + alertId, + limit = 20, + offset = 0, + actorId, + actorAuthMethod, + actor, + actorOrgId + }: TListMatchingCertificatesDTO): Promise => { + const alert = await pkiAlertV2DAL.findById(alertId); + if (!alert) throw new NotFoundError({ message: `Alert with ID '${alertId}' not found` }); + + const { permission } = await permissionService.getProjectPermission({ + actor, + actorId, + projectId: (alert as { projectId: string }).projectId, + actorAuthMethod, + actorOrgId, + actionProjectType: ActionProjectType.CertificateManager + }); + + ForbiddenError.from(permission).throwUnlessCan(ProjectPermissionActions.Read, ProjectPermissionSub.PkiAlerts); + + const options: { + limit: number; + offset: number; + showPreview?: boolean; + excludeAlerted?: boolean; + alertId?: string; + } = { + limit, + offset, + showPreview: true, + excludeAlerted: (alert as { eventType: string }).eventType === PkiAlertEventType.EXPIRATION, + alertId + }; + + const result = await pkiAlertV2DAL.findMatchingCertificates( + (alert as { projectId: string }).projectId, + (alert as { filters: TPkiFilterRule[] }).filters, + options + ); + + return { + certificates: result.certificates, + total: result.total + }; + }; + + const listCurrentMatchingCertificates = async ({ + projectId, + filters, + alertBefore, + limit = 20, + offset = 0, + actorId, + actorAuthMethod, + actor, + actorOrgId + }: TListCurrentMatchingCertificatesDTO): Promise => { + const { permission } = await permissionService.getProjectPermission({ + actor, + actorId, + projectId, + actorAuthMethod, + actorOrgId, + actionProjectType: ActionProjectType.CertificateManager + }); + + ForbiddenError.from(permission).throwUnlessCan(ProjectPermissionActions.Read, ProjectPermissionSub.PkiAlerts); + + try { + parseTimeToPostgresInterval(alertBefore); + } catch (error) { + throw new BadRequestError({ message: "Invalid alertBefore format. Use format like '30d', '1w', '3m', '1y'" }); + } + + const options: { + limit: number; + offset: number; + showPreview?: boolean; + alertBefore?: string; + } = { + limit, + offset, + showPreview: true, + alertBefore: parseTimeToPostgresInterval(alertBefore) + }; + + const result = await pkiAlertV2DAL.findMatchingCertificates(projectId, filters, options); + + return { + certificates: result.certificates, + total: result.total + }; + }; + + const sendAlertNotifications = async (alertId: string, certificateIds: string[]) => { + const alert = await pkiAlertV2DAL.findByIdWithChannels(alertId); + if (!alert || !(alert as { enabled: boolean }).enabled) return; + + const channels = + (alert as { channels?: Array<{ enabled: boolean; channelType: string; config: unknown }> }).channels?.filter( + (channel: { enabled: boolean; channelType: string; config: unknown }) => channel.enabled + ) || []; + if (channels.length === 0) return; + + const { certificates } = await pkiAlertV2DAL.findMatchingCertificates( + (alert as { projectId: string }).projectId, + (alert as { filters: TPkiFilterRule[] }).filters, + { + alertBefore: parseTimeToPostgresInterval((alert as { alertBefore: string }).alertBefore) + } + ); + + const matchingCertificates = certificates.filter( + (cert) => certificateIds.includes(cert.id) && cert.enrollmentType !== CertificateOrigin.CA + ); + + if (matchingCertificates.length === 0) return; + + let hasNotificationSent = false; + let notificationError: string | undefined; + + try { + const emailChannels = channels.filter( + (channel: { enabled: boolean; channelType: string; config: unknown }) => + channel.channelType === PkiAlertChannelType.EMAIL + ); + + const alertBeforeDays = parseTimeToDays((alert as { alertBefore: string }).alertBefore); + const alertName = (alert as { name: string }).name; + + const emailPromises = emailChannels.map((channel) => { + const config = channel.config as TEmailChannelConfig; + + return smtpService.sendMail({ + recipients: config.recipients, + subjectLine: `Infisical Certificate Alert - ${alertName}`, + substitutions: { + alertName, + alertBeforeDays, + projectId: (alert as { projectId: string }).projectId, + items: matchingCertificates.map((cert) => ({ + type: "Certificate", + friendlyName: cert.commonName, + serialNumber: cert.serialNumber, + expiryDate: cert.notAfter.toLocaleDateString() + })) + }, + template: SmtpTemplates.PkiExpirationAlert + }); + }); + + await Promise.all(emailPromises); + + hasNotificationSent = true; + } catch (error) { + notificationError = error instanceof Error ? error.message : "Unknown error occurred"; + logger.error(error, `Failed to send notifications for alert ${alertId}`); + } + + await pkiAlertHistoryDAL.createWithCertificates(alertId, certificateIds, { + hasNotificationSent, + notificationError + }); + }; + + return { + createAlert, + getAlertById, + listAlerts, + updateAlert, + deleteAlert, + listMatchingCertificates, + listCurrentMatchingCertificates, + sendAlertNotifications + }; +}; diff --git a/backend/src/services/pki-alert-v2/pki-alert-v2-types.ts b/backend/src/services/pki-alert-v2/pki-alert-v2-types.ts new file mode 100644 index 0000000000..fe6055402b --- /dev/null +++ b/backend/src/services/pki-alert-v2/pki-alert-v2-types.ts @@ -0,0 +1,205 @@ +import RE2 from "re2"; +import { z } from "zod"; + +import { TGenericPermission } from "@app/lib/types"; + +const createSecureNameValidator = () => { + // Validates name format: lowercase alphanumeric characters with optional hyphens + // Pattern: starts and ends with alphanumeric, allows hyphens between segments + // Examples: "my-alert", "alert1", "test-alert-2" + const nameRegex = new RE2("^[a-z0-9]+(?:-[a-z0-9]+)*$"); + return (value: string) => nameRegex.test(value); +}; + +export const createSecureAlertBeforeValidator = () => { + // Validates alertBefore duration format: number followed by time unit + // Pattern: one or more digits followed by d(days), w(weeks), m(months), or y(years) + // Examples: "30d", "2w", "6m", "1y" + const alertBeforeRegex = new RE2("^\\d+[dwmy]$"); + return (value: string) => { + if (value.length > 32) return false; + return alertBeforeRegex.test(value); + }; +}; + +export enum PkiAlertEventType { + EXPIRATION = "expiration", + RENEWAL = "renewal", + ISSUANCE = "issuance", + REVOCATION = "revocation" +} + +export enum PkiAlertChannelType { + EMAIL = "email", + WEBHOOK = "webhook", + SLACK = "slack" +} + +export enum PkiFilterOperator { + EQUALS = "equals", + MATCHES = "matches", + CONTAINS = "contains", + STARTS_WITH = "starts_with", + ENDS_WITH = "ends_with" +} + +export enum PkiFilterField { + PROFILE_NAME = "profile_name", + COMMON_NAME = "common_name", + SAN = "san", + INCLUDE_CAS = "include_cas" +} + +export enum CertificateOrigin { + UNKNOWN = "unknown", + PROFILE = "profile", + IMPORT = "import", + CA = "ca" +} + +export const PkiFilterRuleSchema = z.object({ + field: z.nativeEnum(PkiFilterField), + operator: z.nativeEnum(PkiFilterOperator), + value: z.union([z.string(), z.array(z.string()), z.boolean()]) +}); + +export type TPkiFilterRule = z.infer; + +export const PkiFiltersSchema = z.array(PkiFilterRuleSchema); +export type TPkiFilters = z.infer; + +export const EmailChannelConfigSchema = z.object({ + recipients: z.array(z.string().email()).min(1).max(10) +}); + +export const WebhookChannelConfigSchema = z.object({ + url: z.string().url(), + method: z.enum(["POST", "PUT"]).default("POST"), + headers: z.record(z.string()).optional() +}); + +export const SlackChannelConfigSchema = z.object({ + webhookUrl: z.string().url(), + channel: z.string().optional(), + mentionUsers: z.array(z.string()).optional() +}); + +export const ChannelConfigSchema = z.union([ + EmailChannelConfigSchema, + WebhookChannelConfigSchema, + SlackChannelConfigSchema +]); + +export type TEmailChannelConfig = z.infer; +export type TWebhookChannelConfig = z.infer; +export type TSlackChannelConfig = z.infer; +export type TChannelConfig = z.infer; + +export const CreateChannelSchema = z.object({ + channelType: z.nativeEnum(PkiAlertChannelType), + config: ChannelConfigSchema, + enabled: z.boolean().default(true) +}); + +export type TCreateChannel = z.infer; + +export const CreatePkiAlertV2Schema = z.object({ + name: z + .string() + .min(1) + .max(255) + .refine(createSecureNameValidator(), "Must be a valid name (lowercase, numbers, hyphens only)"), + description: z.string().max(1000).optional(), + eventType: z.nativeEnum(PkiAlertEventType), + alertBefore: z.string().refine(createSecureAlertBeforeValidator(), "Must be in format like '30d', '1w', '3m', '1y'"), + filters: PkiFiltersSchema, + enabled: z.boolean().default(true), + channels: z.array(CreateChannelSchema).min(1, "At least one channel is required") +}); + +export type TCreatePkiAlertV2 = z.infer; + +export const UpdatePkiAlertV2Schema = CreatePkiAlertV2Schema.partial(); +export type TUpdatePkiAlertV2 = z.infer; + +export type TCreateAlertV2DTO = TGenericPermission & { + projectId: string; +} & TCreatePkiAlertV2; + +export type TUpdateAlertV2DTO = TGenericPermission & { + alertId: string; +} & TUpdatePkiAlertV2; + +export type TGetAlertV2DTO = TGenericPermission & { + alertId: string; +}; + +export type TDeleteAlertV2DTO = TGenericPermission & { + alertId: string; +}; + +export type TListAlertsV2DTO = TGenericPermission & { + projectId: string; + search?: string; + eventType?: PkiAlertEventType; + enabled?: boolean; + limit?: number; + offset?: number; +}; + +export type TListMatchingCertificatesDTO = TGenericPermission & { + alertId: string; + limit?: number; + offset?: number; +}; + +export type TListCurrentMatchingCertificatesDTO = TGenericPermission & { + projectId: string; + filters: TPkiFilters; + alertBefore: string; + limit?: number; + offset?: number; +}; + +export type TCertificatePreview = { + id: string; + serialNumber: string; + commonName: string; + san: string[]; + profileName: string | null; + enrollmentType: CertificateOrigin | null; + notBefore: Date; + notAfter: Date; + status: string; +}; + +export type TAlertV2Response = { + id: string; + name: string; + description: string | null; + eventType: PkiAlertEventType; + alertBefore: string; + filters: TPkiFilters; + enabled: boolean; + projectId: string; + channels: Array<{ + id: string; + channelType: PkiAlertChannelType; + config: TChannelConfig; + enabled: boolean; + createdAt: Date; + updatedAt: Date; + }>; + createdAt: Date; + updatedAt: Date; +}; + +export type TListAlertsV2Response = { + alerts: TAlertV2Response[]; + total: number; +}; + +export type TListMatchingCertificatesResponse = { + certificates: TCertificatePreview[]; + total: number; +}; diff --git a/backend/src/services/smtp/emails/PkiExpirationAlertTemplate.tsx b/backend/src/services/smtp/emails/PkiExpirationAlertTemplate.tsx index a09a125a28..f7a89f4e14 100644 --- a/backend/src/services/smtp/emails/PkiExpirationAlertTemplate.tsx +++ b/backend/src/services/smtp/emails/PkiExpirationAlertTemplate.tsx @@ -1,11 +1,12 @@ -import { Heading, Hr, Section, Text } from "@react-email/components"; -import React, { Fragment } from "react"; +import { Heading, Section, Text } from "@react-email/components"; +import { BaseButton } from "./BaseButton"; import { BaseEmailWrapper, BaseEmailWrapperProps } from "./BaseEmailWrapper"; interface PkiExpirationAlertTemplateProps extends Omit { alertName: string; alertBeforeDays: number; + projectId: string; items: { type: string; friendlyName: string; serialNumber: string; expiryDate: string }[]; } @@ -13,44 +14,56 @@ export const PkiExpirationAlertTemplate = ({ alertName, siteUrl, alertBeforeDays, + projectId, items }: PkiExpirationAlertTemplateProps) => { + const formatDate = (dateStr: string) => { + try { + return new Date(dateStr).toLocaleDateString("en-US", { + year: "numeric", + month: "long", + day: "numeric" + }); + } catch { + return dateStr; + } + }; + + const certificateText = items.length === 1 ? "certificate" : "certificates"; + const daysText = alertBeforeDays === 1 ? "1 day" : `${alertBeforeDays} days`; + + const message = `Alert ${alertName}: You have ${items.length === 1 ? "one" : items.length} ${certificateText} that will expire in ${daysText}.`; + return ( - + - CA/Certificate Expiration Notice + Certificate Expiration Notice -
- Hello, - - This is an automated alert for {alertName} triggered for CAs/Certificates expiring in{" "} - {alertBeforeDays} days. - - - Expiring Items: + +
+ + Alert {alertName}: You have{" "} + {items.length === 1 ? "one" : items.length} {certificateText} that will expire in {daysText}. +
+
+ Expiring certificates: {items.map((item) => ( - -
- {item.type}: - {item.friendlyName} - Serial Number: - {item.serialNumber} - Expires On: - {item.expiryDate} -
+
+ {item.friendlyName} + Serial: {item.serialNumber} + Expires: {formatDate(item.expiryDate)} +
))} -
- - Please take the necessary actions to renew these items before they expire. - - - For more details, please log in to your Infisical account and check your PKI management section. - +
+ +
+ + View Certificate Alerts +
); @@ -59,11 +72,22 @@ export const PkiExpirationAlertTemplate = ({ export default PkiExpirationAlertTemplate; PkiExpirationAlertTemplate.PreviewProps = { - alertBeforeDays: 5, + alertBeforeDays: 7, items: [ - { type: "CA", friendlyName: "Example CA", serialNumber: "1234567890", expiryDate: "2032-01-01" }, - { type: "Certificate", friendlyName: "Example Certificate", serialNumber: "2345678901", expiryDate: "2032-01-01" } + { + type: "Certificate", + friendlyName: "api.production.company.com", + serialNumber: "4B:3E:2F:A1:D6:7C:89:45:B2:E8:7F:1A:3D:9C:5E:8B", + expiryDate: "2025-11-12" + }, + { + type: "Certificate", + friendlyName: "web.company.com", + serialNumber: "8A:7F:1C:E4:92:B5:D3:68:F1:A2:7E:9B:4C:6D:5A:3F", + expiryDate: "2025-11-10" + } ], - alertName: "My PKI Alert", + alertName: "Production SSL Certificate Expiration Alert", + projectId: "c3b0ef29-915b-4cb1-8684-65b91b7fe02d", siteUrl: "https://infisical.com" } as PkiExpirationAlertTemplateProps; diff --git a/frontend/src/hooks/api/pkiAlertsV2/index.ts b/frontend/src/hooks/api/pkiAlertsV2/index.ts new file mode 100644 index 0000000000..4f822c74c1 --- /dev/null +++ b/frontend/src/hooks/api/pkiAlertsV2/index.ts @@ -0,0 +1,30 @@ +export { useCreatePkiAlertV2, useDeletePkiAlertV2, useUpdatePkiAlertV2 } from "./mutations"; +export { + pkiAlertsV2Keys, + useGetPkiAlertsV2, + useGetPkiAlertV2ById, + useGetPkiAlertV2CurrentMatchingCertificates, + useGetPkiAlertV2MatchingCertificates +} from "./queries"; +export type { + TCreatePkiAlertV2, + TDeletePkiAlertV2, + TGetPkiAlertsV2, + TGetPkiAlertV2ById, + TGetPkiAlertV2CurrentMatchingCertificates, + TGetPkiAlertV2CurrentMatchingCertificatesResponse, + TGetPkiAlertV2MatchingCertificates, + TPkiAlertChannelConfigEmail, + TPkiAlertChannelV2, + TPkiAlertV2, + TPkiFilterRuleV2, + TUpdatePkiAlertV2 +} from "./types"; +export { + createPkiAlertV2Schema, + PkiAlertChannelTypeV2, + PkiAlertEventTypeV2, + PkiFilterFieldV2, + PkiFilterOperatorV2, + updatePkiAlertV2Schema +} from "./types"; diff --git a/frontend/src/hooks/api/pkiAlertsV2/mutations.ts b/frontend/src/hooks/api/pkiAlertsV2/mutations.ts new file mode 100644 index 0000000000..7092cf7b54 --- /dev/null +++ b/frontend/src/hooks/api/pkiAlertsV2/mutations.ts @@ -0,0 +1,68 @@ +import { useMutation, useQueryClient } from "@tanstack/react-query"; + +import { apiRequest } from "@app/config/request"; + +import { pkiAlertsV2Keys } from "./queries"; +import { TCreatePkiAlertV2, TDeletePkiAlertV2, TPkiAlertV2, TUpdatePkiAlertV2 } from "./types"; + +export const useCreatePkiAlertV2 = () => { + const queryClient = useQueryClient(); + + return useMutation({ + mutationFn: async (data) => { + const { data: response } = await apiRequest.post<{ alert: TPkiAlertV2 }>( + "/api/v2/pki/alerts", + data + ); + return response.alert; + }, + onSuccess: (_, variables) => { + queryClient.invalidateQueries({ + queryKey: pkiAlertsV2Keys.allPkiAlertsV2({ projectId: variables.projectId }) + }); + } + }); +}; + +export const useUpdatePkiAlertV2 = () => { + const queryClient = useQueryClient(); + + return useMutation({ + mutationFn: async ({ alertId, ...data }) => { + const { data: response } = await apiRequest.patch<{ alert: TPkiAlertV2 }>( + `/api/v2/pki/alerts/${alertId}`, + data + ); + return response.alert; + }, + onSuccess: (_, variables) => { + queryClient.invalidateQueries({ + queryKey: pkiAlertsV2Keys.specificPkiAlertV2(variables.alertId) + }); + queryClient.invalidateQueries({ + queryKey: pkiAlertsV2Keys.all + }); + } + }); +}; + +export const useDeletePkiAlertV2 = () => { + const queryClient = useQueryClient(); + + return useMutation({ + mutationFn: async ({ alertId }) => { + const { data } = await apiRequest.delete<{ alert: TPkiAlertV2 }>( + `/api/v2/pki/alerts/${alertId}` + ); + return data.alert; + }, + onSuccess: (_, variables) => { + queryClient.invalidateQueries({ + queryKey: pkiAlertsV2Keys.all + }); + queryClient.removeQueries({ + queryKey: pkiAlertsV2Keys.specificPkiAlertV2(variables.alertId) + }); + } + }); +}; diff --git a/frontend/src/hooks/api/pkiAlertsV2/queries.ts b/frontend/src/hooks/api/pkiAlertsV2/queries.ts new file mode 100644 index 0000000000..d139341ae3 --- /dev/null +++ b/frontend/src/hooks/api/pkiAlertsV2/queries.ts @@ -0,0 +1,139 @@ +import { useQuery, UseQueryOptions } from "@tanstack/react-query"; + +import { apiRequest } from "@app/config/request"; + +import { + TGetPkiAlertsV2, + TGetPkiAlertsV2Response, + TGetPkiAlertV2ById, + TGetPkiAlertV2CurrentMatchingCertificates, + TGetPkiAlertV2CurrentMatchingCertificatesResponse, + TGetPkiAlertV2MatchingCertificates, + TGetPkiAlertV2MatchingCertificatesResponse, + TPkiAlertV2 +} from "./types"; + +export const pkiAlertsV2Keys = { + all: ["pki-alerts-v2"] as const, + allPkiAlertsV2: (filters?: TGetPkiAlertsV2) => [pkiAlertsV2Keys.all[0], filters] as const, + specificPkiAlertV2: (alertId: string) => [...pkiAlertsV2Keys.all, alertId] as const, + pkiAlertV2MatchingCertificates: (alertId: string, filters?: TGetPkiAlertV2MatchingCertificates) => + [...pkiAlertsV2Keys.specificPkiAlertV2(alertId), "certificates", filters] as const, + pkiAlertV2CurrentMatchingCertificates: (filters?: TGetPkiAlertV2CurrentMatchingCertificates) => + [...pkiAlertsV2Keys.all, "current-certificates", filters] as const +}; + +const fetchPkiAlertsV2 = async (params: TGetPkiAlertsV2): Promise => { + const { data } = await apiRequest.get("/api/v2/pki/alerts", { + params + }); + return data; +}; + +const fetchPkiAlertV2ById = async ({ alertId }: TGetPkiAlertV2ById): Promise => { + const { data } = await apiRequest.get<{ alert: TPkiAlertV2 }>(`/api/v2/pki/alerts/${alertId}`); + return data.alert; +}; + +const fetchPkiAlertV2MatchingCertificates = async ( + params: TGetPkiAlertV2MatchingCertificates +): Promise => { + const { alertId, ...queryParams } = params; + const { data } = await apiRequest.get( + `/api/v2/pki/alerts/${alertId}/certificates`, + { params: queryParams } + ); + return data; +}; + +const fetchPkiAlertV2CurrentMatchingCertificates = async ( + params: TGetPkiAlertV2CurrentMatchingCertificates +): Promise => { + const { data } = await apiRequest.post( + "/api/v2/pki/alerts/preview/certificates", + params + ); + return data; +}; + +export const useGetPkiAlertsV2 = ( + params: TGetPkiAlertsV2, + options?: Omit< + UseQueryOptions< + TGetPkiAlertsV2Response, + unknown, + TGetPkiAlertsV2Response, + ReturnType + >, + "queryKey" | "queryFn" + > +) => { + return useQuery({ + queryKey: pkiAlertsV2Keys.allPkiAlertsV2(params), + queryFn: () => fetchPkiAlertsV2(params), + enabled: !!params.projectId, + ...options + }); +}; + +export const useGetPkiAlertV2ById = ( + params: TGetPkiAlertV2ById, + options?: Omit< + UseQueryOptions< + TPkiAlertV2, + unknown, + TPkiAlertV2, + ReturnType + >, + "queryKey" | "queryFn" + > +) => { + return useQuery({ + queryKey: pkiAlertsV2Keys.specificPkiAlertV2(params.alertId), + queryFn: () => fetchPkiAlertV2ById(params), + enabled: !!params.alertId, + ...options + }); +}; + +export const useGetPkiAlertV2MatchingCertificates = ( + params: TGetPkiAlertV2MatchingCertificates, + options?: Omit< + UseQueryOptions< + TGetPkiAlertV2MatchingCertificatesResponse, + unknown, + TGetPkiAlertV2MatchingCertificatesResponse, + ReturnType + >, + "queryKey" | "queryFn" + > +) => { + return useQuery({ + queryKey: pkiAlertsV2Keys.pkiAlertV2MatchingCertificates(params.alertId, params), + queryFn: () => fetchPkiAlertV2MatchingCertificates(params), + enabled: !!params.alertId, + placeholderData: (previousData) => previousData, + ...options + }); +}; + +export const useGetPkiAlertV2CurrentMatchingCertificates = ( + params: TGetPkiAlertV2CurrentMatchingCertificates, + options?: Omit< + UseQueryOptions< + TGetPkiAlertV2CurrentMatchingCertificatesResponse, + unknown, + TGetPkiAlertV2CurrentMatchingCertificatesResponse, + ReturnType + >, + "queryKey" | "queryFn" + > +) => { + return useQuery({ + queryKey: pkiAlertsV2Keys.pkiAlertV2CurrentMatchingCertificates(params), + queryFn: () => fetchPkiAlertV2CurrentMatchingCertificates(params), + enabled: !!params.projectId && params.filters !== undefined, + placeholderData: (previousData) => previousData, + ...options + }); +}; diff --git a/frontend/src/hooks/api/pkiAlertsV2/types.ts b/frontend/src/hooks/api/pkiAlertsV2/types.ts new file mode 100644 index 0000000000..0f824ed103 --- /dev/null +++ b/frontend/src/hooks/api/pkiAlertsV2/types.ts @@ -0,0 +1,192 @@ +import { z } from "zod"; + +export enum PkiAlertEventTypeV2 { + EXPIRATION = "expiration", + RENEWAL = "renewal", + ISSUANCE = "issuance", + REVOCATION = "revocation" +} + +export enum PkiAlertChannelTypeV2 { + EMAIL = "email" +} + +export enum PkiFilterFieldV2 { + COMMON_NAME = "common_name", + PROFILE_NAME = "profile_name", + SAN = "san", + INCLUDE_CAS = "include_cas" +} + +export enum PkiFilterOperatorV2 { + EQUALS = "equals", + CONTAINS = "contains", + STARTS_WITH = "starts_with", + ENDS_WITH = "ends_with", + MATCHES = "matches" +} + +export interface TPkiFilterRuleV2 { + field: PkiFilterFieldV2; + operator: PkiFilterOperatorV2; + value: string | string[] | boolean; +} + +export interface TPkiAlertChannelConfigEmail { + recipients: string[]; +} + +// In the future other channels like webhooks will be supported here +export type TPkiAlertChannelConfig = TPkiAlertChannelConfigEmail; + +export interface TPkiAlertChannelV2 { + id: string; + channelType: PkiAlertChannelTypeV2; + config: TPkiAlertChannelConfig; + enabled: boolean; + createdAt: string; + updatedAt: string; +} + +export interface TPkiAlertV2 { + id: string; + projectId: string; + name: string; + description?: string; + eventType: PkiAlertEventTypeV2; + alertBefore?: string; + filters: TPkiFilterRuleV2[]; + enabled: boolean; + channels: TPkiAlertChannelV2[]; + createdAt: string; + updatedAt: string; +} + +export interface TPkiCertificateMatchV2 { + id: string; + serialNumber: string; + commonName?: string; + san?: string[]; + profileName?: string; + enrollmentType?: string; + notBefore: string; + notAfter: string; + status: string; +} + +export interface TGetPkiAlertsV2 { + projectId: string; + search?: string; + eventType?: PkiAlertEventTypeV2; + enabled?: boolean; + limit?: number; + offset?: number; +} + +export interface TGetPkiAlertsV2Response { + alerts: TPkiAlertV2[]; + total: number; +} + +export interface TGetPkiAlertV2ById { + alertId: string; +} + +export interface TCreatePkiAlertV2 { + projectId: string; + name: string; + description?: string; + eventType: PkiAlertEventTypeV2; + alertBefore?: string; + filters: TPkiFilterRuleV2[]; + enabled?: boolean; + channels: Omit[]; +} + +export interface TUpdatePkiAlertV2 { + alertId: string; + name?: string; + description?: string; + eventType?: PkiAlertEventTypeV2; + alertBefore?: string; + filters?: TPkiFilterRuleV2[]; + enabled?: boolean; + channels?: Omit[]; +} + +export interface TDeletePkiAlertV2 { + alertId: string; +} + +export interface TGetPkiAlertV2MatchingCertificates { + alertId: string; + limit?: number; + offset?: number; +} + +export interface TGetPkiAlertV2MatchingCertificatesResponse { + certificates: TPkiCertificateMatchV2[]; + total: number; + limit: number; + offset: number; +} + +export interface TGetPkiAlertV2CurrentMatchingCertificates { + projectId: string; + filters: TPkiFilterRuleV2[]; + alertBefore: string; + limit?: number; + offset?: number; +} + +export interface TGetPkiAlertV2CurrentMatchingCertificatesResponse { + certificates: TPkiCertificateMatchV2[]; + total: number; + limit: number; + offset: number; +} + +export const pkiFilterRuleV2Schema = z.object({ + field: z.nativeEnum(PkiFilterFieldV2), + operator: z.nativeEnum(PkiFilterOperatorV2), + value: z.union([z.string(), z.array(z.string()), z.boolean()]) +}); + +const emailChannelConfigSchema = z.object({ + recipients: z + .array(z.string()) + .transform((emails) => emails.filter(Boolean).map((email) => email.trim())) + .refine((emails) => emails.length > 0, "At least one email recipient is required") + .refine((emails) => emails.length <= 10, "Maximum 10 email recipients allowed") + .refine( + (emails) => emails.every((email) => z.string().email().safeParse(email).success), + "All recipients must be valid email addresses" + ) +}); + +export const pkiAlertChannelV2Schema = z.object({ + channelType: z.nativeEnum(PkiAlertChannelTypeV2), + config: emailChannelConfigSchema, + enabled: z.boolean().default(true) +}); + +export const createPkiAlertV2Schema = z.object({ + projectId: z.string().uuid(), + name: z + .string() + .min(1) + .max(255) + .regex(/^[a-z0-9]+(?:-[a-z0-9]+)*$/, "Must be a valid name (lowercase, numbers, hyphens only)"), + description: z.string().max(1000).optional(), + eventType: z.nativeEnum(PkiAlertEventTypeV2), + alertBefore: z + .string() + .regex(/^\d+[dwmy]$/, "Must be in format like '30d', '1w', '3m', '1y'") + .refine((val) => val.length <= 32, "Alert timing too long") + .optional(), + filters: z.array(pkiFilterRuleV2Schema), + enabled: z.boolean().default(true), + channels: z.array(pkiAlertChannelV2Schema).min(1) +}); + +export const updatePkiAlertV2Schema = createPkiAlertV2Schema.partial().omit({ projectId: true }); diff --git a/frontend/src/pages/cert-manager/AlertingPage/AlertingPage.tsx b/frontend/src/pages/cert-manager/AlertingPage/AlertingPage.tsx index cf2c4291d7..6cc0a208a7 100644 --- a/frontend/src/pages/cert-manager/AlertingPage/AlertingPage.tsx +++ b/frontend/src/pages/cert-manager/AlertingPage/AlertingPage.tsx @@ -1,15 +1,27 @@ +import { useState } from "react"; import { Helmet } from "react-helmet"; import { useTranslation } from "react-i18next"; import { ProjectPermissionCan } from "@app/components/permissions"; -import { PageHeader } from "@app/components/v2"; -import { ProjectPermissionActions, ProjectPermissionSub } from "@app/context"; +import { PageHeader, Tab, TabList, TabPanel, Tabs } from "@app/components/v2"; +import { ProjectPermissionActions, ProjectPermissionSub, useProject } from "@app/context"; +import { useListWorkspacePkiAlerts } from "@app/hooks/api"; import { ProjectType } from "@app/hooks/api/projects/types"; +import { PkiAlertsV2Page } from "@app/views/PkiAlertsV2Page"; -import { PkiAlertsSection } from "./components"; +import { PkiAlertsSection, PkiCollectionSection } from "./components"; export const AlertingPage = () => { const { t } = useTranslation(); + const { currentProject } = useProject(); + const [selectedTab, setSelectedTab] = useState("rule-based"); + + const { data: v1AlertsData } = useListWorkspacePkiAlerts({ + projectId: currentProject?.id || "" + }); + + const hasV1Alerts = v1AlertsData?.alerts && v1AlertsData.alerts.length > 0; + return (
@@ -26,7 +38,33 @@ export const AlertingPage = () => { I={ProjectPermissionActions.Read} a={ProjectPermissionSub.PkiAlerts} > - + {!hasV1Alerts ? ( +
+ +
+ ) : ( + + + + Certificate Alerts + + + Collection Alerts (Legacy) + + + + + + + + +
+ + +
+
+
+ )}
diff --git a/frontend/src/pages/cert-manager/PoliciesPage/PoliciesPage.tsx b/frontend/src/pages/cert-manager/PoliciesPage/PoliciesPage.tsx index 5b2988775b..0653d44d04 100644 --- a/frontend/src/pages/cert-manager/PoliciesPage/PoliciesPage.tsx +++ b/frontend/src/pages/cert-manager/PoliciesPage/PoliciesPage.tsx @@ -9,7 +9,6 @@ import { ProjectType } from "@app/hooks/api/projects/types"; import { CertificateProfilesTab } from "./components/CertificateProfilesTab"; import { CertificatesTab } from "./components/CertificatesTab"; import { CertificateTemplatesV2Tab } from "./components/CertificateTemplatesV2Tab"; -import { PkiCollectionsTab } from "./components/PkiCollectionsTab"; enum TabSections { CertificateProfiles = "profiles", @@ -54,9 +53,6 @@ export const PoliciesPage = () => { Certificates - - Certificate Collections - @@ -70,10 +66,6 @@ export const PoliciesPage = () => { - - - - diff --git a/frontend/src/views/PkiAlertsV2Page/PkiAlertsV2Page.tsx b/frontend/src/views/PkiAlertsV2Page/PkiAlertsV2Page.tsx new file mode 100644 index 0000000000..6bd1562540 --- /dev/null +++ b/frontend/src/views/PkiAlertsV2Page/PkiAlertsV2Page.tsx @@ -0,0 +1,208 @@ +import { useState } from "react"; +import { faPlus, faSearch } from "@fortawesome/free-solid-svg-icons"; +import { FontAwesomeIcon } from "@fortawesome/react-fontawesome"; + +import { createNotification } from "@app/components/notifications"; +import { + Button, + DeleteActionModal, + Input, + Pagination, + Skeleton, + Table, + TableContainer, + TBody, + Td, + Th, + THead, + Tr +} from "@app/components/v2"; +import { useProject } from "@app/context"; +import { useDebounce } from "@app/hooks"; +import { useDeletePkiAlertV2, useGetPkiAlertsV2 } from "@app/hooks/api/pkiAlertsV2"; + +import { CreatePkiAlertV2Modal } from "./components/CreatePkiAlertV2Modal"; +import { PkiAlertV2Row } from "./components/PkiAlertV2Row"; +import { ViewPkiAlertV2Modal } from "./components/ViewPkiAlertV2Modal"; + +interface Props { + hideContainer?: boolean; +} + +export const PkiAlertsV2Page = ({ hideContainer = false }: Props) => { + const { currentProject } = useProject(); + const [page, setPage] = useState(1); + const [perPage, setPerPage] = useState(25); + const [search, setSearch] = useState(""); + const [debouncedSearch] = useDebounce(search, 500); + + const [alertModal, setAlertModal] = useState<{ isOpen: boolean; alertId?: string }>({ + isOpen: false + }); + const [viewModal, setViewModal] = useState<{ isOpen: boolean; alertId?: string }>({ + isOpen: false + }); + const [deleteModal, setDeleteModal] = useState<{ + isOpen: boolean; + alertId?: string; + name?: string; + }>({ + isOpen: false + }); + + const { data: alertsData, isLoading } = useGetPkiAlertsV2({ + projectId: currentProject?.id || "", + search: debouncedSearch || undefined, + limit: perPage, + offset: (page - 1) * perPage + }); + + const { mutateAsync: deletePkiAlert } = useDeletePkiAlertV2(); + + const handleDeleteAlert = async () => { + if (!deleteModal.alertId) return; + + try { + await deletePkiAlert({ alertId: deleteModal.alertId }); + setDeleteModal({ isOpen: false }); + createNotification({ + text: "PKI alert deleted successfully", + type: "success" + }); + } catch { + createNotification({ + text: "Failed to delete PKI alert", + type: "error" + }); + } + }; + + const totalPages = Math.ceil((alertsData?.total || 0) / perPage); + + const renderTableContent = () => { + if (isLoading) { + return Array.from({ length: 5 }, (_, index) => ( + + + + + + + + + + + + + + + + + + )); + } + + if (alertsData?.alerts?.length) { + return alertsData.alerts.map((alert) => ( + setViewModal({ isOpen: true, alertId: alert.id })} + onEdit={() => setAlertModal({ isOpen: true, alertId: alert.id })} + onDelete={() => + setDeleteModal({ + isOpen: true, + alertId: alert.id, + name: alert.name + }) + } + /> + )); + } + + return ( + + + {search ? "No alerts found matching your search." : "No PKI alerts configured yet."} + + + ); + }; + + return ( +
+
+
+
+ + setSearch(e.target.value)} + className="w-full pl-10" + /> +
+
+ + +
+ + + + + + + + + + + + + {renderTableContent()} +
NameEvent TypeStatusAlert BeforeActions
+
+ + {totalPages > 1 && ( +
+ +
+ )} + + setAlertModal({ isOpen, alertId: undefined })} + alertId={alertModal.alertId} + /> + + setViewModal({ isOpen, alertId: undefined })} + alertId={viewModal.alertId} + /> + + setDeleteModal({ isOpen, alertId: undefined, name: undefined })} + onDeleteApproved={handleDeleteAlert} + /> +
+ ); +}; diff --git a/frontend/src/views/PkiAlertsV2Page/components/CreatePkiAlertV2FormSteps.tsx b/frontend/src/views/PkiAlertsV2Page/components/CreatePkiAlertV2FormSteps.tsx new file mode 100644 index 0000000000..2b5d0109d7 --- /dev/null +++ b/frontend/src/views/PkiAlertsV2Page/components/CreatePkiAlertV2FormSteps.tsx @@ -0,0 +1,592 @@ +/* eslint-disable react/no-array-index-key */ +import { useState } from "react"; +import { Controller, useFormContext } from "react-hook-form"; +import { faPlus, faTrash } from "@fortawesome/free-solid-svg-icons"; +import { FontAwesomeIcon } from "@fortawesome/react-fontawesome"; +import { Tab } from "@headlessui/react"; + +import { CertificateDisplayName } from "@app/components/utilities/certificateDisplayUtils"; +import { + Button, + FormControl, + FormLabel, + GenericFieldLabel, + IconButton, + Input, + Pagination, + Select, + SelectItem, + Skeleton, + Table, + TableContainer, + TBody, + Td, + TextArea, + Th, + THead, + Tr +} from "@app/components/v2"; +import { Badge } from "@app/components/v3"; +import { useProject } from "@app/context"; +import { + PkiAlertEventTypeV2, + PkiFilterFieldV2, + PkiFilterOperatorV2, + TCreatePkiAlertV2, + TPkiAlertChannelV2, + TPkiFilterRuleV2, + useGetPkiAlertV2CurrentMatchingCertificates +} from "@app/hooks/api/pkiAlertsV2"; + +export const CreatePkiAlertV2FormSteps = () => { + const { control, watch, setValue } = useFormContext(); + const { currentProject } = useProject(); + + const [certificatesPage, setCertificatesPage] = useState(1); + const certificatesPerPage = 10; + + const watchedFilters = watch("filters"); + const watchedChannels = watch("channels"); + const watchedEventType = watch("eventType"); + const watchedAlertBefore = watch("alertBefore"); + + const { data: currentCertificatesData, isLoading: isLoadingCurrentCertificates } = + useGetPkiAlertV2CurrentMatchingCertificates( + { + projectId: currentProject?.id || "", + filters: watchedFilters || [], + alertBefore: watchedAlertBefore || "30d", + limit: certificatesPerPage, + offset: (certificatesPage - 1) * certificatesPerPage + }, + { + enabled: !!currentProject?.id && watchedEventType === PkiAlertEventTypeV2.EXPIRATION, + refetchOnWindowFocus: false + } + ); + + const addFilter = () => { + const currentFilters = watchedFilters || []; + setValue("filters", [ + ...currentFilters, + { + field: PkiFilterFieldV2.COMMON_NAME, + operator: PkiFilterOperatorV2.CONTAINS, + value: "" + } + ]); + }; + + const removeFilter = (index: number) => { + const currentFilters = watchedFilters || []; + setValue( + "filters", + currentFilters.filter((_, i) => i !== index) + ); + }; + + const updateFilter = (index: number, updatedFilter: Partial) => { + const currentFilters = [...(watchedFilters || [])]; + currentFilters[index] = { ...currentFilters[index], ...updatedFilter }; + setValue("filters", currentFilters); + }; + + const updateChannel = ( + index: number, + updatedChannel: Partial> + ) => { + const currentChannels = [...(watchedChannels || [])]; + currentChannels[index] = { ...currentChannels[index], ...updatedChannel }; + setValue("channels", currentChannels); + }; + + const getFieldOperators = (field: PkiFilterFieldV2) => { + if (field === PkiFilterFieldV2.INCLUDE_CAS) { + return [PkiFilterOperatorV2.EQUALS]; + } + return Object.values(PkiFilterOperatorV2); + }; + + const isValueBoolean = (field: PkiFilterFieldV2) => { + return field === PkiFilterFieldV2.INCLUDE_CAS; + }; + + const canOperatorTakeArray = (operator: PkiFilterOperatorV2) => { + return operator === PkiFilterOperatorV2.MATCHES; + }; + + const formatEventType = (eventType: PkiAlertEventTypeV2) => { + switch (eventType) { + case PkiAlertEventTypeV2.EXPIRATION: + return "Certificate Expiration"; + case PkiAlertEventTypeV2.RENEWAL: + return "Certificate Renewal"; + case PkiAlertEventTypeV2.ISSUANCE: + return "Certificate Issuance"; + case PkiAlertEventTypeV2.REVOCATION: + return "Certificate Revocation"; + default: + return eventType; + } + }; + + const formatAlertBefore = (alertBefore?: string) => { + if (!alertBefore) return "-"; + + const match = alertBefore.match(/^(\d+)([dwmy])$/); + if (!match) return alertBefore; + + const [, value, unit] = match; + const unitMap = { + d: "days", + w: "weeks", + m: "months", + y: "years" + }; + + return `${value} ${unitMap[unit as keyof typeof unitMap] || unit}`; + }; + + return ( + <> + +
+

+ Choose the event that will trigger this alert notification. +

+ +
+ ( + + + + )} + /> +
+
+
+ + +
+

+ Configure the name, description, and timing for your alert. +

+ + ( + + + + )} + /> + + ( + +