mirror of
https://github.com/Infisical/infisical.git
synced 2026-01-09 15:38:03 -05:00
Merge pull request #4821 from Infisical/feat/pki-alerting
PKI Alerting v2
This commit is contained in:
2
backend/src/@types/fastify.d.ts
vendored
2
backend/src/@types/fastify.d.ts
vendored
@@ -101,6 +101,7 @@ import { TOfflineUsageReportServiceFactory } from "@app/services/offline-usage-r
|
|||||||
import { TOrgServiceFactory } from "@app/services/org/org-service";
|
import { TOrgServiceFactory } from "@app/services/org/org-service";
|
||||||
import { TOrgAdminServiceFactory } from "@app/services/org-admin/org-admin-service";
|
import { TOrgAdminServiceFactory } from "@app/services/org-admin/org-admin-service";
|
||||||
import { TPkiAlertServiceFactory } from "@app/services/pki-alert/pki-alert-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 { TPkiCollectionServiceFactory } from "@app/services/pki-collection/pki-collection-service";
|
||||||
import { TPkiSubscriberServiceFactory } from "@app/services/pki-subscriber/pki-subscriber-service";
|
import { TPkiSubscriberServiceFactory } from "@app/services/pki-subscriber/pki-subscriber-service";
|
||||||
import { TPkiSyncServiceFactory } from "@app/services/pki-sync/pki-sync-service";
|
import { TPkiSyncServiceFactory } from "@app/services/pki-sync/pki-sync-service";
|
||||||
@@ -355,6 +356,7 @@ declare module "fastify" {
|
|||||||
role: TRoleServiceFactory;
|
role: TRoleServiceFactory;
|
||||||
convertor: TConvertorServiceFactory;
|
convertor: TConvertorServiceFactory;
|
||||||
subOrganization: TSubOrgServiceFactory;
|
subOrganization: TSubOrgServiceFactory;
|
||||||
|
pkiAlertV2: TPkiAlertV2ServiceFactory;
|
||||||
};
|
};
|
||||||
// this is exclusive use for middlewares in which we need to inject data
|
// this is exclusive use for middlewares in which we need to inject data
|
||||||
// everywhere else access using service layer
|
// everywhere else access using service layer
|
||||||
|
|||||||
28
backend/src/@types/knex.d.ts
vendored
28
backend/src/@types/knex.d.ts
vendored
@@ -284,9 +284,21 @@ import {
|
|||||||
TPkiAcmeOrders,
|
TPkiAcmeOrders,
|
||||||
TPkiAcmeOrdersInsert,
|
TPkiAcmeOrdersInsert,
|
||||||
TPkiAcmeOrdersUpdate,
|
TPkiAcmeOrdersUpdate,
|
||||||
|
TPkiAlertChannels,
|
||||||
|
TPkiAlertChannelsInsert,
|
||||||
|
TPkiAlertChannelsUpdate,
|
||||||
|
TPkiAlertHistory,
|
||||||
|
TPkiAlertHistoryCertificate,
|
||||||
|
TPkiAlertHistoryCertificateInsert,
|
||||||
|
TPkiAlertHistoryCertificateUpdate,
|
||||||
|
TPkiAlertHistoryInsert,
|
||||||
|
TPkiAlertHistoryUpdate,
|
||||||
TPkiAlerts,
|
TPkiAlerts,
|
||||||
TPkiAlertsInsert,
|
TPkiAlertsInsert,
|
||||||
TPkiAlertsUpdate,
|
TPkiAlertsUpdate,
|
||||||
|
TPkiAlertsV2,
|
||||||
|
TPkiAlertsV2Insert,
|
||||||
|
TPkiAlertsV2Update,
|
||||||
TPkiApiEnrollmentConfigs,
|
TPkiApiEnrollmentConfigs,
|
||||||
TPkiApiEnrollmentConfigsInsert,
|
TPkiApiEnrollmentConfigsInsert,
|
||||||
TPkiApiEnrollmentConfigsUpdate,
|
TPkiApiEnrollmentConfigsUpdate,
|
||||||
@@ -769,6 +781,22 @@ declare module "knex/types/tables" {
|
|||||||
TCertificateSecretsUpdate
|
TCertificateSecretsUpdate
|
||||||
>;
|
>;
|
||||||
[TableName.PkiAlert]: KnexOriginal.CompositeTableType<TPkiAlerts, TPkiAlertsInsert, TPkiAlertsUpdate>;
|
[TableName.PkiAlert]: KnexOriginal.CompositeTableType<TPkiAlerts, TPkiAlertsInsert, TPkiAlertsUpdate>;
|
||||||
|
[TableName.PkiAlertsV2]: KnexOriginal.CompositeTableType<TPkiAlertsV2, TPkiAlertsV2Insert, TPkiAlertsV2Update>;
|
||||||
|
[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<
|
[TableName.PkiCollection]: KnexOriginal.CompositeTableType<
|
||||||
TPkiCollections,
|
TPkiCollections,
|
||||||
TPkiCollectionsInsert,
|
TPkiCollectionsInsert,
|
||||||
|
|||||||
@@ -0,0 +1,87 @@
|
|||||||
|
import { Knex } from "knex";
|
||||||
|
|
||||||
|
import { TableName } from "../schemas";
|
||||||
|
|
||||||
|
export async function up(knex: Knex): Promise<void> {
|
||||||
|
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<void> {
|
||||||
|
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);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -98,7 +98,11 @@ export * from "./pki-acme-challenges";
|
|||||||
export * from "./pki-acme-enrollment-configs";
|
export * from "./pki-acme-enrollment-configs";
|
||||||
export * from "./pki-acme-order-auths";
|
export * from "./pki-acme-order-auths";
|
||||||
export * from "./pki-acme-orders";
|
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";
|
||||||
|
export * from "./pki-alerts-v2";
|
||||||
export * from "./pki-api-enrollment-configs";
|
export * from "./pki-api-enrollment-configs";
|
||||||
export * from "./pki-certificate-profiles";
|
export * from "./pki-certificate-profiles";
|
||||||
export * from "./pki-certificate-templates-v2";
|
export * from "./pki-certificate-templates-v2";
|
||||||
|
|||||||
@@ -30,6 +30,10 @@ export enum TableName {
|
|||||||
PkiAcmeEnrollmentConfig = "pki_acme_enrollment_configs",
|
PkiAcmeEnrollmentConfig = "pki_acme_enrollment_configs",
|
||||||
PkiSubscriber = "pki_subscribers",
|
PkiSubscriber = "pki_subscribers",
|
||||||
PkiAlert = "pki_alerts",
|
PkiAlert = "pki_alerts",
|
||||||
|
PkiAlertsV2 = "pki_alerts_v2",
|
||||||
|
PkiAlertChannels = "pki_alert_channels",
|
||||||
|
PkiAlertHistory = "pki_alert_history",
|
||||||
|
PkiAlertHistoryCertificate = "pki_alert_history_certificate",
|
||||||
PkiCollection = "pki_collections",
|
PkiCollection = "pki_collections",
|
||||||
PkiCollectionItem = "pki_collection_items",
|
PkiCollectionItem = "pki_collection_items",
|
||||||
Groups = "groups",
|
Groups = "groups",
|
||||||
|
|||||||
22
backend/src/db/schemas/pki-alert-channels.ts
Normal file
22
backend/src/db/schemas/pki-alert-channels.ts
Normal file
@@ -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<typeof PkiAlertChannelsSchema>;
|
||||||
|
export type TPkiAlertChannelsInsert = Omit<z.input<typeof PkiAlertChannelsSchema>, TImmutableDBKeys>;
|
||||||
|
export type TPkiAlertChannelsUpdate = Partial<Omit<z.input<typeof PkiAlertChannelsSchema>, TImmutableDBKeys>>;
|
||||||
25
backend/src/db/schemas/pki-alert-history-certificate.ts
Normal file
25
backend/src/db/schemas/pki-alert-history-certificate.ts
Normal file
@@ -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<typeof PkiAlertHistoryCertificateSchema>;
|
||||||
|
export type TPkiAlertHistoryCertificateInsert = Omit<
|
||||||
|
z.input<typeof PkiAlertHistoryCertificateSchema>,
|
||||||
|
TImmutableDBKeys
|
||||||
|
>;
|
||||||
|
export type TPkiAlertHistoryCertificateUpdate = Partial<
|
||||||
|
Omit<z.input<typeof PkiAlertHistoryCertificateSchema>, TImmutableDBKeys>
|
||||||
|
>;
|
||||||
22
backend/src/db/schemas/pki-alert-history.ts
Normal file
22
backend/src/db/schemas/pki-alert-history.ts
Normal file
@@ -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<typeof PkiAlertHistorySchema>;
|
||||||
|
export type TPkiAlertHistoryInsert = Omit<z.input<typeof PkiAlertHistorySchema>, TImmutableDBKeys>;
|
||||||
|
export type TPkiAlertHistoryUpdate = Partial<Omit<z.input<typeof PkiAlertHistorySchema>, TImmutableDBKeys>>;
|
||||||
25
backend/src/db/schemas/pki-alerts-v2.ts
Normal file
25
backend/src/db/schemas/pki-alerts-v2.ts
Normal file
@@ -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<typeof PkiAlertsV2Schema>;
|
||||||
|
export type TPkiAlertsV2Insert = Omit<z.input<typeof PkiAlertsV2Schema>, TImmutableDBKeys>;
|
||||||
|
export type TPkiAlertsV2Update = Partial<Omit<z.input<typeof PkiAlertsV2Schema>, TImmutableDBKeys>>;
|
||||||
@@ -36,6 +36,7 @@ import { CertExtendedKeyUsage, CertKeyAlgorithm, CertKeyUsage } from "@app/servi
|
|||||||
import { CaStatus } from "@app/services/certificate-authority/certificate-authority-enums";
|
import { CaStatus } from "@app/services/certificate-authority/certificate-authority-enums";
|
||||||
import { TIdentityTrustedIp } from "@app/services/identity/identity-types";
|
import { TIdentityTrustedIp } from "@app/services/identity/identity-types";
|
||||||
import { TAllowedFields } from "@app/services/identity-ldap-auth/identity-ldap-auth-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 { PkiItemType } from "@app/services/pki-collection/pki-collection-types";
|
||||||
import { SecretSync, SecretSyncImportBehavior } from "@app/services/secret-sync/secret-sync-enums";
|
import { SecretSync, SecretSyncImportBehavior } from "@app/services/secret-sync/secret-sync-enums";
|
||||||
import {
|
import {
|
||||||
@@ -2318,10 +2319,11 @@ interface CreatePkiAlert {
|
|||||||
type: EventType.CREATE_PKI_ALERT;
|
type: EventType.CREATE_PKI_ALERT;
|
||||||
metadata: {
|
metadata: {
|
||||||
pkiAlertId: string;
|
pkiAlertId: string;
|
||||||
pkiCollectionId: string;
|
pkiCollectionId?: string;
|
||||||
name: string;
|
name: string;
|
||||||
alertBeforeDays: number;
|
alertBefore: string;
|
||||||
recipientEmails: string;
|
eventType: PkiAlertEventType;
|
||||||
|
recipientEmails?: string;
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
interface GetPkiAlert {
|
interface GetPkiAlert {
|
||||||
@@ -2337,7 +2339,8 @@ interface UpdatePkiAlert {
|
|||||||
pkiAlertId: string;
|
pkiAlertId: string;
|
||||||
pkiCollectionId?: string;
|
pkiCollectionId?: string;
|
||||||
name?: string;
|
name?: string;
|
||||||
alertBeforeDays?: number;
|
alertBefore?: string;
|
||||||
|
eventType?: PkiAlertEventType;
|
||||||
recipientEmails?: string;
|
recipientEmails?: string;
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -51,6 +51,7 @@ export enum QueueName {
|
|||||||
AuditLogPrune = "audit-log-prune",
|
AuditLogPrune = "audit-log-prune",
|
||||||
DailyResourceCleanUp = "daily-resource-cleanup",
|
DailyResourceCleanUp = "daily-resource-cleanup",
|
||||||
DailyExpiringPkiItemAlert = "daily-expiring-pki-item-alert",
|
DailyExpiringPkiItemAlert = "daily-expiring-pki-item-alert",
|
||||||
|
DailyPkiAlertV2Processing = "daily-pki-alert-v2-processing",
|
||||||
PkiSyncCleanup = "pki-sync-cleanup",
|
PkiSyncCleanup = "pki-sync-cleanup",
|
||||||
PkiSubscriber = "pki-subscriber",
|
PkiSubscriber = "pki-subscriber",
|
||||||
TelemetryInstanceStats = "telemtry-self-hosted-stats",
|
TelemetryInstanceStats = "telemtry-self-hosted-stats",
|
||||||
@@ -90,6 +91,7 @@ export enum QueueJobs {
|
|||||||
AuditLogPrune = "audit-log-prune-job",
|
AuditLogPrune = "audit-log-prune-job",
|
||||||
DailyResourceCleanUp = "daily-resource-cleanup-job",
|
DailyResourceCleanUp = "daily-resource-cleanup-job",
|
||||||
DailyExpiringPkiItemAlert = "daily-expiring-pki-item-alert",
|
DailyExpiringPkiItemAlert = "daily-expiring-pki-item-alert",
|
||||||
|
DailyPkiAlertV2Processing = "daily-pki-alert-v2-processing",
|
||||||
PkiSyncCleanup = "pki-sync-cleanup-job",
|
PkiSyncCleanup = "pki-sync-cleanup-job",
|
||||||
SecWebhook = "secret-webhook-trigger",
|
SecWebhook = "secret-webhook-trigger",
|
||||||
TelemetryInstanceStats = "telemetry-self-hosted-stats",
|
TelemetryInstanceStats = "telemetry-self-hosted-stats",
|
||||||
@@ -159,6 +161,10 @@ export type TQueueJobTypes = {
|
|||||||
name: QueueJobs.DailyExpiringPkiItemAlert;
|
name: QueueJobs.DailyExpiringPkiItemAlert;
|
||||||
payload: undefined;
|
payload: undefined;
|
||||||
};
|
};
|
||||||
|
[QueueName.DailyPkiAlertV2Processing]: {
|
||||||
|
name: QueueJobs.DailyPkiAlertV2Processing;
|
||||||
|
payload: undefined;
|
||||||
|
};
|
||||||
[QueueName.PkiSyncCleanup]: {
|
[QueueName.PkiSyncCleanup]: {
|
||||||
name: QueueJobs.PkiSyncCleanup;
|
name: QueueJobs.PkiSyncCleanup;
|
||||||
payload: undefined;
|
payload: undefined;
|
||||||
|
|||||||
@@ -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 { dailyExpiringPkiItemAlertQueueServiceFactory } from "@app/services/pki-alert/expiring-pki-item-alert-queue";
|
||||||
import { pkiAlertDALFactory } from "@app/services/pki-alert/pki-alert-dal";
|
import { pkiAlertDALFactory } from "@app/services/pki-alert/pki-alert-dal";
|
||||||
import { pkiAlertServiceFactory } from "@app/services/pki-alert/pki-alert-service";
|
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 { pkiCollectionDALFactory } from "@app/services/pki-collection/pki-collection-dal";
|
||||||
import { pkiCollectionItemDALFactory } from "@app/services/pki-collection/pki-collection-item-dal";
|
import { pkiCollectionItemDALFactory } from "@app/services/pki-collection/pki-collection-item-dal";
|
||||||
import { pkiCollectionServiceFactory } from "@app/services/pki-collection/pki-collection-service";
|
import { pkiCollectionServiceFactory } from "@app/services/pki-collection/pki-collection-service";
|
||||||
@@ -555,6 +560,9 @@ export const registerRoutes = async (
|
|||||||
const additionalPrivilegeDAL = additionalPrivilegeDALFactory(db);
|
const additionalPrivilegeDAL = additionalPrivilegeDALFactory(db);
|
||||||
const membershipRoleDAL = membershipRoleDALFactory(db);
|
const membershipRoleDAL = membershipRoleDALFactory(db);
|
||||||
const roleDAL = roleDALFactory(db);
|
const roleDAL = roleDALFactory(db);
|
||||||
|
const pkiAlertHistoryDAL = pkiAlertHistoryDALFactory(db);
|
||||||
|
const pkiAlertChannelDAL = pkiAlertChannelDALFactory(db);
|
||||||
|
const pkiAlertV2DAL = pkiAlertV2DALFactory(db);
|
||||||
|
|
||||||
const vaultExternalMigrationConfigDAL = vaultExternalMigrationConfigDALFactory(db);
|
const vaultExternalMigrationConfigDAL = vaultExternalMigrationConfigDALFactory(db);
|
||||||
|
|
||||||
@@ -1818,6 +1826,21 @@ export const registerRoutes = async (
|
|||||||
groupDAL
|
groupDAL
|
||||||
});
|
});
|
||||||
|
|
||||||
|
const pkiAlertV2Service = pkiAlertV2ServiceFactory({
|
||||||
|
pkiAlertV2DAL,
|
||||||
|
pkiAlertChannelDAL,
|
||||||
|
pkiAlertHistoryDAL,
|
||||||
|
permissionService,
|
||||||
|
smtpService
|
||||||
|
});
|
||||||
|
|
||||||
|
const pkiAlertV2Queue = pkiAlertV2QueueServiceFactory({
|
||||||
|
queueService,
|
||||||
|
pkiAlertV2Service,
|
||||||
|
pkiAlertV2DAL,
|
||||||
|
pkiAlertHistoryDAL
|
||||||
|
});
|
||||||
|
|
||||||
const dynamicSecretProviders = buildDynamicSecretProviders({
|
const dynamicSecretProviders = buildDynamicSecretProviders({
|
||||||
gatewayService,
|
gatewayService,
|
||||||
gatewayV2Service
|
gatewayV2Service
|
||||||
@@ -2391,6 +2414,7 @@ export const registerRoutes = async (
|
|||||||
await dailyReminderQueueService.startSecretReminderMigrationJob();
|
await dailyReminderQueueService.startSecretReminderMigrationJob();
|
||||||
await dailyExpiringPkiItemAlert.startSendingAlerts();
|
await dailyExpiringPkiItemAlert.startSendingAlerts();
|
||||||
await pkiSubscriberQueue.startDailyAutoRenewalJob();
|
await pkiSubscriberQueue.startDailyAutoRenewalJob();
|
||||||
|
await pkiAlertV2Queue.init();
|
||||||
await certificateV3Queue.init();
|
await certificateV3Queue.init();
|
||||||
await kmsService.startService(hsmStatus);
|
await kmsService.startService(hsmStatus);
|
||||||
await microsoftTeamsService.start();
|
await microsoftTeamsService.start();
|
||||||
@@ -2523,7 +2547,8 @@ export const registerRoutes = async (
|
|||||||
role: roleService,
|
role: roleService,
|
||||||
additionalPrivilege: additionalPrivilegeService,
|
additionalPrivilege: additionalPrivilegeService,
|
||||||
identityProject: identityProjectService,
|
identityProject: identityProjectService,
|
||||||
convertor: convertorService
|
convertor: convertorService,
|
||||||
|
pkiAlertV2: pkiAlertV2Service
|
||||||
});
|
});
|
||||||
|
|
||||||
const cronJobs: CronJob[] = [];
|
const cronJobs: CronJob[] = [];
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ import { ALERTS, ApiDocsTags } from "@app/lib/api-docs";
|
|||||||
import { readLimit, writeLimit } from "@app/server/config/rateLimiter";
|
import { readLimit, writeLimit } from "@app/server/config/rateLimiter";
|
||||||
import { verifyAuth } from "@app/server/plugins/auth/verify-auth";
|
import { verifyAuth } from "@app/server/plugins/auth/verify-auth";
|
||||||
import { AuthMode } from "@app/services/auth/auth-type";
|
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) => {
|
export const registerPkiAlertRouter = async (server: FastifyZodProvider) => {
|
||||||
server.route({
|
server.route({
|
||||||
@@ -52,7 +53,8 @@ export const registerPkiAlertRouter = async (server: FastifyZodProvider) => {
|
|||||||
pkiAlertId: alert.id,
|
pkiAlertId: alert.id,
|
||||||
pkiCollectionId: alert.pkiCollectionId,
|
pkiCollectionId: alert.pkiCollectionId,
|
||||||
name: alert.name,
|
name: alert.name,
|
||||||
alertBeforeDays: alert.alertBeforeDays,
|
alertBefore: alert.alertBeforeDays.toString(),
|
||||||
|
eventType: PkiAlertEventType.EXPIRATION,
|
||||||
recipientEmails: alert.recipientEmails
|
recipientEmails: alert.recipientEmails
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -152,7 +154,8 @@ export const registerPkiAlertRouter = async (server: FastifyZodProvider) => {
|
|||||||
pkiAlertId: alert.id,
|
pkiAlertId: alert.id,
|
||||||
pkiCollectionId: alert.pkiCollectionId,
|
pkiCollectionId: alert.pkiCollectionId,
|
||||||
name: alert.name,
|
name: alert.name,
|
||||||
alertBeforeDays: alert.alertBeforeDays,
|
alertBefore: alert.alertBeforeDays.toString(),
|
||||||
|
eventType: PkiAlertEventType.EXPIRATION,
|
||||||
recipientEmails: alert.recipientEmails
|
recipientEmails: alert.recipientEmails
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -8,6 +8,7 @@ import { registerIdentityOrgRouter } from "./identity-org-router";
|
|||||||
import { registerMfaRouter } from "./mfa-router";
|
import { registerMfaRouter } from "./mfa-router";
|
||||||
import { registerOrgRouter } from "./organization-router";
|
import { registerOrgRouter } from "./organization-router";
|
||||||
import { registerPasswordRouter } from "./password-router";
|
import { registerPasswordRouter } from "./password-router";
|
||||||
|
import { registerPkiAlertRouter } from "./pki-alert-router";
|
||||||
import { registerPkiTemplatesRouter } from "./pki-templates-router";
|
import { registerPkiTemplatesRouter } from "./pki-templates-router";
|
||||||
import { registerSecretFolderRouter } from "./secret-folder-router";
|
import { registerSecretFolderRouter } from "./secret-folder-router";
|
||||||
import { registerSecretImportRouter } from "./secret-import-router";
|
import { registerSecretImportRouter } from "./secret-import-router";
|
||||||
@@ -26,6 +27,7 @@ export const registerV2Routes = async (server: FastifyZodProvider) => {
|
|||||||
async (pkiRouter) => {
|
async (pkiRouter) => {
|
||||||
await pkiRouter.register(registerCaRouter, { prefix: "/ca" });
|
await pkiRouter.register(registerCaRouter, { prefix: "/ca" });
|
||||||
await pkiRouter.register(registerPkiTemplatesRouter, { prefix: "/certificate-templates" });
|
await pkiRouter.register(registerPkiTemplatesRouter, { prefix: "/certificate-templates" });
|
||||||
|
await pkiRouter.register(registerPkiAlertRouter, { prefix: "/alerts" });
|
||||||
},
|
},
|
||||||
{ prefix: "/pki" }
|
{ prefix: "/pki" }
|
||||||
);
|
);
|
||||||
|
|||||||
443
backend/src/server/routes/v2/pki-alert-router.ts
Normal file
443
backend/src/server/routes/v2/pki-alert-router.ts
Normal file
@@ -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;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
};
|
||||||
61
backend/src/services/pki-alert-v2/pki-alert-channel-dal.ts
Normal file
61
backend/src/services/pki-alert-v2/pki-alert-channel-dal.ts
Normal file
@@ -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<typeof pkiAlertChannelDALFactory>;
|
||||||
|
|
||||||
|
export const pkiAlertChannelDALFactory = (db: TDbClient) => {
|
||||||
|
const pkiAlertChannelOrm = ormify(db, TableName.PkiAlertChannels);
|
||||||
|
|
||||||
|
const insertMany = async (data: TPkiAlertChannelsInsert[], tx?: Knex): Promise<TPkiAlertChannels[]> => {
|
||||||
|
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<TPkiAlertChannels[]> => {
|
||||||
|
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<number> => {
|
||||||
|
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
|
||||||
|
};
|
||||||
|
};
|
||||||
110
backend/src/services/pki-alert-v2/pki-alert-history-dal.ts
Normal file
110
backend/src/services/pki-alert-v2/pki-alert-history-dal.ts
Normal file
@@ -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<typeof pkiAlertHistoryDALFactory>;
|
||||||
|
|
||||||
|
export const pkiAlertHistoryDALFactory = (db: TDbClient) => {
|
||||||
|
const pkiAlertHistoryOrm = ormify(db, TableName.PkiAlertHistory);
|
||||||
|
|
||||||
|
const createWithCertificates = async (
|
||||||
|
alertId: string,
|
||||||
|
certificateIds: string[],
|
||||||
|
options?: {
|
||||||
|
hasNotificationSent?: boolean;
|
||||||
|
notificationError?: string;
|
||||||
|
}
|
||||||
|
): Promise<TPkiAlertHistory> => {
|
||||||
|
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<TPkiAlertHistory[]> => {
|
||||||
|
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<string[]> => {
|
||||||
|
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
|
||||||
|
};
|
||||||
|
};
|
||||||
556
backend/src/services/pki-alert-v2/pki-alert-v2-dal.ts
Normal file
556
backend/src/services/pki-alert-v2/pki-alert-v2-dal.ts
Normal file
@@ -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<typeof pkiAlertV2DALFactory>;
|
||||||
|
|
||||||
|
export const pkiAlertV2DALFactory = (db: TDbClient) => {
|
||||||
|
const pkiAlertV2Orm = ormify(db, TableName.PkiAlertsV2);
|
||||||
|
|
||||||
|
const create = async (data: TPkiAlertsV2Insert, tx?: Knex): Promise<TPkiAlertsV2> => {
|
||||||
|
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<TPkiAlertsV2> => {
|
||||||
|
try {
|
||||||
|
const serializedData: Record<string, unknown> = {
|
||||||
|
...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<TPkiAlertsV2 | null> => {
|
||||||
|
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<TAlertWithChannels | null> => {
|
||||||
|
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<string, TChannelResult[]>
|
||||||
|
);
|
||||||
|
|
||||||
|
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<TAlertWithChannels[]> => {
|
||||||
|
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<number> => {
|
||||||
|
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<string[]> => {
|
||||||
|
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
|
||||||
|
};
|
||||||
|
};
|
||||||
358
backend/src/services/pki-alert-v2/pki-alert-v2-filter-utils.ts
Normal file
358
backend/src/services/pki-alert-v2/pki-alert-v2-filter-utils.ts
Normal file
@@ -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;
|
||||||
|
};
|
||||||
220
backend/src/services/pki-alert-v2/pki-alert-v2-queue.ts
Normal file
220
backend/src/services/pki-alert-v2/pki-alert-v2-queue.ts
Normal file
@@ -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<TPkiAlertV2ServiceFactory, "sendAlertNotifications">;
|
||||||
|
pkiAlertV2DAL: Pick<TPkiAlertV2DALFactory, "findByProjectId" | "findMatchingCertificates" | "getDistinctProjectIds">;
|
||||||
|
pkiAlertHistoryDAL: Pick<TPkiAlertHistoryDALFactory, "findRecentlyAlertedCertificates">;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type TPkiAlertV2QueueServiceFactory = ReturnType<typeof pkiAlertV2QueueServiceFactory>;
|
||||||
|
|
||||||
|
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<string[]> => {
|
||||||
|
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<QueueName.DailyPkiAlertV2Processing>(
|
||||||
|
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
|
||||||
|
};
|
||||||
|
};
|
||||||
507
backend/src/services/pki-alert-v2/pki-alert-v2-service.ts
Normal file
507
backend/src/services/pki-alert-v2/pki-alert-v2-service.ts
Normal file
@@ -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<TPkiAlertChannelDALFactory, "create" | "findByAlertId" | "deleteByAlertId" | "insertMany">;
|
||||||
|
pkiAlertHistoryDAL: Pick<TPkiAlertHistoryDALFactory, "createWithCertificates" | "findByAlertId">;
|
||||||
|
permissionService: Pick<TPermissionServiceFactory, "getProjectPermission">;
|
||||||
|
smtpService: Pick<TSmtpService, "sendMail">;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type TPkiAlertV2ServiceFactory = ReturnType<typeof pkiAlertV2ServiceFactory>;
|
||||||
|
|
||||||
|
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<TAlertV2Response> => {
|
||||||
|
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<TAlertV2Response> => {
|
||||||
|
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<TListAlertsV2Response> => {
|
||||||
|
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<TAlertV2Response> => {
|
||||||
|
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<TAlertV2Response> => {
|
||||||
|
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<TListMatchingCertificatesResponse> => {
|
||||||
|
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<TListMatchingCertificatesResponse> => {
|
||||||
|
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
|
||||||
|
};
|
||||||
|
};
|
||||||
205
backend/src/services/pki-alert-v2/pki-alert-v2-types.ts
Normal file
205
backend/src/services/pki-alert-v2/pki-alert-v2-types.ts
Normal file
@@ -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<typeof PkiFilterRuleSchema>;
|
||||||
|
|
||||||
|
export const PkiFiltersSchema = z.array(PkiFilterRuleSchema);
|
||||||
|
export type TPkiFilters = z.infer<typeof PkiFiltersSchema>;
|
||||||
|
|
||||||
|
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<typeof EmailChannelConfigSchema>;
|
||||||
|
export type TWebhookChannelConfig = z.infer<typeof WebhookChannelConfigSchema>;
|
||||||
|
export type TSlackChannelConfig = z.infer<typeof SlackChannelConfigSchema>;
|
||||||
|
export type TChannelConfig = z.infer<typeof ChannelConfigSchema>;
|
||||||
|
|
||||||
|
export const CreateChannelSchema = z.object({
|
||||||
|
channelType: z.nativeEnum(PkiAlertChannelType),
|
||||||
|
config: ChannelConfigSchema,
|
||||||
|
enabled: z.boolean().default(true)
|
||||||
|
});
|
||||||
|
|
||||||
|
export type TCreateChannel = z.infer<typeof CreateChannelSchema>;
|
||||||
|
|
||||||
|
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<typeof CreatePkiAlertV2Schema>;
|
||||||
|
|
||||||
|
export const UpdatePkiAlertV2Schema = CreatePkiAlertV2Schema.partial();
|
||||||
|
export type TUpdatePkiAlertV2 = z.infer<typeof UpdatePkiAlertV2Schema>;
|
||||||
|
|
||||||
|
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;
|
||||||
|
};
|
||||||
@@ -1,11 +1,12 @@
|
|||||||
import { Heading, Hr, Section, Text } from "@react-email/components";
|
import { Heading, Section, Text } from "@react-email/components";
|
||||||
import React, { Fragment } from "react";
|
|
||||||
|
|
||||||
|
import { BaseButton } from "./BaseButton";
|
||||||
import { BaseEmailWrapper, BaseEmailWrapperProps } from "./BaseEmailWrapper";
|
import { BaseEmailWrapper, BaseEmailWrapperProps } from "./BaseEmailWrapper";
|
||||||
|
|
||||||
interface PkiExpirationAlertTemplateProps extends Omit<BaseEmailWrapperProps, "title" | "preview" | "children"> {
|
interface PkiExpirationAlertTemplateProps extends Omit<BaseEmailWrapperProps, "title" | "preview" | "children"> {
|
||||||
alertName: string;
|
alertName: string;
|
||||||
alertBeforeDays: number;
|
alertBeforeDays: number;
|
||||||
|
projectId: string;
|
||||||
items: { type: string; friendlyName: string; serialNumber: string; expiryDate: string }[];
|
items: { type: string; friendlyName: string; serialNumber: string; expiryDate: string }[];
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -13,44 +14,56 @@ export const PkiExpirationAlertTemplate = ({
|
|||||||
alertName,
|
alertName,
|
||||||
siteUrl,
|
siteUrl,
|
||||||
alertBeforeDays,
|
alertBeforeDays,
|
||||||
|
projectId,
|
||||||
items
|
items
|
||||||
}: PkiExpirationAlertTemplateProps) => {
|
}: 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 (
|
return (
|
||||||
<BaseEmailWrapper
|
<BaseEmailWrapper title="Certificate Expiration Notice" preview={message} siteUrl={siteUrl}>
|
||||||
title="Infisical CA/Certificate Expiration Notice"
|
|
||||||
preview="One or more of your Infisical certificates is about to expire."
|
|
||||||
siteUrl={siteUrl}
|
|
||||||
>
|
|
||||||
<Heading className="text-black text-[18px] leading-[28px] text-center font-normal p-0 mx-0">
|
<Heading className="text-black text-[18px] leading-[28px] text-center font-normal p-0 mx-0">
|
||||||
<strong>CA/Certificate Expiration Notice</strong>
|
<strong>Certificate Expiration Notice</strong>
|
||||||
</Heading>
|
</Heading>
|
||||||
<Section className="px-[24px] mt-[36px] pt-[12px] pb-[8px] border border-solid border-gray-200 rounded-md bg-gray-50">
|
|
||||||
<Text>Hello,</Text>
|
<Section className="px-[24px] mb-[28px] mt-[36px] pt-[12px] pb-[8px] border border-solid border-gray-200 rounded-md bg-gray-50">
|
||||||
<Text className="text-black text-[14px] leading-[24px]">
|
<Text className="text-[14px]">
|
||||||
This is an automated alert for <strong>{alertName}</strong> triggered for CAs/Certificates expiring in{" "}
|
Alert <strong className="font-semibold">{alertName}</strong>: You have{" "}
|
||||||
<strong>{alertBeforeDays}</strong> days.
|
{items.length === 1 ? "one" : items.length} {certificateText} that will expire in {daysText}.
|
||||||
</Text>
|
|
||||||
<Text className="text-[14px] leading-[24px] mb-[4px]">
|
|
||||||
<strong>Expiring Items:</strong>
|
|
||||||
</Text>
|
</Text>
|
||||||
|
</Section>
|
||||||
|
<Section className="mb-[28px]">
|
||||||
|
<Text className="text-[14px] font-semibold mb-[12px]">Expiring certificates:</Text>
|
||||||
{items.map((item) => (
|
{items.map((item) => (
|
||||||
<Fragment key={item.serialNumber}>
|
<Section
|
||||||
<Hr className="mb-[16px]" />
|
key={item.serialNumber}
|
||||||
<strong className="text-[14px]">{item.type}:</strong>
|
className="mb-[16px] p-[16px] border border-solid border-gray-200 rounded-md bg-gray-50"
|
||||||
<Text className="text-[14px] my-[2px] leading-[24px]">{item.friendlyName}</Text>
|
>
|
||||||
<strong className="text-[14px]">Serial Number:</strong>
|
<Text className="text-[14px] font-semibold m-0 mb-[4px]">{item.friendlyName}</Text>
|
||||||
<Text className="text-[14px] my-[2px] leading-[24px]">{item.serialNumber}</Text>
|
<Text className="text-[12px] text-gray-600 m-0 mb-[4px]">Serial: {item.serialNumber}</Text>
|
||||||
<strong className="text-[14px]">Expires On:</strong>
|
<Text className="text-[12px] text-gray-600 m-0">Expires: {formatDate(item.expiryDate)}</Text>
|
||||||
<Text className="text-[14px] mt-[2px] mb-[16px] leading-[24px]">{item.expiryDate}</Text>
|
</Section>
|
||||||
</Fragment>
|
|
||||||
))}
|
))}
|
||||||
<Hr />
|
</Section>
|
||||||
<Text className="text-[14px] leading-[24px]">
|
|
||||||
Please take the necessary actions to renew these items before they expire.
|
<Section className="text-center mt-[32px] mb-[16px]">
|
||||||
</Text>
|
<BaseButton href={`${siteUrl}/projects/cert-management/${projectId}/policies`}>
|
||||||
<Text className="text-[14px] leading-[24px]">
|
View Certificate Alerts
|
||||||
For more details, please log in to your Infisical account and check your PKI management section.
|
</BaseButton>
|
||||||
</Text>
|
|
||||||
</Section>
|
</Section>
|
||||||
</BaseEmailWrapper>
|
</BaseEmailWrapper>
|
||||||
);
|
);
|
||||||
@@ -59,11 +72,22 @@ export const PkiExpirationAlertTemplate = ({
|
|||||||
export default PkiExpirationAlertTemplate;
|
export default PkiExpirationAlertTemplate;
|
||||||
|
|
||||||
PkiExpirationAlertTemplate.PreviewProps = {
|
PkiExpirationAlertTemplate.PreviewProps = {
|
||||||
alertBeforeDays: 5,
|
alertBeforeDays: 7,
|
||||||
items: [
|
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"
|
siteUrl: "https://infisical.com"
|
||||||
} as PkiExpirationAlertTemplateProps;
|
} as PkiExpirationAlertTemplateProps;
|
||||||
|
|||||||
30
frontend/src/hooks/api/pkiAlertsV2/index.ts
Normal file
30
frontend/src/hooks/api/pkiAlertsV2/index.ts
Normal file
@@ -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";
|
||||||
68
frontend/src/hooks/api/pkiAlertsV2/mutations.ts
Normal file
68
frontend/src/hooks/api/pkiAlertsV2/mutations.ts
Normal file
@@ -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<TPkiAlertV2, unknown, TCreatePkiAlertV2>({
|
||||||
|
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<TPkiAlertV2, unknown, TUpdatePkiAlertV2>({
|
||||||
|
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<TPkiAlertV2, unknown, TDeletePkiAlertV2>({
|
||||||
|
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)
|
||||||
|
});
|
||||||
|
}
|
||||||
|
});
|
||||||
|
};
|
||||||
139
frontend/src/hooks/api/pkiAlertsV2/queries.ts
Normal file
139
frontend/src/hooks/api/pkiAlertsV2/queries.ts
Normal file
@@ -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<TGetPkiAlertsV2Response> => {
|
||||||
|
const { data } = await apiRequest.get<TGetPkiAlertsV2Response>("/api/v2/pki/alerts", {
|
||||||
|
params
|
||||||
|
});
|
||||||
|
return data;
|
||||||
|
};
|
||||||
|
|
||||||
|
const fetchPkiAlertV2ById = async ({ alertId }: TGetPkiAlertV2ById): Promise<TPkiAlertV2> => {
|
||||||
|
const { data } = await apiRequest.get<{ alert: TPkiAlertV2 }>(`/api/v2/pki/alerts/${alertId}`);
|
||||||
|
return data.alert;
|
||||||
|
};
|
||||||
|
|
||||||
|
const fetchPkiAlertV2MatchingCertificates = async (
|
||||||
|
params: TGetPkiAlertV2MatchingCertificates
|
||||||
|
): Promise<TGetPkiAlertV2MatchingCertificatesResponse> => {
|
||||||
|
const { alertId, ...queryParams } = params;
|
||||||
|
const { data } = await apiRequest.get<TGetPkiAlertV2MatchingCertificatesResponse>(
|
||||||
|
`/api/v2/pki/alerts/${alertId}/certificates`,
|
||||||
|
{ params: queryParams }
|
||||||
|
);
|
||||||
|
return data;
|
||||||
|
};
|
||||||
|
|
||||||
|
const fetchPkiAlertV2CurrentMatchingCertificates = async (
|
||||||
|
params: TGetPkiAlertV2CurrentMatchingCertificates
|
||||||
|
): Promise<TGetPkiAlertV2CurrentMatchingCertificatesResponse> => {
|
||||||
|
const { data } = await apiRequest.post<TGetPkiAlertV2CurrentMatchingCertificatesResponse>(
|
||||||
|
"/api/v2/pki/alerts/preview/certificates",
|
||||||
|
params
|
||||||
|
);
|
||||||
|
return data;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const useGetPkiAlertsV2 = (
|
||||||
|
params: TGetPkiAlertsV2,
|
||||||
|
options?: Omit<
|
||||||
|
UseQueryOptions<
|
||||||
|
TGetPkiAlertsV2Response,
|
||||||
|
unknown,
|
||||||
|
TGetPkiAlertsV2Response,
|
||||||
|
ReturnType<typeof pkiAlertsV2Keys.allPkiAlertsV2>
|
||||||
|
>,
|
||||||
|
"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<typeof pkiAlertsV2Keys.specificPkiAlertV2>
|
||||||
|
>,
|
||||||
|
"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<typeof pkiAlertsV2Keys.pkiAlertV2MatchingCertificates>
|
||||||
|
>,
|
||||||
|
"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<typeof pkiAlertsV2Keys.pkiAlertV2CurrentMatchingCertificates>
|
||||||
|
>,
|
||||||
|
"queryKey" | "queryFn"
|
||||||
|
>
|
||||||
|
) => {
|
||||||
|
return useQuery({
|
||||||
|
queryKey: pkiAlertsV2Keys.pkiAlertV2CurrentMatchingCertificates(params),
|
||||||
|
queryFn: () => fetchPkiAlertV2CurrentMatchingCertificates(params),
|
||||||
|
enabled: !!params.projectId && params.filters !== undefined,
|
||||||
|
placeholderData: (previousData) => previousData,
|
||||||
|
...options
|
||||||
|
});
|
||||||
|
};
|
||||||
192
frontend/src/hooks/api/pkiAlertsV2/types.ts
Normal file
192
frontend/src/hooks/api/pkiAlertsV2/types.ts
Normal file
@@ -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<TPkiAlertChannelV2, "id" | "createdAt" | "updatedAt">[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface TUpdatePkiAlertV2 {
|
||||||
|
alertId: string;
|
||||||
|
name?: string;
|
||||||
|
description?: string;
|
||||||
|
eventType?: PkiAlertEventTypeV2;
|
||||||
|
alertBefore?: string;
|
||||||
|
filters?: TPkiFilterRuleV2[];
|
||||||
|
enabled?: boolean;
|
||||||
|
channels?: Omit<TPkiAlertChannelV2, "id" | "createdAt" | "updatedAt">[];
|
||||||
|
}
|
||||||
|
|
||||||
|
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 });
|
||||||
@@ -1,15 +1,27 @@
|
|||||||
|
import { useState } from "react";
|
||||||
import { Helmet } from "react-helmet";
|
import { Helmet } from "react-helmet";
|
||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
|
|
||||||
import { ProjectPermissionCan } from "@app/components/permissions";
|
import { ProjectPermissionCan } from "@app/components/permissions";
|
||||||
import { PageHeader } from "@app/components/v2";
|
import { PageHeader, Tab, TabList, TabPanel, Tabs } from "@app/components/v2";
|
||||||
import { ProjectPermissionActions, ProjectPermissionSub } from "@app/context";
|
import { ProjectPermissionActions, ProjectPermissionSub, useProject } from "@app/context";
|
||||||
|
import { useListWorkspacePkiAlerts } from "@app/hooks/api";
|
||||||
import { ProjectType } from "@app/hooks/api/projects/types";
|
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 = () => {
|
export const AlertingPage = () => {
|
||||||
const { t } = useTranslation();
|
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 (
|
return (
|
||||||
<div className="mx-auto flex h-full flex-col justify-between bg-bunker-800 text-white">
|
<div className="mx-auto flex h-full flex-col justify-between bg-bunker-800 text-white">
|
||||||
<Helmet>
|
<Helmet>
|
||||||
@@ -26,7 +38,33 @@ export const AlertingPage = () => {
|
|||||||
I={ProjectPermissionActions.Read}
|
I={ProjectPermissionActions.Read}
|
||||||
a={ProjectPermissionSub.PkiAlerts}
|
a={ProjectPermissionSub.PkiAlerts}
|
||||||
>
|
>
|
||||||
<PkiAlertsSection />
|
{!hasV1Alerts ? (
|
||||||
|
<div>
|
||||||
|
<PkiAlertsV2Page hideContainer />
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<Tabs orientation="vertical" value={selectedTab} onValueChange={setSelectedTab}>
|
||||||
|
<TabList>
|
||||||
|
<Tab variant="project" value="rule-based">
|
||||||
|
Certificate Alerts
|
||||||
|
</Tab>
|
||||||
|
<Tab variant="project" value="legacy">
|
||||||
|
Collection Alerts (Legacy)
|
||||||
|
</Tab>
|
||||||
|
</TabList>
|
||||||
|
|
||||||
|
<TabPanel value="rule-based">
|
||||||
|
<PkiAlertsV2Page />
|
||||||
|
</TabPanel>
|
||||||
|
|
||||||
|
<TabPanel value="legacy">
|
||||||
|
<div className="space-y-6">
|
||||||
|
<PkiAlertsSection />
|
||||||
|
<PkiCollectionSection />
|
||||||
|
</div>
|
||||||
|
</TabPanel>
|
||||||
|
</Tabs>
|
||||||
|
)}
|
||||||
</ProjectPermissionCan>
|
</ProjectPermissionCan>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -9,7 +9,6 @@ import { ProjectType } from "@app/hooks/api/projects/types";
|
|||||||
import { CertificateProfilesTab } from "./components/CertificateProfilesTab";
|
import { CertificateProfilesTab } from "./components/CertificateProfilesTab";
|
||||||
import { CertificatesTab } from "./components/CertificatesTab";
|
import { CertificatesTab } from "./components/CertificatesTab";
|
||||||
import { CertificateTemplatesV2Tab } from "./components/CertificateTemplatesV2Tab";
|
import { CertificateTemplatesV2Tab } from "./components/CertificateTemplatesV2Tab";
|
||||||
import { PkiCollectionsTab } from "./components/PkiCollectionsTab";
|
|
||||||
|
|
||||||
enum TabSections {
|
enum TabSections {
|
||||||
CertificateProfiles = "profiles",
|
CertificateProfiles = "profiles",
|
||||||
@@ -54,9 +53,6 @@ export const PoliciesPage = () => {
|
|||||||
<Tab variant="project" value={TabSections.Certificates}>
|
<Tab variant="project" value={TabSections.Certificates}>
|
||||||
Certificates
|
Certificates
|
||||||
</Tab>
|
</Tab>
|
||||||
<Tab variant="project" value={TabSections.PkiCollections}>
|
|
||||||
Certificate Collections
|
|
||||||
</Tab>
|
|
||||||
</TabList>
|
</TabList>
|
||||||
|
|
||||||
<TabPanel value={TabSections.CertificateProfiles}>
|
<TabPanel value={TabSections.CertificateProfiles}>
|
||||||
@@ -70,10 +66,6 @@ export const PoliciesPage = () => {
|
|||||||
<TabPanel value={TabSections.Certificates}>
|
<TabPanel value={TabSections.Certificates}>
|
||||||
<CertificatesTab />
|
<CertificatesTab />
|
||||||
</TabPanel>
|
</TabPanel>
|
||||||
|
|
||||||
<TabPanel value={TabSections.PkiCollections}>
|
|
||||||
<PkiCollectionsTab />
|
|
||||||
</TabPanel>
|
|
||||||
</Tabs>
|
</Tabs>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
208
frontend/src/views/PkiAlertsV2Page/PkiAlertsV2Page.tsx
Normal file
208
frontend/src/views/PkiAlertsV2Page/PkiAlertsV2Page.tsx
Normal file
@@ -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) => (
|
||||||
|
<Tr key={`skeleton-${index}`}>
|
||||||
|
<Td>
|
||||||
|
<Skeleton className="h-4 w-32" />
|
||||||
|
</Td>
|
||||||
|
<Td>
|
||||||
|
<Skeleton className="h-4 w-24" />
|
||||||
|
</Td>
|
||||||
|
<Td>
|
||||||
|
<Skeleton className="h-4 w-16" />
|
||||||
|
</Td>
|
||||||
|
<Td>
|
||||||
|
<Skeleton className="h-4 w-16" />
|
||||||
|
</Td>
|
||||||
|
<Td className="text-right">
|
||||||
|
<Skeleton className="ml-auto h-4 w-24" />
|
||||||
|
</Td>
|
||||||
|
</Tr>
|
||||||
|
));
|
||||||
|
}
|
||||||
|
|
||||||
|
if (alertsData?.alerts?.length) {
|
||||||
|
return alertsData.alerts.map((alert) => (
|
||||||
|
<PkiAlertV2Row
|
||||||
|
key={alert.id}
|
||||||
|
alert={alert}
|
||||||
|
onView={() => setViewModal({ isOpen: true, alertId: alert.id })}
|
||||||
|
onEdit={() => setAlertModal({ isOpen: true, alertId: alert.id })}
|
||||||
|
onDelete={() =>
|
||||||
|
setDeleteModal({
|
||||||
|
isOpen: true,
|
||||||
|
alertId: alert.id,
|
||||||
|
name: alert.name
|
||||||
|
})
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
));
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Tr>
|
||||||
|
<Td colSpan={5} className="py-8 text-center text-gray-400">
|
||||||
|
{search ? "No alerts found matching your search." : "No PKI alerts configured yet."}
|
||||||
|
</Td>
|
||||||
|
</Tr>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className={hideContainer ? "" : "container mx-auto p-6"}>
|
||||||
|
<div className="mb-4 flex items-center justify-between">
|
||||||
|
<div className="flex w-full items-center space-x-4">
|
||||||
|
<div className="relative mr-2 w-full">
|
||||||
|
<FontAwesomeIcon
|
||||||
|
icon={faSearch}
|
||||||
|
className="absolute top-1/2 left-3 -translate-y-1/2 transform text-gray-400"
|
||||||
|
/>
|
||||||
|
<Input
|
||||||
|
placeholder="Search alerts..."
|
||||||
|
value={search}
|
||||||
|
onChange={(e) => setSearch(e.target.value)}
|
||||||
|
className="w-full pl-10"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<Button
|
||||||
|
variant="solid"
|
||||||
|
colorSchema="primary"
|
||||||
|
leftIcon={<FontAwesomeIcon icon={faPlus} />}
|
||||||
|
onClick={() => setAlertModal({ isOpen: true })}
|
||||||
|
>
|
||||||
|
Create Certificate Alert
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<TableContainer>
|
||||||
|
<Table>
|
||||||
|
<THead>
|
||||||
|
<Tr>
|
||||||
|
<Th>Name</Th>
|
||||||
|
<Th>Event Type</Th>
|
||||||
|
<Th>Status</Th>
|
||||||
|
<Th>Alert Before</Th>
|
||||||
|
<Th className="text-right">Actions</Th>
|
||||||
|
</Tr>
|
||||||
|
</THead>
|
||||||
|
<TBody>{renderTableContent()}</TBody>
|
||||||
|
</Table>
|
||||||
|
</TableContainer>
|
||||||
|
|
||||||
|
{totalPages > 1 && (
|
||||||
|
<div className="mt-4 flex justify-center">
|
||||||
|
<Pagination
|
||||||
|
count={totalPages}
|
||||||
|
page={page}
|
||||||
|
onChangePage={setPage}
|
||||||
|
perPage={perPage}
|
||||||
|
onChangePerPage={setPerPage}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<CreatePkiAlertV2Modal
|
||||||
|
isOpen={alertModal.isOpen}
|
||||||
|
onOpenChange={(isOpen) => setAlertModal({ isOpen, alertId: undefined })}
|
||||||
|
alertId={alertModal.alertId}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<ViewPkiAlertV2Modal
|
||||||
|
isOpen={viewModal.isOpen}
|
||||||
|
onOpenChange={(isOpen) => setViewModal({ isOpen, alertId: undefined })}
|
||||||
|
alertId={viewModal.alertId}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<DeleteActionModal
|
||||||
|
isOpen={deleteModal.isOpen}
|
||||||
|
deleteKey="delete"
|
||||||
|
title={`Delete PKI Alert "${deleteModal.name}"`}
|
||||||
|
onChange={(isOpen) => setDeleteModal({ isOpen, alertId: undefined, name: undefined })}
|
||||||
|
onDeleteApproved={handleDeleteAlert}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
@@ -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<TCreatePkiAlertV2>();
|
||||||
|
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<TPkiFilterRuleV2>) => {
|
||||||
|
const currentFilters = [...(watchedFilters || [])];
|
||||||
|
currentFilters[index] = { ...currentFilters[index], ...updatedFilter };
|
||||||
|
setValue("filters", currentFilters);
|
||||||
|
};
|
||||||
|
|
||||||
|
const updateChannel = (
|
||||||
|
index: number,
|
||||||
|
updatedChannel: Partial<Omit<TPkiAlertChannelV2, "id" | "createdAt" | "updatedAt">>
|
||||||
|
) => {
|
||||||
|
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 (
|
||||||
|
<>
|
||||||
|
<Tab.Panel>
|
||||||
|
<div className="space-y-6">
|
||||||
|
<p className="mb-4 text-sm text-bunker-300">
|
||||||
|
Choose the event that will trigger this alert notification.
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<div className="w-full">
|
||||||
|
<Controller
|
||||||
|
control={control}
|
||||||
|
name="eventType"
|
||||||
|
render={({ field, fieldState: { error } }) => (
|
||||||
|
<FormControl label="Alert Type" isError={Boolean(error)} errorText={error?.message}>
|
||||||
|
<Select
|
||||||
|
value={field.value}
|
||||||
|
onValueChange={(value) => field.onChange(value)}
|
||||||
|
className="w-full"
|
||||||
|
>
|
||||||
|
<SelectItem value={PkiAlertEventTypeV2.EXPIRATION}>
|
||||||
|
Certificate Expiration
|
||||||
|
</SelectItem>
|
||||||
|
</Select>
|
||||||
|
</FormControl>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</Tab.Panel>
|
||||||
|
|
||||||
|
<Tab.Panel>
|
||||||
|
<div className="space-y-6">
|
||||||
|
<p className="mb-4 text-sm text-bunker-300">
|
||||||
|
Configure the name, description, and timing for your alert.
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<Controller
|
||||||
|
control={control}
|
||||||
|
name="name"
|
||||||
|
render={({ field, fieldState: { error } }) => (
|
||||||
|
<FormControl label="Alert Name" isError={Boolean(error)} errorText={error?.message}>
|
||||||
|
<Input {...field} placeholder="e.g., prod-cert-expiring-soon" />
|
||||||
|
</FormControl>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<Controller
|
||||||
|
control={control}
|
||||||
|
name="description"
|
||||||
|
render={({ field, fieldState: { error } }) => (
|
||||||
|
<FormControl
|
||||||
|
label="Description (Optional)"
|
||||||
|
isError={Boolean(error)}
|
||||||
|
errorText={error?.message}
|
||||||
|
>
|
||||||
|
<TextArea {...field} placeholder="Alert description..." rows={2} />
|
||||||
|
</FormControl>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
|
||||||
|
{watchedEventType === PkiAlertEventTypeV2.EXPIRATION && (
|
||||||
|
<Controller
|
||||||
|
control={control}
|
||||||
|
name="alertBefore"
|
||||||
|
render={({ field, fieldState: { error } }) => (
|
||||||
|
<FormControl
|
||||||
|
label="Alert Before"
|
||||||
|
isError={Boolean(error)}
|
||||||
|
errorText={error?.message}
|
||||||
|
helperText="Format: number + unit (d=days, w=weeks, m=months, y=years). Example: 30d"
|
||||||
|
>
|
||||||
|
<Input {...field} placeholder="30d" />
|
||||||
|
</FormControl>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</Tab.Panel>
|
||||||
|
|
||||||
|
<Tab.Panel>
|
||||||
|
<div className="space-y-6">
|
||||||
|
<p className="mb-4 text-sm text-bunker-300">
|
||||||
|
Add filter rules to specify which certificates should trigger this alert. Leave empty to
|
||||||
|
monitor all certificates.
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<div className="space-y-4">
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<FormLabel label="Certificate Filter Rules" />
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
variant="outline_bg"
|
||||||
|
size="sm"
|
||||||
|
leftIcon={<FontAwesomeIcon icon={faPlus} />}
|
||||||
|
onClick={addFilter}
|
||||||
|
>
|
||||||
|
Add Filter
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{watchedFilters?.map((filter, index) => (
|
||||||
|
<div
|
||||||
|
key={`filter-${index}`}
|
||||||
|
className="space-y-2 rounded-md border border-mineshaft-600 p-3"
|
||||||
|
>
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<h4 className="text-sm font-medium text-mineshaft-100">
|
||||||
|
Filter Rule #{index + 1}
|
||||||
|
</h4>
|
||||||
|
<IconButton
|
||||||
|
size="sm"
|
||||||
|
variant="plain"
|
||||||
|
colorSchema="danger"
|
||||||
|
ariaLabel="Remove filter"
|
||||||
|
onClick={() => removeFilter(index)}
|
||||||
|
>
|
||||||
|
<FontAwesomeIcon icon={faTrash} />
|
||||||
|
</IconButton>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="grid grid-cols-1 gap-4 md:grid-cols-3">
|
||||||
|
<div className="md:col-span-1">
|
||||||
|
<FormControl label="Field">
|
||||||
|
<Select
|
||||||
|
value={filter.field}
|
||||||
|
onValueChange={(value) =>
|
||||||
|
updateFilter(index, {
|
||||||
|
field: value as PkiFilterFieldV2,
|
||||||
|
operator:
|
||||||
|
value === PkiFilterFieldV2.INCLUDE_CAS
|
||||||
|
? PkiFilterOperatorV2.EQUALS
|
||||||
|
: filter.operator,
|
||||||
|
value: isValueBoolean(value as PkiFilterFieldV2) ? false : ""
|
||||||
|
})
|
||||||
|
}
|
||||||
|
className="w-full min-w-[200px]"
|
||||||
|
>
|
||||||
|
<SelectItem value={PkiFilterFieldV2.COMMON_NAME}>Common Name</SelectItem>
|
||||||
|
<SelectItem value={PkiFilterFieldV2.PROFILE_NAME}>Profile Name</SelectItem>
|
||||||
|
<SelectItem value={PkiFilterFieldV2.SAN}>
|
||||||
|
Subject Alternative Names
|
||||||
|
</SelectItem>
|
||||||
|
<SelectItem value={PkiFilterFieldV2.INCLUDE_CAS}>
|
||||||
|
Include Certificate Authorities
|
||||||
|
</SelectItem>
|
||||||
|
</Select>
|
||||||
|
</FormControl>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="md:col-span-1">
|
||||||
|
<FormControl label="Operator">
|
||||||
|
<Select
|
||||||
|
value={filter.operator}
|
||||||
|
onValueChange={(value) =>
|
||||||
|
updateFilter(index, { operator: value as PkiFilterOperatorV2 })
|
||||||
|
}
|
||||||
|
className="w-full min-w-[140px]"
|
||||||
|
>
|
||||||
|
{getFieldOperators(filter.field).map((operator) => (
|
||||||
|
<SelectItem key={operator} value={operator}>
|
||||||
|
{operator
|
||||||
|
.replace(/_/g, " ")
|
||||||
|
.toLowerCase()
|
||||||
|
.replace(/\b\w/g, (l) => l.toUpperCase())}
|
||||||
|
</SelectItem>
|
||||||
|
))}
|
||||||
|
</Select>
|
||||||
|
</FormControl>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="md:col-span-1">
|
||||||
|
<FormControl label="Value">
|
||||||
|
{isValueBoolean(filter.field) ? (
|
||||||
|
<Select
|
||||||
|
value={String(filter.value)}
|
||||||
|
onValueChange={(value) =>
|
||||||
|
updateFilter(index, { value: value === "true" })
|
||||||
|
}
|
||||||
|
className="w-full"
|
||||||
|
>
|
||||||
|
<SelectItem value="true">Yes</SelectItem>
|
||||||
|
<SelectItem value="false">No</SelectItem>
|
||||||
|
</Select>
|
||||||
|
) : (
|
||||||
|
<Input
|
||||||
|
value={
|
||||||
|
Array.isArray(filter.value)
|
||||||
|
? filter.value.join(", ")
|
||||||
|
: String(filter.value || "")
|
||||||
|
}
|
||||||
|
onChange={(e) => {
|
||||||
|
const { value } = e.target;
|
||||||
|
updateFilter(index, { value });
|
||||||
|
}}
|
||||||
|
onBlur={(e) => {
|
||||||
|
const { value } = e.target;
|
||||||
|
if (canOperatorTakeArray(filter.operator) && value.includes(",")) {
|
||||||
|
const finalValue = value
|
||||||
|
.split(",")
|
||||||
|
.map((v) => v.trim())
|
||||||
|
.filter(Boolean);
|
||||||
|
updateFilter(index, { value: finalValue });
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
placeholder={
|
||||||
|
canOperatorTakeArray(filter.operator)
|
||||||
|
? "example.com, test.com"
|
||||||
|
: "example.com"
|
||||||
|
}
|
||||||
|
className="w-full"
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</FormControl>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
|
||||||
|
{(!watchedFilters || watchedFilters.length === 0) && (
|
||||||
|
<div className="py-8 text-center text-bunker-400">
|
||||||
|
No filter rules configured. This alert will monitor all certificates.
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</Tab.Panel>
|
||||||
|
|
||||||
|
<Tab.Panel>
|
||||||
|
<div className="space-y-6">
|
||||||
|
<p className="mb-4 text-sm text-bunker-300">
|
||||||
|
Preview all certificates that match your filter criteria. This shows all non-expired
|
||||||
|
certificates that would be monitored by this alert.
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<div className="space-y-4">
|
||||||
|
<TableContainer>
|
||||||
|
<Table>
|
||||||
|
<THead>
|
||||||
|
<Tr>
|
||||||
|
<Th className="w-1/2">SAN / CN</Th>
|
||||||
|
<Th className="w-1/4">Not Before</Th>
|
||||||
|
<Th className="w-1/4">Not After</Th>
|
||||||
|
</Tr>
|
||||||
|
</THead>
|
||||||
|
<TBody>
|
||||||
|
{(() => {
|
||||||
|
if (watchedEventType !== PkiAlertEventTypeV2.EXPIRATION) {
|
||||||
|
return (
|
||||||
|
<Tr>
|
||||||
|
<Td colSpan={3} className="py-8 text-center text-gray-400">
|
||||||
|
Preview is only available for Certificate Expiration alerts
|
||||||
|
</Td>
|
||||||
|
</Tr>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (isLoadingCurrentCertificates) {
|
||||||
|
return Array.from({ length: 5 }, (_, index) => (
|
||||||
|
<Tr key={`skeleton-row-${index}`}>
|
||||||
|
<Td>
|
||||||
|
<Skeleton className="h-4 w-32" />
|
||||||
|
</Td>
|
||||||
|
<Td>
|
||||||
|
<Skeleton className="h-4 w-24" />
|
||||||
|
</Td>
|
||||||
|
<Td>
|
||||||
|
<Skeleton className="h-4 w-24" />
|
||||||
|
</Td>
|
||||||
|
</Tr>
|
||||||
|
));
|
||||||
|
}
|
||||||
|
|
||||||
|
if (currentCertificatesData?.certificates?.length) {
|
||||||
|
return currentCertificatesData.certificates.map((cert) => (
|
||||||
|
<Tr key={cert.id} className="group h-10">
|
||||||
|
<Td className="max-w-0">
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<CertificateDisplayName
|
||||||
|
cert={{
|
||||||
|
altNames: cert.san?.join(", ") || null,
|
||||||
|
commonName: cert.commonName
|
||||||
|
}}
|
||||||
|
maxLength={48}
|
||||||
|
fallback="—"
|
||||||
|
/>
|
||||||
|
{(cert.enrollmentType === "ca" ||
|
||||||
|
cert.enrollmentType === "internal-ca") && (
|
||||||
|
<Badge variant="info" className="shrink-0 text-xs">
|
||||||
|
CA
|
||||||
|
</Badge>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</Td>
|
||||||
|
<Td>
|
||||||
|
{cert.notBefore
|
||||||
|
? new Date(cert.notBefore).toLocaleDateString("en-CA")
|
||||||
|
: "-"}
|
||||||
|
</Td>
|
||||||
|
<Td>
|
||||||
|
{cert.notAfter
|
||||||
|
? new Date(cert.notAfter).toLocaleDateString("en-CA")
|
||||||
|
: "-"}
|
||||||
|
</Td>
|
||||||
|
</Tr>
|
||||||
|
));
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Tr>
|
||||||
|
<Td colSpan={3} className="py-8 text-center text-gray-400">
|
||||||
|
No certificates currently match this alert's criteria
|
||||||
|
</Td>
|
||||||
|
</Tr>
|
||||||
|
);
|
||||||
|
})()}
|
||||||
|
</TBody>
|
||||||
|
</Table>
|
||||||
|
</TableContainer>
|
||||||
|
|
||||||
|
{(currentCertificatesData?.total || 0) > 0 && (
|
||||||
|
<div className="flex justify-center">
|
||||||
|
<Pagination
|
||||||
|
count={currentCertificatesData?.total || 0}
|
||||||
|
page={certificatesPage}
|
||||||
|
onChangePage={setCertificatesPage}
|
||||||
|
perPage={certificatesPerPage}
|
||||||
|
onChangePerPage={() => {}}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</Tab.Panel>
|
||||||
|
|
||||||
|
<Tab.Panel>
|
||||||
|
<div className="space-y-6">
|
||||||
|
<p className="mb-4 text-sm text-bunker-300">
|
||||||
|
Set up email notifications to receive alerts when events occur.
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<div className="space-y-4">
|
||||||
|
<FormControl label="Email Recipients">
|
||||||
|
<TextArea
|
||||||
|
value={
|
||||||
|
Array.isArray((watchedChannels?.[0]?.config as any)?.recipients)
|
||||||
|
? (watchedChannels[0].config as any).recipients.join(", ")
|
||||||
|
: ""
|
||||||
|
}
|
||||||
|
onChange={(e) => {
|
||||||
|
const emailList = e.target.value
|
||||||
|
.split(",")
|
||||||
|
.map((email) => email.trim())
|
||||||
|
.filter((email) => email.length > 0);
|
||||||
|
updateChannel(0, { config: { recipients: emailList }, enabled: true });
|
||||||
|
}}
|
||||||
|
placeholder="admin@example.com, security@example.com"
|
||||||
|
className="w-full"
|
||||||
|
rows={2}
|
||||||
|
/>
|
||||||
|
</FormControl>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</Tab.Panel>
|
||||||
|
|
||||||
|
<Tab.Panel>
|
||||||
|
<div className="mb-4 flex flex-col gap-6">
|
||||||
|
<p className="mb-4 text-sm text-bunker-300">
|
||||||
|
Please review the settings below before creating your alert.
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<div className="flex flex-col gap-3">
|
||||||
|
<div className="w-full border-b border-mineshaft-600">
|
||||||
|
<span className="text-sm text-mineshaft-300">Basic Information</span>
|
||||||
|
</div>
|
||||||
|
<div className="flex flex-wrap gap-x-8 gap-y-2">
|
||||||
|
<GenericFieldLabel label="Name">{watch("name") || "Not specified"}</GenericFieldLabel>
|
||||||
|
<GenericFieldLabel label="Event Type">
|
||||||
|
{formatEventType(watchedEventType)}
|
||||||
|
</GenericFieldLabel>
|
||||||
|
<GenericFieldLabel label="Status">
|
||||||
|
<div className="mt-1">
|
||||||
|
<Badge variant={watch("enabled") ? "success" : "danger"}>
|
||||||
|
{watch("enabled") ? "Enabled" : "Disabled"}
|
||||||
|
</Badge>
|
||||||
|
</div>
|
||||||
|
</GenericFieldLabel>
|
||||||
|
{watchedEventType === PkiAlertEventTypeV2.EXPIRATION && (
|
||||||
|
<GenericFieldLabel label="Alert Before">
|
||||||
|
{formatAlertBefore(watch("alertBefore"))}
|
||||||
|
</GenericFieldLabel>
|
||||||
|
)}
|
||||||
|
{watch("description") && (
|
||||||
|
<GenericFieldLabel label="Description">{watch("description")}</GenericFieldLabel>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex flex-col gap-3">
|
||||||
|
<div className="w-full border-b border-mineshaft-600">
|
||||||
|
<span className="text-sm text-mineshaft-300">Filter Rules</span>
|
||||||
|
</div>
|
||||||
|
<div className="flex flex-wrap gap-x-8 gap-y-2">
|
||||||
|
{watchedFilters && watchedFilters.length > 0 ? (
|
||||||
|
watchedFilters.map((filter, index) => (
|
||||||
|
<GenericFieldLabel key={`review-filter-${index}`} label={`Rule ${index + 1}`}>
|
||||||
|
<span className="font-mono text-xs">
|
||||||
|
{filter.field
|
||||||
|
.replace(/_/g, " ")
|
||||||
|
.toLowerCase()
|
||||||
|
.replace(/\b\w/g, (l) => l.toUpperCase())}{" "}
|
||||||
|
{filter.operator
|
||||||
|
.replace(/_/g, " ")
|
||||||
|
.toLowerCase()
|
||||||
|
.replace(/\b\w/g, (l) => l.toUpperCase())}{" "}
|
||||||
|
"{String(filter.value)}"
|
||||||
|
</span>
|
||||||
|
</GenericFieldLabel>
|
||||||
|
))
|
||||||
|
) : (
|
||||||
|
<span className="text-bunker-400">
|
||||||
|
No filter rules - will monitor all certificates
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex flex-col gap-3">
|
||||||
|
<div className="w-full border-b border-mineshaft-600">
|
||||||
|
<span className="text-sm text-mineshaft-300">Notifications</span>
|
||||||
|
</div>
|
||||||
|
<div className="flex flex-wrap gap-x-8 gap-y-3">
|
||||||
|
<GenericFieldLabel label="Email Recipients">
|
||||||
|
{Array.isArray((watchedChannels?.[0]?.config as any)?.recipients)
|
||||||
|
? (watchedChannels[0].config as any).recipients.join(", ")
|
||||||
|
: (watchedChannels?.[0]?.config as any)?.recipients || "No recipients"}
|
||||||
|
</GenericFieldLabel>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</Tab.Panel>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
};
|
||||||
@@ -0,0 +1,268 @@
|
|||||||
|
import React, { useState } from "react";
|
||||||
|
import { FormProvider, useForm } from "react-hook-form";
|
||||||
|
import { Tab } from "@headlessui/react";
|
||||||
|
import { zodResolver } from "@hookform/resolvers/zod";
|
||||||
|
import { twMerge } from "tailwind-merge";
|
||||||
|
|
||||||
|
import { createNotification } from "@app/components/notifications";
|
||||||
|
import { Button, Modal, ModalContent } from "@app/components/v2";
|
||||||
|
import { useProject } from "@app/context";
|
||||||
|
import {
|
||||||
|
createPkiAlertV2Schema,
|
||||||
|
PkiAlertChannelTypeV2,
|
||||||
|
PkiAlertEventTypeV2,
|
||||||
|
TCreatePkiAlertV2,
|
||||||
|
TPkiAlertChannelConfigEmail,
|
||||||
|
TPkiAlertV2,
|
||||||
|
TUpdatePkiAlertV2,
|
||||||
|
updatePkiAlertV2Schema,
|
||||||
|
useCreatePkiAlertV2,
|
||||||
|
useGetPkiAlertV2ById,
|
||||||
|
useUpdatePkiAlertV2
|
||||||
|
} from "@app/hooks/api/pkiAlertsV2";
|
||||||
|
|
||||||
|
import { CreatePkiAlertV2FormSteps } from "./CreatePkiAlertV2FormSteps";
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
isOpen: boolean;
|
||||||
|
onOpenChange: (isOpen: boolean) => void;
|
||||||
|
alertToEdit?: TPkiAlertV2;
|
||||||
|
alertId?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
type TFormData = TCreatePkiAlertV2;
|
||||||
|
|
||||||
|
const FORM_TABS: { name: string; key: string; fields: (keyof TFormData)[] }[] = [
|
||||||
|
{ name: "Alert Type", key: "alertType", fields: ["eventType"] },
|
||||||
|
{
|
||||||
|
name: "Details",
|
||||||
|
key: "basicInfo",
|
||||||
|
fields: ["name", "description", "alertBefore"]
|
||||||
|
},
|
||||||
|
{ name: "Filters", key: "filterRules", fields: ["filters"] },
|
||||||
|
{ name: "Preview", key: "preview", fields: [] },
|
||||||
|
{ name: "Channels", key: "channels", fields: ["channels"] },
|
||||||
|
{ name: "Review", key: "review", fields: [] }
|
||||||
|
];
|
||||||
|
|
||||||
|
export const CreatePkiAlertV2Modal = ({ isOpen, onOpenChange, alertToEdit, alertId }: Props) => {
|
||||||
|
const { currentProject } = useProject();
|
||||||
|
const [selectedTabIndex, setSelectedTabIndex] = useState(0);
|
||||||
|
|
||||||
|
const { data: fetchedAlert } = useGetPkiAlertV2ById(
|
||||||
|
{ alertId: alertId || "" },
|
||||||
|
{ enabled: !!alertId && isOpen && !alertToEdit }
|
||||||
|
);
|
||||||
|
|
||||||
|
const editingAlert = alertToEdit || fetchedAlert;
|
||||||
|
const isEditing = !!(editingAlert || alertId);
|
||||||
|
|
||||||
|
const formMethods = useForm<TFormData>({
|
||||||
|
resolver: zodResolver(isEditing ? updatePkiAlertV2Schema : createPkiAlertV2Schema),
|
||||||
|
defaultValues: {
|
||||||
|
projectId: currentProject?.id || "",
|
||||||
|
name: "",
|
||||||
|
description: "",
|
||||||
|
eventType: PkiAlertEventTypeV2.EXPIRATION,
|
||||||
|
alertBefore: "30d",
|
||||||
|
filters: [],
|
||||||
|
enabled: true,
|
||||||
|
channels: [
|
||||||
|
{
|
||||||
|
channelType: PkiAlertChannelTypeV2.EMAIL,
|
||||||
|
config: { recipients: [] },
|
||||||
|
enabled: true
|
||||||
|
}
|
||||||
|
]
|
||||||
|
},
|
||||||
|
reValidateMode: "onBlur"
|
||||||
|
});
|
||||||
|
|
||||||
|
const { handleSubmit, trigger, reset } = formMethods;
|
||||||
|
|
||||||
|
const { mutateAsync: createAlert } = useCreatePkiAlertV2();
|
||||||
|
const { mutateAsync: updateAlert } = useUpdatePkiAlertV2();
|
||||||
|
|
||||||
|
const handleModalClose = () => {
|
||||||
|
reset();
|
||||||
|
setSelectedTabIndex(0);
|
||||||
|
onOpenChange(false);
|
||||||
|
};
|
||||||
|
|
||||||
|
React.useEffect(() => {
|
||||||
|
if (editingAlert && isEditing) {
|
||||||
|
reset({
|
||||||
|
projectId: currentProject?.id || "",
|
||||||
|
name: editingAlert.name,
|
||||||
|
description: editingAlert.description || "",
|
||||||
|
eventType: editingAlert.eventType,
|
||||||
|
alertBefore: editingAlert.alertBefore || "30d",
|
||||||
|
filters: editingAlert.filters || [],
|
||||||
|
enabled: editingAlert.enabled,
|
||||||
|
channels: editingAlert.channels?.map(({ id, createdAt, updatedAt, ...channel }) => {
|
||||||
|
if (channel.channelType === PkiAlertChannelTypeV2.EMAIL) {
|
||||||
|
const emailConfig = channel.config as TPkiAlertChannelConfigEmail;
|
||||||
|
return {
|
||||||
|
...channel,
|
||||||
|
config: {
|
||||||
|
recipients: Array.isArray(emailConfig.recipients) ? emailConfig.recipients : []
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}
|
||||||
|
return channel;
|
||||||
|
}) || [
|
||||||
|
{
|
||||||
|
channelType: PkiAlertChannelTypeV2.EMAIL,
|
||||||
|
config: { recipients: [] },
|
||||||
|
enabled: true
|
||||||
|
}
|
||||||
|
]
|
||||||
|
});
|
||||||
|
} else if (!isEditing) {
|
||||||
|
reset({
|
||||||
|
projectId: currentProject?.id || "",
|
||||||
|
name: "",
|
||||||
|
description: "",
|
||||||
|
eventType: PkiAlertEventTypeV2.EXPIRATION,
|
||||||
|
alertBefore: "30d",
|
||||||
|
filters: [],
|
||||||
|
enabled: true,
|
||||||
|
channels: [
|
||||||
|
{
|
||||||
|
channelType: PkiAlertChannelTypeV2.EMAIL,
|
||||||
|
config: { recipients: [] },
|
||||||
|
enabled: true
|
||||||
|
}
|
||||||
|
]
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}, [editingAlert, isEditing, currentProject?.id, reset]);
|
||||||
|
|
||||||
|
const onSubmit = async (data: TFormData) => {
|
||||||
|
if (!currentProject?.id) return;
|
||||||
|
|
||||||
|
const processedData = {
|
||||||
|
...data,
|
||||||
|
channels: data.channels?.map((channel) => {
|
||||||
|
if (channel.channelType === PkiAlertChannelTypeV2.EMAIL) {
|
||||||
|
const emailConfig = channel.config as TPkiAlertChannelConfigEmail;
|
||||||
|
return {
|
||||||
|
...channel,
|
||||||
|
config: {
|
||||||
|
recipients: Array.isArray(emailConfig.recipients) ? emailConfig.recipients : []
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}
|
||||||
|
return channel;
|
||||||
|
})
|
||||||
|
};
|
||||||
|
|
||||||
|
try {
|
||||||
|
if (isEditing && (alertId || editingAlert?.id)) {
|
||||||
|
await updateAlert({
|
||||||
|
alertId: alertId || editingAlert!.id,
|
||||||
|
...processedData
|
||||||
|
} as TUpdatePkiAlertV2);
|
||||||
|
} else {
|
||||||
|
await createAlert({
|
||||||
|
...processedData,
|
||||||
|
projectId: currentProject.id
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
createNotification({
|
||||||
|
text: `PKI alert ${isEditing ? "updated" : "created"} successfully`,
|
||||||
|
type: "success"
|
||||||
|
});
|
||||||
|
|
||||||
|
handleModalClose();
|
||||||
|
} catch {
|
||||||
|
createNotification({
|
||||||
|
text: `Failed to ${isEditing ? "update" : "create"} PKI alert`,
|
||||||
|
type: "error"
|
||||||
|
});
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handlePrev = () => {
|
||||||
|
if (selectedTabIndex === 0) {
|
||||||
|
onOpenChange(false);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
setSelectedTabIndex((prev) => prev - 1);
|
||||||
|
};
|
||||||
|
|
||||||
|
const isStepValid = async (index: number) => trigger(FORM_TABS[index].fields);
|
||||||
|
|
||||||
|
const isFinalStep = selectedTabIndex === FORM_TABS.length - 1;
|
||||||
|
|
||||||
|
const handleNext = async () => {
|
||||||
|
if (isFinalStep) {
|
||||||
|
await handleSubmit(onSubmit)();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const isValid = await isStepValid(selectedTabIndex);
|
||||||
|
if (!isValid) return;
|
||||||
|
|
||||||
|
setSelectedTabIndex((prev) => prev + 1);
|
||||||
|
};
|
||||||
|
|
||||||
|
const isTabEnabled = async (index: number) => {
|
||||||
|
const validationPromises = [];
|
||||||
|
for (let i = index - 1; i >= 0; i -= 1) {
|
||||||
|
validationPromises.push(isStepValid(i));
|
||||||
|
}
|
||||||
|
const results = await Promise.all(validationPromises);
|
||||||
|
return results.every(Boolean);
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Modal isOpen={isOpen} onOpenChange={handleModalClose}>
|
||||||
|
<ModalContent
|
||||||
|
title={`${isEditing ? "Update" : "Create"} Certificate Alert`}
|
||||||
|
className="max-w-2xl"
|
||||||
|
>
|
||||||
|
<form className={twMerge(isFinalStep && "max-h-[70vh] overflow-y-auto")}>
|
||||||
|
<FormProvider {...formMethods}>
|
||||||
|
<Tab.Group selectedIndex={selectedTabIndex} onChange={setSelectedTabIndex}>
|
||||||
|
<Tab.List className="-pb-1 mb-6 w-full border-b-2 border-mineshaft-600">
|
||||||
|
{FORM_TABS.map((tab, index) => (
|
||||||
|
<Tab
|
||||||
|
onClick={async (e) => {
|
||||||
|
e.preventDefault();
|
||||||
|
const isEnabled = await isTabEnabled(index);
|
||||||
|
setSelectedTabIndex((prev) => (isEnabled ? index : prev));
|
||||||
|
}}
|
||||||
|
className={({ selected }) =>
|
||||||
|
`-mb-[0.14rem] whitespace-nowrap ${index > selectedTabIndex ? "opacity-30" : ""} px-4 py-2 text-sm font-medium outline-hidden disabled:opacity-60 ${
|
||||||
|
selected
|
||||||
|
? "border-b-2 border-mineshaft-300 text-mineshaft-200"
|
||||||
|
: "text-bunker-300"
|
||||||
|
}`
|
||||||
|
}
|
||||||
|
key={tab.key}
|
||||||
|
>
|
||||||
|
{index + 1}. {tab.name}
|
||||||
|
</Tab>
|
||||||
|
))}
|
||||||
|
</Tab.List>
|
||||||
|
<Tab.Panels>
|
||||||
|
<CreatePkiAlertV2FormSteps />
|
||||||
|
</Tab.Panels>
|
||||||
|
</Tab.Group>
|
||||||
|
</FormProvider>
|
||||||
|
|
||||||
|
<div className="flex w-full flex-row-reverse justify-between gap-4 pt-4">
|
||||||
|
<Button onClick={handleNext} colorSchema="secondary">
|
||||||
|
{isFinalStep ? `${isEditing ? "Update" : "Create"} Alert` : "Next"}
|
||||||
|
</Button>
|
||||||
|
<Button onClick={handlePrev} colorSchema="secondary">
|
||||||
|
{selectedTabIndex === 0 ? "Cancel" : "Back"}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</ModalContent>
|
||||||
|
</Modal>
|
||||||
|
);
|
||||||
|
};
|
||||||
136
frontend/src/views/PkiAlertsV2Page/components/PkiAlertV2Row.tsx
Normal file
136
frontend/src/views/PkiAlertsV2Page/components/PkiAlertV2Row.tsx
Normal file
@@ -0,0 +1,136 @@
|
|||||||
|
import {
|
||||||
|
faCircleInfo,
|
||||||
|
faEllipsisH,
|
||||||
|
faEye,
|
||||||
|
faPencil,
|
||||||
|
faPlay,
|
||||||
|
faStop,
|
||||||
|
faTrash
|
||||||
|
} from "@fortawesome/free-solid-svg-icons";
|
||||||
|
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
|
||||||
|
|
||||||
|
import { createNotification } from "@app/components/notifications";
|
||||||
|
import {
|
||||||
|
DropdownMenu,
|
||||||
|
DropdownMenuContent,
|
||||||
|
DropdownMenuItem,
|
||||||
|
DropdownMenuTrigger,
|
||||||
|
IconButton,
|
||||||
|
Td,
|
||||||
|
Tooltip,
|
||||||
|
Tr
|
||||||
|
} from "@app/components/v2";
|
||||||
|
import { Badge } from "@app/components/v3";
|
||||||
|
import { PkiAlertEventTypeV2, TPkiAlertV2, useUpdatePkiAlertV2 } from "@app/hooks/api/pkiAlertsV2";
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
alert: TPkiAlertV2;
|
||||||
|
onView: () => void;
|
||||||
|
onEdit: () => void;
|
||||||
|
onDelete: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const PkiAlertV2Row = ({ alert, onView, onEdit, onDelete }: Props) => {
|
||||||
|
const { mutateAsync: updateAlert } = useUpdatePkiAlertV2();
|
||||||
|
|
||||||
|
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 handleToggleAlert = async () => {
|
||||||
|
try {
|
||||||
|
await updateAlert({
|
||||||
|
alertId: alert.id,
|
||||||
|
enabled: !alert.enabled
|
||||||
|
});
|
||||||
|
createNotification({
|
||||||
|
text: `Alert ${!alert.enabled ? "enabled" : "disabled"} successfully`,
|
||||||
|
type: "success"
|
||||||
|
});
|
||||||
|
} catch {
|
||||||
|
createNotification({
|
||||||
|
text: "Failed to update alert status",
|
||||||
|
type: "error"
|
||||||
|
});
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
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 (
|
||||||
|
<Tr>
|
||||||
|
<Td>
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<div className="font-medium text-gray-200">{alert.name}</div>
|
||||||
|
{alert.description && (
|
||||||
|
<Tooltip content={alert.description}>
|
||||||
|
<FontAwesomeIcon icon={faCircleInfo} className="text-mineshaft-400" />
|
||||||
|
</Tooltip>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</Td>
|
||||||
|
<Td>
|
||||||
|
<span className="text-gray-300">{formatEventType(alert.eventType)}</span>
|
||||||
|
</Td>
|
||||||
|
<Td>
|
||||||
|
<Badge variant={alert.enabled ? "success" : "neutral"}>
|
||||||
|
{alert.enabled ? "Enabled" : "Disabled"}
|
||||||
|
</Badge>
|
||||||
|
</Td>
|
||||||
|
<Td className="text-gray-300">{formatAlertBefore(alert.alertBefore)}</Td>
|
||||||
|
<Td className="text-right">
|
||||||
|
<DropdownMenu>
|
||||||
|
<DropdownMenuTrigger asChild>
|
||||||
|
<IconButton size="sm" variant="plain" ariaLabel="Alert actions">
|
||||||
|
<FontAwesomeIcon icon={faEllipsisH} />
|
||||||
|
</IconButton>
|
||||||
|
</DropdownMenuTrigger>
|
||||||
|
<DropdownMenuContent align="end">
|
||||||
|
<DropdownMenuItem onClick={onView}>
|
||||||
|
<FontAwesomeIcon icon={faEye} className="mr-2 h-4 w-4" />
|
||||||
|
View details
|
||||||
|
</DropdownMenuItem>
|
||||||
|
<DropdownMenuItem onClick={onEdit}>
|
||||||
|
<FontAwesomeIcon icon={faPencil} className="mr-2 h-4 w-4" />
|
||||||
|
Edit alert
|
||||||
|
</DropdownMenuItem>
|
||||||
|
<DropdownMenuItem onClick={handleToggleAlert}>
|
||||||
|
<FontAwesomeIcon icon={alert.enabled ? faStop : faPlay} className="mr-2 h-4 w-4" />
|
||||||
|
{alert.enabled ? "Disable" : "Enable"} alert
|
||||||
|
</DropdownMenuItem>
|
||||||
|
<DropdownMenuItem onClick={onDelete} className="text-red-600">
|
||||||
|
<FontAwesomeIcon icon={faTrash} className="mr-2 h-4 w-4" />
|
||||||
|
Delete alert
|
||||||
|
</DropdownMenuItem>
|
||||||
|
</DropdownMenuContent>
|
||||||
|
</DropdownMenu>
|
||||||
|
</Td>
|
||||||
|
</Tr>
|
||||||
|
);
|
||||||
|
};
|
||||||
@@ -0,0 +1,478 @@
|
|||||||
|
import { useEffect, useState } from "react";
|
||||||
|
import { Controller, useForm } from "react-hook-form";
|
||||||
|
import { faPlus, faTrash } from "@fortawesome/free-solid-svg-icons";
|
||||||
|
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
|
||||||
|
import { zodResolver } from "@hookform/resolvers/zod";
|
||||||
|
|
||||||
|
import { createNotification } from "@app/components/notifications";
|
||||||
|
import {
|
||||||
|
Button,
|
||||||
|
FormControl,
|
||||||
|
FormLabel,
|
||||||
|
IconButton,
|
||||||
|
Input,
|
||||||
|
Modal,
|
||||||
|
ModalContent,
|
||||||
|
Select,
|
||||||
|
SelectItem,
|
||||||
|
Skeleton,
|
||||||
|
Switch,
|
||||||
|
TextArea
|
||||||
|
} from "@app/components/v2";
|
||||||
|
import {
|
||||||
|
PkiAlertChannelTypeV2,
|
||||||
|
PkiAlertEventTypeV2,
|
||||||
|
PkiFilterFieldV2,
|
||||||
|
PkiFilterOperatorV2,
|
||||||
|
TPkiAlertChannelV2,
|
||||||
|
TPkiFilterRuleV2,
|
||||||
|
TUpdatePkiAlertV2,
|
||||||
|
updatePkiAlertV2Schema,
|
||||||
|
useGetPkiAlertV2ById,
|
||||||
|
useUpdatePkiAlertV2
|
||||||
|
} from "@app/hooks/api/pkiAlertsV2";
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
isOpen: boolean;
|
||||||
|
onOpenChange: (isOpen: boolean) => void;
|
||||||
|
alertId?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
type TFormData = Omit<TUpdatePkiAlertV2, "alertId">;
|
||||||
|
|
||||||
|
export const UpdatePkiAlertV2Modal = ({ isOpen, onOpenChange, alertId }: Props) => {
|
||||||
|
const [isSubmitting, setIsSubmitting] = useState(false);
|
||||||
|
|
||||||
|
const { control, handleSubmit, reset, watch, setValue } = useForm<TFormData>({
|
||||||
|
resolver: zodResolver(updatePkiAlertV2Schema)
|
||||||
|
});
|
||||||
|
|
||||||
|
const { data: alert, isLoading } = useGetPkiAlertV2ById(
|
||||||
|
{ alertId: alertId || "" },
|
||||||
|
{ enabled: !!alertId && isOpen }
|
||||||
|
);
|
||||||
|
|
||||||
|
const { mutateAsync: updateAlert } = useUpdatePkiAlertV2();
|
||||||
|
|
||||||
|
const watchedFilters = watch("filters");
|
||||||
|
const watchedChannels = watch("channels");
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (alert) {
|
||||||
|
reset({
|
||||||
|
name: alert.name,
|
||||||
|
description: alert.description || "",
|
||||||
|
eventType: alert.eventType,
|
||||||
|
alertBefore: alert.alertBefore || "",
|
||||||
|
filters: alert.filters,
|
||||||
|
enabled: alert.enabled,
|
||||||
|
channels: alert.channels.map(({ id, createdAt, updatedAt, ...channel }) => channel)
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}, [alert, reset]);
|
||||||
|
|
||||||
|
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<TPkiFilterRuleV2>) => {
|
||||||
|
const currentFilters = [...(watchedFilters || [])];
|
||||||
|
currentFilters[index] = { ...currentFilters[index], ...updatedFilter };
|
||||||
|
setValue("filters", currentFilters);
|
||||||
|
};
|
||||||
|
|
||||||
|
const addChannel = () => {
|
||||||
|
const currentChannels = watchedChannels || [];
|
||||||
|
setValue("channels", [
|
||||||
|
...currentChannels,
|
||||||
|
{
|
||||||
|
channelType: PkiAlertChannelTypeV2.EMAIL,
|
||||||
|
config: { recipients: [] },
|
||||||
|
enabled: true
|
||||||
|
}
|
||||||
|
]);
|
||||||
|
};
|
||||||
|
|
||||||
|
const removeChannel = (index: number) => {
|
||||||
|
const currentChannels = watchedChannels || [];
|
||||||
|
if (currentChannels.length > 1) {
|
||||||
|
setValue(
|
||||||
|
"channels",
|
||||||
|
currentChannels.filter((_, i) => i !== index)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const updateChannel = (
|
||||||
|
index: number,
|
||||||
|
updatedChannel: Partial<Omit<TPkiAlertChannelV2, "id" | "createdAt" | "updatedAt">>
|
||||||
|
) => {
|
||||||
|
const currentChannels = [...(watchedChannels || [])];
|
||||||
|
currentChannels[index] = { ...currentChannels[index], ...updatedChannel };
|
||||||
|
setValue("channels", currentChannels);
|
||||||
|
};
|
||||||
|
|
||||||
|
const onSubmit = async (data: TFormData) => {
|
||||||
|
if (!alertId) return;
|
||||||
|
|
||||||
|
setIsSubmitting(true);
|
||||||
|
try {
|
||||||
|
await updateAlert({
|
||||||
|
alertId,
|
||||||
|
...data
|
||||||
|
});
|
||||||
|
|
||||||
|
createNotification({
|
||||||
|
text: "PKI alert updated successfully",
|
||||||
|
type: "success"
|
||||||
|
});
|
||||||
|
|
||||||
|
onOpenChange(false);
|
||||||
|
} catch {
|
||||||
|
createNotification({
|
||||||
|
text: "Failed to update PKI alert",
|
||||||
|
type: "error"
|
||||||
|
});
|
||||||
|
} finally {
|
||||||
|
setIsSubmitting(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
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;
|
||||||
|
};
|
||||||
|
|
||||||
|
if (isLoading) {
|
||||||
|
return (
|
||||||
|
<Modal isOpen={isOpen} onOpenChange={onOpenChange}>
|
||||||
|
<ModalContent title="Update Certificate Alert" className="max-w-4xl">
|
||||||
|
<div className="space-y-6">
|
||||||
|
<Skeleton className="h-8 w-full" />
|
||||||
|
<Skeleton className="h-8 w-full" />
|
||||||
|
<Skeleton className="h-32 w-full" />
|
||||||
|
</div>
|
||||||
|
</ModalContent>
|
||||||
|
</Modal>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Modal isOpen={isOpen} onOpenChange={onOpenChange}>
|
||||||
|
<ModalContent title="Update Certificate Alert" className="max-w-4xl">
|
||||||
|
<form onSubmit={handleSubmit(onSubmit)} className="space-y-6">
|
||||||
|
<div className="grid grid-cols-1 gap-4 md:grid-cols-2">
|
||||||
|
<Controller
|
||||||
|
control={control}
|
||||||
|
name="name"
|
||||||
|
render={({ field, fieldState: { error } }) => (
|
||||||
|
<FormControl label="Alert Name" isError={Boolean(error)} errorText={error?.message}>
|
||||||
|
<Input {...field} placeholder="certificate-expiration-alert" />
|
||||||
|
</FormControl>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<Controller
|
||||||
|
control={control}
|
||||||
|
name="eventType"
|
||||||
|
render={({ field, fieldState: { error } }) => (
|
||||||
|
<FormControl label="Event Type" isError={Boolean(error)} errorText={error?.message}>
|
||||||
|
<Select value={field.value} onValueChange={(value) => field.onChange(value)}>
|
||||||
|
<SelectItem value={PkiAlertEventTypeV2.EXPIRATION}>
|
||||||
|
Certificate Expiration
|
||||||
|
</SelectItem>
|
||||||
|
<SelectItem value={PkiAlertEventTypeV2.RENEWAL}>Certificate Renewal</SelectItem>
|
||||||
|
<SelectItem value={PkiAlertEventTypeV2.ISSUANCE}>
|
||||||
|
Certificate Issuance
|
||||||
|
</SelectItem>
|
||||||
|
<SelectItem value={PkiAlertEventTypeV2.REVOCATION}>
|
||||||
|
Certificate Revocation
|
||||||
|
</SelectItem>
|
||||||
|
</Select>
|
||||||
|
</FormControl>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<Controller
|
||||||
|
control={control}
|
||||||
|
name="description"
|
||||||
|
render={({ field, fieldState: { error } }) => (
|
||||||
|
<FormControl
|
||||||
|
label="Description (Optional)"
|
||||||
|
isError={Boolean(error)}
|
||||||
|
errorText={error?.message}
|
||||||
|
>
|
||||||
|
<TextArea {...field} placeholder="Alert description..." rows={2} />
|
||||||
|
</FormControl>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<div className="grid grid-cols-1 gap-4 md:grid-cols-2">
|
||||||
|
<Controller
|
||||||
|
control={control}
|
||||||
|
name="alertBefore"
|
||||||
|
render={({ field, fieldState: { error } }) => (
|
||||||
|
<FormControl
|
||||||
|
label="Alert Before (for expiration)"
|
||||||
|
isError={Boolean(error)}
|
||||||
|
errorText={error?.message}
|
||||||
|
helperText="Format: number + unit (d=days, w=weeks, m=months, y=years). Example: 30d"
|
||||||
|
>
|
||||||
|
<Input {...field} placeholder="30d" />
|
||||||
|
</FormControl>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<Controller
|
||||||
|
control={control}
|
||||||
|
name="enabled"
|
||||||
|
render={({ field }) => (
|
||||||
|
<FormControl label="Status">
|
||||||
|
<div className="mt-2 flex items-center space-x-2">
|
||||||
|
<Switch
|
||||||
|
id="alert-enabled-update"
|
||||||
|
isChecked={field.value}
|
||||||
|
onCheckedChange={field.onChange}
|
||||||
|
/>
|
||||||
|
<span className="text-sm text-gray-300">
|
||||||
|
{field.value ? "Enabled" : "Disabled"}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</FormControl>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="space-y-4">
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<FormLabel label="Certificate Filter Rules" />
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
variant="outline_bg"
|
||||||
|
size="sm"
|
||||||
|
leftIcon={<FontAwesomeIcon icon={faPlus} />}
|
||||||
|
onClick={addFilter}
|
||||||
|
>
|
||||||
|
Add Filter
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{watchedFilters?.map((filter, index) => (
|
||||||
|
<div
|
||||||
|
key={`filter-${filter.field}-${filter.operator}-${String(filter.value)}`}
|
||||||
|
className="space-y-3 rounded-lg border border-gray-600 p-4"
|
||||||
|
>
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<h4 className="text-sm font-medium text-gray-200">Filter Rule #{index + 1}</h4>
|
||||||
|
<IconButton
|
||||||
|
size="sm"
|
||||||
|
variant="plain"
|
||||||
|
colorSchema="danger"
|
||||||
|
ariaLabel="Remove filter"
|
||||||
|
onClick={() => removeFilter(index)}
|
||||||
|
>
|
||||||
|
<FontAwesomeIcon icon={faTrash} />
|
||||||
|
</IconButton>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="grid grid-cols-1 gap-3 md:grid-cols-3">
|
||||||
|
<FormControl label="Field">
|
||||||
|
<Select
|
||||||
|
value={filter.field}
|
||||||
|
onValueChange={(value) =>
|
||||||
|
updateFilter(index, {
|
||||||
|
field: value as PkiFilterFieldV2,
|
||||||
|
operator:
|
||||||
|
value === PkiFilterFieldV2.INCLUDE_CAS
|
||||||
|
? PkiFilterOperatorV2.EQUALS
|
||||||
|
: filter.operator,
|
||||||
|
value: isValueBoolean(value as PkiFilterFieldV2) ? false : ""
|
||||||
|
})
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<SelectItem value={PkiFilterFieldV2.COMMON_NAME}>Common Name</SelectItem>
|
||||||
|
<SelectItem value={PkiFilterFieldV2.PROFILE_NAME}>Profile Name</SelectItem>
|
||||||
|
<SelectItem value={PkiFilterFieldV2.SAN}>
|
||||||
|
Subject Alternative Names
|
||||||
|
</SelectItem>
|
||||||
|
<SelectItem value={PkiFilterFieldV2.INCLUDE_CAS}>
|
||||||
|
Include Certificate Authorities
|
||||||
|
</SelectItem>
|
||||||
|
</Select>
|
||||||
|
</FormControl>
|
||||||
|
|
||||||
|
<FormControl label="Operator">
|
||||||
|
<Select
|
||||||
|
value={filter.operator}
|
||||||
|
onValueChange={(value) =>
|
||||||
|
updateFilter(index, { operator: value as PkiFilterOperatorV2 })
|
||||||
|
}
|
||||||
|
>
|
||||||
|
{getFieldOperators(filter.field).map((operator) => (
|
||||||
|
<SelectItem key={operator} value={operator}>
|
||||||
|
{operator.replace("_", " ").toUpperCase()}
|
||||||
|
</SelectItem>
|
||||||
|
))}
|
||||||
|
</Select>
|
||||||
|
</FormControl>
|
||||||
|
|
||||||
|
<FormControl label="Value">
|
||||||
|
{isValueBoolean(filter.field) ? (
|
||||||
|
<Select
|
||||||
|
value={String(filter.value)}
|
||||||
|
onValueChange={(value) => updateFilter(index, { value: value === "true" })}
|
||||||
|
>
|
||||||
|
<SelectItem value="true">Yes</SelectItem>
|
||||||
|
<SelectItem value="false">No</SelectItem>
|
||||||
|
</Select>
|
||||||
|
) : (
|
||||||
|
<Input
|
||||||
|
value={
|
||||||
|
Array.isArray(filter.value)
|
||||||
|
? filter.value.join(", ")
|
||||||
|
: String(filter.value)
|
||||||
|
}
|
||||||
|
onChange={(e) => {
|
||||||
|
const { value } = e.target;
|
||||||
|
const finalValue =
|
||||||
|
canOperatorTakeArray(filter.operator) && value.includes(",")
|
||||||
|
? value
|
||||||
|
.split(",")
|
||||||
|
.map((v) => v.trim())
|
||||||
|
.filter(Boolean)
|
||||||
|
: value;
|
||||||
|
updateFilter(index, { value: finalValue });
|
||||||
|
}}
|
||||||
|
placeholder={
|
||||||
|
canOperatorTakeArray(filter.operator)
|
||||||
|
? "example.com, test.com"
|
||||||
|
: "example.com"
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</FormControl>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="space-y-4">
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<FormLabel label="Notification Channels" />
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
variant="outline_bg"
|
||||||
|
size="sm"
|
||||||
|
leftIcon={<FontAwesomeIcon icon={faPlus} />}
|
||||||
|
onClick={addChannel}
|
||||||
|
>
|
||||||
|
Add Channel
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{watchedChannels?.map((channel, index) => (
|
||||||
|
<div
|
||||||
|
key={`channel-${channel.channelType}-${JSON.stringify(channel.config)}`}
|
||||||
|
className="space-y-3 rounded-lg border border-gray-600 p-4"
|
||||||
|
>
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<h4 className="text-sm font-medium text-gray-200">Channel #{index + 1}</h4>
|
||||||
|
{watchedChannels.length > 1 && (
|
||||||
|
<IconButton
|
||||||
|
size="sm"
|
||||||
|
variant="plain"
|
||||||
|
colorSchema="danger"
|
||||||
|
ariaLabel="Remove channel"
|
||||||
|
onClick={() => removeChannel(index)}
|
||||||
|
>
|
||||||
|
<FontAwesomeIcon icon={faTrash} />
|
||||||
|
</IconButton>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="grid grid-cols-1 gap-3 md:grid-cols-2">
|
||||||
|
<FormControl label="Channel Type">
|
||||||
|
<Select
|
||||||
|
value={channel.channelType}
|
||||||
|
onValueChange={(value) =>
|
||||||
|
updateChannel(index, {
|
||||||
|
channelType: value as PkiAlertChannelTypeV2,
|
||||||
|
config: { recipients: [] }
|
||||||
|
})
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<SelectItem value={PkiAlertChannelTypeV2.EMAIL}>Email</SelectItem>
|
||||||
|
</Select>
|
||||||
|
</FormControl>
|
||||||
|
|
||||||
|
<FormControl label="Status">
|
||||||
|
<div className="mt-2 flex items-center space-x-2">
|
||||||
|
<Switch
|
||||||
|
id={`channel-enabled-update-${index}`}
|
||||||
|
isChecked={channel.enabled}
|
||||||
|
onCheckedChange={(enabled) => updateChannel(index, { enabled })}
|
||||||
|
/>
|
||||||
|
<span className="text-sm text-gray-300">
|
||||||
|
{channel.enabled ? "Enabled" : "Disabled"}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</FormControl>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<FormControl label="Email Recipients">
|
||||||
|
<Input
|
||||||
|
value={(channel.config as any)?.recipients?.join(", ") || ""}
|
||||||
|
onChange={(e) => {
|
||||||
|
const recipients = e.target.value
|
||||||
|
.split(",")
|
||||||
|
.map((email) => email.trim())
|
||||||
|
.filter(Boolean);
|
||||||
|
updateChannel(index, { config: { recipients } });
|
||||||
|
}}
|
||||||
|
placeholder="admin@example.com, security@example.com"
|
||||||
|
/>
|
||||||
|
</FormControl>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex justify-end space-x-3 pt-4">
|
||||||
|
<Button type="button" variant="plain" onClick={() => onOpenChange(false)}>
|
||||||
|
Cancel
|
||||||
|
</Button>
|
||||||
|
<Button type="submit" isLoading={isSubmitting} isDisabled={isSubmitting}>
|
||||||
|
Update Alert
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</ModalContent>
|
||||||
|
</Modal>
|
||||||
|
);
|
||||||
|
};
|
||||||
@@ -0,0 +1,359 @@
|
|||||||
|
import { useEffect, useState } from "react";
|
||||||
|
|
||||||
|
import { CertificateDisplayName } from "@app/components/utilities/certificateDisplayUtils";
|
||||||
|
import {
|
||||||
|
Modal,
|
||||||
|
ModalContent,
|
||||||
|
Pagination,
|
||||||
|
Skeleton,
|
||||||
|
Tab,
|
||||||
|
Table,
|
||||||
|
TableContainer,
|
||||||
|
TabList,
|
||||||
|
TabPanel,
|
||||||
|
Tabs,
|
||||||
|
TBody,
|
||||||
|
Td,
|
||||||
|
Th,
|
||||||
|
THead,
|
||||||
|
Tr
|
||||||
|
} from "@app/components/v2";
|
||||||
|
import { Badge } from "@app/components/v3";
|
||||||
|
import {
|
||||||
|
PkiAlertEventTypeV2,
|
||||||
|
useGetPkiAlertV2ById,
|
||||||
|
useGetPkiAlertV2MatchingCertificates
|
||||||
|
} from "@app/hooks/api/pkiAlertsV2";
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
isOpen: boolean;
|
||||||
|
onOpenChange: (isOpen: boolean) => void;
|
||||||
|
alertId?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const ViewPkiAlertV2Modal = ({ isOpen, onOpenChange, alertId }: Props) => {
|
||||||
|
const [activeTab, setActiveTab] = useState("overview");
|
||||||
|
const [certificatesPage, setCertificatesPage] = useState(1);
|
||||||
|
const certificatesPerPage = 10;
|
||||||
|
|
||||||
|
const { data: alert, isLoading: isLoadingAlert } = useGetPkiAlertV2ById(
|
||||||
|
{ alertId: alertId || "" },
|
||||||
|
{ enabled: !!alertId && isOpen }
|
||||||
|
);
|
||||||
|
|
||||||
|
const { data: certificatesData, isLoading: isLoadingCertificates } =
|
||||||
|
useGetPkiAlertV2MatchingCertificates(
|
||||||
|
{
|
||||||
|
alertId: alertId || "",
|
||||||
|
limit: certificatesPerPage,
|
||||||
|
offset: (certificatesPage - 1) * certificatesPerPage
|
||||||
|
},
|
||||||
|
{ enabled: !!alertId && isOpen }
|
||||||
|
);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (isOpen && alertId) {
|
||||||
|
setActiveTab("overview");
|
||||||
|
setCertificatesPage(1);
|
||||||
|
}
|
||||||
|
}, [isOpen, alertId]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!isOpen) {
|
||||||
|
setActiveTab("overview");
|
||||||
|
setCertificatesPage(1);
|
||||||
|
}
|
||||||
|
}, [isOpen]);
|
||||||
|
|
||||||
|
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 "Not set";
|
||||||
|
|
||||||
|
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}`;
|
||||||
|
};
|
||||||
|
|
||||||
|
const formatDate = (dateString: string) => {
|
||||||
|
return new Date(dateString).toLocaleDateString("en-US", {
|
||||||
|
year: "numeric",
|
||||||
|
month: "short",
|
||||||
|
day: "numeric",
|
||||||
|
hour: "2-digit",
|
||||||
|
minute: "2-digit"
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
if (isLoadingAlert) {
|
||||||
|
return (
|
||||||
|
<Modal isOpen={isOpen} onOpenChange={onOpenChange}>
|
||||||
|
<ModalContent title="Certificate Alert Details" className="max-w-6xl">
|
||||||
|
<div className="space-y-6">
|
||||||
|
<Skeleton className="h-8 w-full" />
|
||||||
|
<Skeleton className="h-32 w-full" />
|
||||||
|
<Skeleton className="h-64 w-full" />
|
||||||
|
</div>
|
||||||
|
</ModalContent>
|
||||||
|
</Modal>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!alert) {
|
||||||
|
return (
|
||||||
|
<Modal isOpen={isOpen} onOpenChange={onOpenChange}>
|
||||||
|
<ModalContent title="Certificate Alert Details" className="max-w-6xl">
|
||||||
|
<div className="py-8 text-center text-gray-400">Alert not found</div>
|
||||||
|
</ModalContent>
|
||||||
|
</Modal>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Modal isOpen={isOpen} onOpenChange={onOpenChange}>
|
||||||
|
<ModalContent title="Certificate Alert Details" className="max-w-6xl">
|
||||||
|
<div className="space-y-6">
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<h2 className="text-xl font-semibold text-gray-200">{alert.name}</h2>
|
||||||
|
<Badge variant={alert.enabled ? "success" : "neutral"}>
|
||||||
|
{alert.enabled ? "Enabled" : "Disabled"}
|
||||||
|
</Badge>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{alert.description && <p className="text-gray-300">{alert.description}</p>}
|
||||||
|
|
||||||
|
<Tabs value={activeTab} onValueChange={setActiveTab}>
|
||||||
|
<TabList>
|
||||||
|
<Tab value="overview">Overview</Tab>
|
||||||
|
<Tab value="certificates">Matching Certificates</Tab>
|
||||||
|
</TabList>
|
||||||
|
|
||||||
|
<TabPanel value="overview">
|
||||||
|
<div className="space-y-6">
|
||||||
|
<div className="space-y-4">
|
||||||
|
<h3 className="text-lg font-medium text-gray-200">Basic Information</h3>
|
||||||
|
<div className="grid grid-cols-1 gap-4 md:grid-cols-2 lg:grid-cols-3">
|
||||||
|
<div>
|
||||||
|
<div className="mb-1 block text-sm font-medium text-gray-400">Event Type</div>
|
||||||
|
<span className="text-gray-300">{formatEventType(alert.eventType)}</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<div className="mb-1 block text-sm font-medium text-gray-400">
|
||||||
|
Alert Before
|
||||||
|
</div>
|
||||||
|
<span className="text-gray-300">{formatAlertBefore(alert.alertBefore)}</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<div className="mb-1 block text-sm font-medium text-gray-400">Created</div>
|
||||||
|
<span className="text-gray-300">{formatDate(alert.createdAt)}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="space-y-3">
|
||||||
|
<h3 className="text-lg font-medium text-gray-200">Filter Rules</h3>
|
||||||
|
{(alert.filters || []).length === 0 ? (
|
||||||
|
<p className="text-gray-400">No filter rules configured</p>
|
||||||
|
) : (
|
||||||
|
<div className="space-y-2">
|
||||||
|
{(alert.filters || []).map((filter) => {
|
||||||
|
const formatFilterText = () => {
|
||||||
|
const field = filter.field.replace(/_/g, " ");
|
||||||
|
const operator = filter.operator.replace(/_/g, " ");
|
||||||
|
const value = Array.isArray(filter.value)
|
||||||
|
? filter.value.join(", ")
|
||||||
|
: String(filter.value);
|
||||||
|
|
||||||
|
return `${field} ${operator} ${value}`;
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
key={`filter-${filter.field}-${filter.operator}-${String(filter.value)}`}
|
||||||
|
>
|
||||||
|
<div className="text-sm text-gray-200 capitalize">
|
||||||
|
{formatFilterText()}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="space-y-3">
|
||||||
|
<h3 className="text-lg font-medium text-gray-200">Notification Recipients</h3>
|
||||||
|
{(alert.channels || []).some(
|
||||||
|
(channel) =>
|
||||||
|
channel.channelType === "email" && (channel.config as any)?.recipients
|
||||||
|
) ? (
|
||||||
|
<div className="space-y-3">
|
||||||
|
<div>
|
||||||
|
<div className="flex flex-wrap gap-1">
|
||||||
|
{(alert.channels || [])
|
||||||
|
.filter(
|
||||||
|
(channel) =>
|
||||||
|
channel.channelType === "email" &&
|
||||||
|
(channel.config as any)?.recipients
|
||||||
|
)
|
||||||
|
.flatMap((channel) => (channel.config as any).recipients)
|
||||||
|
.map((email: string) => (
|
||||||
|
<Badge key={`email-${email}`} variant="neutral" className="text-sm">
|
||||||
|
{email}
|
||||||
|
</Badge>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<p className="text-gray-400">No notification channels configured</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</TabPanel>
|
||||||
|
|
||||||
|
<TabPanel value="certificates">
|
||||||
|
<div className="space-y-4">
|
||||||
|
<TableContainer>
|
||||||
|
<Table>
|
||||||
|
<THead>
|
||||||
|
<Tr>
|
||||||
|
<Th className="w-1/2">SAN / CN</Th>
|
||||||
|
<Th className="w-1/6">Status</Th>
|
||||||
|
<Th className="w-1/6">Not Before</Th>
|
||||||
|
<Th className="w-1/6">Not After</Th>
|
||||||
|
</Tr>
|
||||||
|
</THead>
|
||||||
|
<TBody>
|
||||||
|
{(() => {
|
||||||
|
if (isLoadingCertificates) {
|
||||||
|
return Array.from({ length: 5 }, (_, index) => (
|
||||||
|
<Tr key={`skeleton-row-${index}`}>
|
||||||
|
<Td>
|
||||||
|
<Skeleton className="h-4 w-32" />
|
||||||
|
</Td>
|
||||||
|
<Td>
|
||||||
|
<Skeleton className="h-4 w-16" />
|
||||||
|
</Td>
|
||||||
|
<Td>
|
||||||
|
<Skeleton className="h-4 w-24" />
|
||||||
|
</Td>
|
||||||
|
<Td>
|
||||||
|
<Skeleton className="h-4 w-24" />
|
||||||
|
</Td>
|
||||||
|
</Tr>
|
||||||
|
));
|
||||||
|
}
|
||||||
|
|
||||||
|
if (certificatesData?.certificates?.length) {
|
||||||
|
return certificatesData.certificates.map((cert) => (
|
||||||
|
<Tr key={cert.id} className="group h-10">
|
||||||
|
<Td className="max-w-0">
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<CertificateDisplayName
|
||||||
|
cert={{
|
||||||
|
altNames: cert.san?.join(", ") || null,
|
||||||
|
commonName: cert.commonName
|
||||||
|
}}
|
||||||
|
maxLength={48}
|
||||||
|
fallback="—"
|
||||||
|
/>
|
||||||
|
{cert.enrollmentType === "ca" && (
|
||||||
|
<Badge variant="info" className="shrink-0 text-xs">
|
||||||
|
CA
|
||||||
|
</Badge>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</Td>
|
||||||
|
<Td>
|
||||||
|
<Badge
|
||||||
|
variant={(() => {
|
||||||
|
const now = new Date();
|
||||||
|
const expirationDate = new Date(cert.notAfter);
|
||||||
|
const isExpired = expirationDate < now;
|
||||||
|
|
||||||
|
if (isExpired) return "danger";
|
||||||
|
if (cert.status === "active") return "success";
|
||||||
|
return "neutral";
|
||||||
|
})()}
|
||||||
|
className="capitalize"
|
||||||
|
>
|
||||||
|
{(() => {
|
||||||
|
const now = new Date();
|
||||||
|
const expirationDate = new Date(cert.notAfter);
|
||||||
|
const isExpired = expirationDate < now;
|
||||||
|
|
||||||
|
if (isExpired) return "Expired";
|
||||||
|
return cert.status;
|
||||||
|
})()}
|
||||||
|
</Badge>
|
||||||
|
</Td>
|
||||||
|
<Td>
|
||||||
|
{cert.notBefore
|
||||||
|
? new Date(cert.notBefore).toLocaleDateString("en-CA")
|
||||||
|
: "-"}
|
||||||
|
</Td>
|
||||||
|
<Td>
|
||||||
|
{cert.notAfter
|
||||||
|
? new Date(cert.notAfter).toLocaleDateString("en-CA")
|
||||||
|
: "-"}
|
||||||
|
</Td>
|
||||||
|
</Tr>
|
||||||
|
));
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Tr>
|
||||||
|
<Td colSpan={4} className="py-8 text-center text-gray-400">
|
||||||
|
No certificates will match this alert's criteria in the future
|
||||||
|
</Td>
|
||||||
|
</Tr>
|
||||||
|
);
|
||||||
|
})()}
|
||||||
|
</TBody>
|
||||||
|
</Table>
|
||||||
|
</TableContainer>
|
||||||
|
|
||||||
|
{(certificatesData?.total || 0) > 0 && (
|
||||||
|
<div className="flex justify-center">
|
||||||
|
<Pagination
|
||||||
|
count={certificatesData?.total || 0}
|
||||||
|
page={certificatesPage}
|
||||||
|
onChangePage={setCertificatesPage}
|
||||||
|
perPage={certificatesPerPage}
|
||||||
|
onChangePerPage={() => {}}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</TabPanel>
|
||||||
|
</Tabs>
|
||||||
|
</div>
|
||||||
|
</ModalContent>
|
||||||
|
</Modal>
|
||||||
|
);
|
||||||
|
};
|
||||||
4
frontend/src/views/PkiAlertsV2Page/components/index.ts
Normal file
4
frontend/src/views/PkiAlertsV2Page/components/index.ts
Normal file
@@ -0,0 +1,4 @@
|
|||||||
|
export { CreatePkiAlertV2Modal } from "./CreatePkiAlertV2Modal";
|
||||||
|
export { PkiAlertV2Row } from "./PkiAlertV2Row";
|
||||||
|
export { UpdatePkiAlertV2Modal } from "./UpdatePkiAlertV2Modal";
|
||||||
|
export { ViewPkiAlertV2Modal } from "./ViewPkiAlertV2Modal";
|
||||||
1
frontend/src/views/PkiAlertsV2Page/index.ts
Normal file
1
frontend/src/views/PkiAlertsV2Page/index.ts
Normal file
@@ -0,0 +1 @@
|
|||||||
|
export { PkiAlertsV2Page } from "./PkiAlertsV2Page";
|
||||||
Reference in New Issue
Block a user