diff --git a/backend/src/db/migrations/20251103120000_add-pki-alerts-v2.ts b/backend/src/db/migrations/20251103120000_add-pki-alerts-v2.ts index 7702c6ee1..d542c2278 100644 --- a/backend/src/db/migrations/20251103120000_add-pki-alerts-v2.ts +++ b/backend/src/db/migrations/20251103120000_add-pki-alerts-v2.ts @@ -6,7 +6,7 @@ export async function up(knex: Knex): Promise { if (!(await knex.schema.hasTable(TableName.PkiAlertsV2))) { await knex.schema.createTable(TableName.PkiAlertsV2, (t) => { t.uuid("id", { primaryKey: true }).defaultTo(knex.fn.uuid()); - t.string("slug").notNullable(); + t.string("name").notNullable(); t.text("description").nullable(); t.string("eventType").notNullable(); t.string("alertBefore").nullable(); @@ -17,9 +17,7 @@ export async function up(knex: Knex): Promise { t.foreign("projectId").references("id").inTable(TableName.Project).onDelete("CASCADE"); t.index("projectId"); - t.index("eventType"); - t.index("enabled"); - t.unique(["slug", "projectId"]); + t.unique(["name", "projectId"]); }); } @@ -44,7 +42,7 @@ export async function up(knex: Knex): Promise { t.uuid("id", { primaryKey: true }).defaultTo(knex.fn.uuid()); t.uuid("alertId").notNullable(); t.timestamp("triggeredAt").defaultTo(knex.fn.now()); - t.boolean("notificationSent").defaultTo(false); + t.boolean("hasNotificationSent").defaultTo(false); t.text("notificationError").nullable(); t.timestamps(true, true, true); diff --git a/backend/src/db/schemas/pki-alert-history.ts b/backend/src/db/schemas/pki-alert-history.ts index c1e439aef..20af7b757 100644 --- a/backend/src/db/schemas/pki-alert-history.ts +++ b/backend/src/db/schemas/pki-alert-history.ts @@ -11,7 +11,7 @@ export const PkiAlertHistorySchema = z.object({ id: z.string().uuid(), alertId: z.string().uuid(), triggeredAt: z.date().nullable().optional(), - notificationSent: z.boolean().default(false).nullable().optional(), + hasNotificationSent: z.boolean().default(false).nullable().optional(), notificationError: z.string().nullable().optional(), createdAt: z.date(), updatedAt: z.date() diff --git a/backend/src/db/schemas/pki-alerts-v2.ts b/backend/src/db/schemas/pki-alerts-v2.ts index 74829a440..cbb28220c 100644 --- a/backend/src/db/schemas/pki-alerts-v2.ts +++ b/backend/src/db/schemas/pki-alerts-v2.ts @@ -9,7 +9,7 @@ import { TImmutableDBKeys } from "./models"; export const PkiAlertsV2Schema = z.object({ id: z.string().uuid(), - slug: z.string(), + name: z.string(), description: z.string().nullable().optional(), eventType: z.string(), alertBefore: z.string().nullable().optional(), diff --git a/backend/src/server/routes/v2/pki-alert-router.ts b/backend/src/server/routes/v2/pki-alert-router.ts index d1352cda4..459b5cc6c 100644 --- a/backend/src/server/routes/v2/pki-alert-router.ts +++ b/backend/src/server/routes/v2/pki-alert-router.ts @@ -1,12 +1,14 @@ 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 @@ -22,32 +24,34 @@ export const registerPkiAlertRouter = async (server: FastifyZodProvider) => { onRequest: verifyAuth([AuthMode.JWT, AuthMode.IDENTITY_ACCESS_TOKEN]), schema: { description: "Create a new PKI alert", - tags: ["PKI Alerts"], - body: z.object({ - projectId: z.string().uuid().describe("Project ID"), - ...CreatePkiAlertV2Schema.shape + tags: [ApiDocsTags.PkiAlerting], + body: CreatePkiAlertV2Schema.extend({ + projectId: z.string().uuid().describe("Project ID") }), response: { 200: z.object({ - id: z.string().uuid(), - slug: z.string(), - description: z.string().nullable(), - eventType: z.nativeEnum(PkiAlertEventType), - alertBefore: z.string(), - filters: z.array(z.any()), - enabled: z.boolean(), - channels: z.array( - z.object({ - id: z.string().uuid(), - channelType: z.string(), - config: z.record(z.any()), - enabled: z.boolean(), - createdAt: z.date(), - updatedAt: z.date() - }) - ), - createdAt: z.date(), - updatedAt: z.date() + 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() + }) }) } }, @@ -67,14 +71,14 @@ export const registerPkiAlertRouter = async (server: FastifyZodProvider) => { type: EventType.CREATE_PKI_ALERT, metadata: { pkiAlertId: alert.id, - name: alert.slug, + name: alert.name, eventType: alert.eventType, alertBefore: alert.alertBefore } } }); - return alert; + return { alert }; } }); @@ -87,7 +91,7 @@ export const registerPkiAlertRouter = async (server: FastifyZodProvider) => { onRequest: verifyAuth([AuthMode.JWT, AuthMode.IDENTITY_ACCESS_TOKEN]), schema: { description: "List PKI alerts for a project", - tags: ["PKI Alerts"], + tags: [ApiDocsTags.PkiAlerting], querystring: z.object({ projectId: z.string().uuid(), search: z.string().optional(), @@ -101,16 +105,16 @@ export const registerPkiAlertRouter = async (server: FastifyZodProvider) => { alerts: z.array( z.object({ id: z.string().uuid(), - slug: z.string(), + name: z.string(), description: z.string().nullable(), eventType: z.nativeEnum(PkiAlertEventType), alertBefore: z.string(), - filters: z.array(z.any()), + filters: z.array(PkiFilterRuleSchema), enabled: z.boolean(), channels: z.array( z.object({ id: z.string().uuid(), - channelType: z.string(), + channelType: z.nativeEnum(PkiAlertChannelType), config: z.record(z.any()), enabled: z.boolean(), createdAt: z.date(), @@ -147,31 +151,34 @@ export const registerPkiAlertRouter = async (server: FastifyZodProvider) => { onRequest: verifyAuth([AuthMode.JWT, AuthMode.IDENTITY_ACCESS_TOKEN]), schema: { description: "Get a PKI alert by ID", - tags: ["PKI Alerts"], + tags: [ApiDocsTags.PkiAlerting], params: z.object({ alertId: z.string().uuid().describe("Alert ID") }), response: { 200: z.object({ - id: z.string().uuid(), - slug: z.string(), - description: z.string().nullable(), - eventType: z.nativeEnum(PkiAlertEventType), - alertBefore: z.string(), - filters: z.array(z.any()), - enabled: z.boolean(), - channels: z.array( - z.object({ - id: z.string().uuid(), - channelType: z.string(), - config: z.record(z.any()), - enabled: z.boolean(), - createdAt: z.date(), - updatedAt: z.date() - }) - ), - createdAt: z.date(), - updatedAt: z.date() + 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() + }) }) } }, @@ -195,7 +202,7 @@ export const registerPkiAlertRouter = async (server: FastifyZodProvider) => { } }); - return alert; + return { alert }; } }); @@ -208,32 +215,35 @@ export const registerPkiAlertRouter = async (server: FastifyZodProvider) => { onRequest: verifyAuth([AuthMode.JWT, AuthMode.IDENTITY_ACCESS_TOKEN]), schema: { description: "Update a PKI alert", - tags: ["PKI Alerts"], + tags: [ApiDocsTags.PkiAlerting], params: z.object({ alertId: z.string().uuid().describe("Alert ID") }), body: UpdatePkiAlertV2Schema, response: { 200: z.object({ - id: z.string().uuid(), - slug: z.string(), - description: z.string().nullable(), - eventType: z.nativeEnum(PkiAlertEventType), - alertBefore: z.string(), - filters: z.array(z.any()), - enabled: z.boolean(), - channels: z.array( - z.object({ - id: z.string().uuid(), - channelType: z.string(), - config: z.record(z.any()), - enabled: z.boolean(), - createdAt: z.date(), - updatedAt: z.date() - }) - ), - createdAt: z.date(), - updatedAt: z.date() + 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() + }) }) } }, @@ -254,14 +264,14 @@ export const registerPkiAlertRouter = async (server: FastifyZodProvider) => { type: EventType.UPDATE_PKI_ALERT, metadata: { pkiAlertId: alert.id, - name: alert.slug, + name: alert.name, eventType: alert.eventType, alertBefore: alert.alertBefore } } }); - return alert; + return { alert }; } }); @@ -274,31 +284,34 @@ export const registerPkiAlertRouter = async (server: FastifyZodProvider) => { onRequest: verifyAuth([AuthMode.JWT, AuthMode.IDENTITY_ACCESS_TOKEN]), schema: { description: "Delete a PKI alert", - tags: ["PKI Alerts"], + tags: [ApiDocsTags.PkiAlerting], params: z.object({ alertId: z.string().uuid().describe("Alert ID") }), response: { 200: z.object({ - id: z.string().uuid(), - slug: z.string(), - description: z.string().nullable(), - eventType: z.nativeEnum(PkiAlertEventType), - alertBefore: z.string(), - filters: z.array(z.any()), - enabled: z.boolean(), - channels: z.array( - z.object({ - id: z.string().uuid(), - channelType: z.string(), - config: z.record(z.any()), - enabled: z.boolean(), - createdAt: z.date(), - updatedAt: z.date() - }) - ), - createdAt: z.date(), - updatedAt: z.date() + 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() + }) }) } }, @@ -322,7 +335,7 @@ export const registerPkiAlertRouter = async (server: FastifyZodProvider) => { } }); - return alert; + return { alert }; } }); @@ -335,7 +348,7 @@ export const registerPkiAlertRouter = async (server: FastifyZodProvider) => { onRequest: verifyAuth([AuthMode.JWT, AuthMode.IDENTITY_ACCESS_TOKEN]), schema: { description: "List certificates that match an alert's filter rules", - tags: ["PKI Alerts"], + tags: [ApiDocsTags.PkiAlerting], params: z.object({ alertId: z.string().uuid().describe("Alert ID") }), @@ -358,9 +371,7 @@ export const registerPkiAlertRouter = async (server: FastifyZodProvider) => { status: z.string() }) ), - total: z.number(), - limit: z.number(), - offset: z.number() + total: z.number() }) } }, @@ -382,12 +393,12 @@ export const registerPkiAlertRouter = async (server: FastifyZodProvider) => { method: "POST", url: "/preview/certificates", config: { - rateLimit: readLimit + rateLimit: writeLimit }, onRequest: verifyAuth([AuthMode.JWT, AuthMode.IDENTITY_ACCESS_TOKEN]), schema: { description: "Preview certificates that would match the given filter rules", - tags: ["PKI Alerts"], + tags: [ApiDocsTags.PkiAlerting], body: z.object({ projectId: z.string().uuid().describe("Project ID"), filters: z.array(PkiFilterRuleSchema), @@ -413,9 +424,7 @@ export const registerPkiAlertRouter = async (server: FastifyZodProvider) => { status: z.string() }) ), - total: z.number(), - limit: z.number(), - offset: z.number() + total: z.number() }) } }, diff --git a/backend/src/services/pki-alert-v2/pki-alert-history-dal.ts b/backend/src/services/pki-alert-v2/pki-alert-history-dal.ts index 97cf0d8ea..ceb2820c4 100644 --- a/backend/src/services/pki-alert-v2/pki-alert-history-dal.ts +++ b/backend/src/services/pki-alert-v2/pki-alert-history-dal.ts @@ -12,7 +12,7 @@ export const pkiAlertHistoryDALFactory = (db: TDbClient) => { alertId: string, certificateIds: string[], options?: { - notificationSent?: boolean; + hasNotificationSent?: boolean; notificationError?: string; } ): Promise => { @@ -21,7 +21,7 @@ export const pkiAlertHistoryDALFactory = (db: TDbClient) => { const historyRecords = await tx(TableName.PkiAlertHistory) .insert({ alertId, - notificationSent: options?.notificationSent || false, + hasNotificationSent: options?.hasNotificationSent || false, notificationError: options?.notificationError }) .returning("*"); @@ -91,7 +91,7 @@ export const pkiAlertHistoryDALFactory = (db: TDbClient) => { .from(`${TableName.PkiAlertHistory} as hist`) .join(`${TableName.PkiAlertHistoryCertificate} as cert`, "hist.id", "cert.alertHistoryId") .where("hist.alertId", alertId) - .where("hist.notificationSent", true) + .where("hist.hasNotificationSent", true) .where("hist.triggeredAt", ">=", cutoffDate) .whereIn("cert.certificateId", certificateIds)) as Array<{ certificateId: string }>; diff --git a/backend/src/services/pki-alert-v2/pki-alert-v2-dal.ts b/backend/src/services/pki-alert-v2/pki-alert-v2-dal.ts index 12369cd97..d10c1689b 100644 --- a/backend/src/services/pki-alert-v2/pki-alert-v2-dal.ts +++ b/backend/src/services/pki-alert-v2/pki-alert-v2-dal.ts @@ -102,7 +102,7 @@ export const pkiAlertV2DALFactory = (db: TDbClient) => { } }; - const findByProjectId = async ( + const findByProjectIdWithCount = async ( projectId: string, filters?: { search?: string; @@ -112,15 +112,32 @@ export const pkiAlertV2DALFactory = (db: TDbClient) => { offset?: number; }, tx?: Knex - ): Promise => { + ): 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}.slug`, `%${sanitizeLikeInput(filters.search)}%`); + alertQuery = alertQuery.whereILike(`${TableName.PkiAlertsV2}.name`, `%${sanitizeLikeInput(filters.search)}%`); } if (filters?.eventType) { @@ -141,13 +158,11 @@ export const pkiAlertV2DALFactory = (db: TDbClient) => { alertQuery = alertQuery.offset(filters.offset); } - const alerts = (await alertQuery) as TPkiAlertsV2[]; + const [countResult, alerts] = await Promise.all([countQuery, alertQuery]); - if (alerts.length === 0) { - return []; - } + const total = parseInt((countResult[0] as { count: string }).count, 10); - const alertIds = alerts.map((alert) => alert.id); + const alertIds = (alerts as TPkiAlertsV2[]).map((alert) => alert.id); const channels = (await (tx || db.replicaNode()) .select(selectAllTableCols(TableName.PkiAlertChannels)) .from(TableName.PkiAlertChannels) @@ -164,17 +179,32 @@ export const pkiAlertV2DALFactory = (db: TDbClient) => { {} as Record ); - const result: TAlertWithChannels[] = alerts.map((alert) => ({ + const alertsWithChannels: TAlertWithChannels[] = (alerts as TPkiAlertsV2[]).map((alert) => ({ ...alert, channels: channelsByAlertId[alert.id] || [] })); - return result; + return { alerts: alertsWithChannels, total }; } catch (error) { - throw new DatabaseError({ error, name: "FindByProjectId" }); + throw new DatabaseError({ error, name: "FindByProjectIdWithCount" }); } }; + const findByProjectId = async ( + projectId: string, + filters?: { + search?: string; + eventType?: string; + enabled?: boolean; + limit?: number; + offset?: number; + }, + tx?: Knex + ): Promise => { + const result = await findByProjectIdWithCount(projectId, filters, tx); + return result.alerts; + }; + const countByProjectId = async ( projectId: string, filters?: { @@ -191,7 +221,7 @@ export const pkiAlertV2DALFactory = (db: TDbClient) => { .where(`${TableName.PkiAlertsV2}.projectId`, projectId); if (filters?.search) { - query = query.whereILike(`${TableName.PkiAlertsV2}.slug`, `%${sanitizeLikeInput(filters.search)}%`); + query = query.whereILike(`${TableName.PkiAlertsV2}.name`, `%${sanitizeLikeInput(filters.search)}%`); } if (filters?.eventType) { @@ -257,7 +287,7 @@ export const pkiAlertV2DALFactory = (db: TDbClient) => { let caCountQuery = (tx || db.replicaNode()) .count("* as count") .from(TableName.CertificateAuthority) - .leftJoin( + .innerJoin( `${TableName.InternalCertificateAuthority} as ica`, `${TableName.CertificateAuthority}.id`, `ica.caId` @@ -268,16 +298,16 @@ export const pkiAlertV2DALFactory = (db: TDbClient) => { if (options?.alertBefore) { if (options.showFutureMatches) { caCountQuery = caCountQuery - .whereRaw(`ica."notAfter" > NOW() + INTERVAL '${options.alertBefore}'`) + .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}'`); + .whereRaw(`ica."notAfter" <= NOW() + ?::interval`, [options.alertBefore]); } else { caCountQuery = caCountQuery .whereRaw(`ica."notAfter" > NOW()`) - .whereRaw(`ica."notAfter" <= NOW() + INTERVAL '${options.alertBefore}'`); + .whereRaw(`ica."notAfter" <= NOW() + ?::interval`, [options.alertBefore]); } } @@ -290,23 +320,23 @@ export const pkiAlertV2DALFactory = (db: TDbClient) => { if (options?.showPreview) { certCountQuery = certCountQuery - .whereRaw(`${TableName.Certificate}."notAfter" > NOW()`) + .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()`) + .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}'`) + .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}'`) + .whereRaw(`"${TableName.Certificate}"."notAfter" > NOW()`) + .whereRaw(`"${TableName.Certificate}"."notAfter" <= NOW() + ?::interval`, [options.alertBefore]) .whereNot(`${TableName.Certificate}.status`, "revoked"); } } @@ -322,7 +352,7 @@ export const pkiAlertV2DALFactory = (db: TDbClient) => { `${TableName.PkiAlertHistoryCertificate}.alertHistoryId` ) .where(`${TableName.PkiAlertHistory}.alertId`, options.alertId) - .whereRaw(`"${TableName.PkiAlertHistoryCertificate}"."certificateId" = "${TableName.Certificate}"."id"`) + .whereRaw(`"${TableName.PkiAlertHistoryCertificate}"."certificateId" = "${TableName.Certificate}".id`) ); } @@ -346,7 +376,7 @@ export const pkiAlertV2DALFactory = (db: TDbClient) => { ]; if (needsProfileJoin) { - selectColumns.push("profile.slug as profileName"); + selectColumns.push("profile.name as profileName"); } let certificateQuery = (tx || db.replicaNode()).select(selectColumns).from(TableName.Certificate); @@ -355,23 +385,23 @@ export const pkiAlertV2DALFactory = (db: TDbClient) => { if (options?.showPreview) { certificateQuery = certificateQuery - .whereRaw(`${TableName.Certificate}."notAfter" > NOW()`) + .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()`) + .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}'`) + .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}'`) + .whereRaw(`"${TableName.Certificate}"."notAfter" > NOW()`) + .whereRaw(`"${TableName.Certificate}"."notAfter" <= NOW() + ?::interval`, [options.alertBefore]) .whereNot(`${TableName.Certificate}.status`, "revoked"); } } @@ -387,7 +417,7 @@ export const pkiAlertV2DALFactory = (db: TDbClient) => { `${TableName.PkiAlertHistoryCertificate}.alertHistoryId` ) .where(`${TableName.PkiAlertHistory}.alertId`, options.alertId) - .whereRaw(`"${TableName.PkiAlertHistoryCertificate}"."certificateId" = "${TableName.Certificate}"."id"`) + .whereRaw(`"${TableName.PkiAlertHistoryCertificate}"."certificateId" = "${TableName.Certificate}".id`) ); } @@ -447,7 +477,7 @@ export const pkiAlertV2DALFactory = (db: TDbClient) => { `ica.notAfter` ) .from(TableName.CertificateAuthority) - .leftJoin( + .innerJoin( `${TableName.InternalCertificateAuthority} as ica`, `${TableName.CertificateAuthority}.id`, `ica.caId` @@ -458,12 +488,12 @@ export const pkiAlertV2DALFactory = (db: TDbClient) => { if (options?.alertBefore) { if (options.showFutureMatches) { caQuery = caQuery - .whereRaw(`ica."notAfter" > NOW() + INTERVAL '${options.alertBefore}'`) + .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}'`); + .whereRaw(`ica."notAfter" <= NOW() + ?::interval`, [options.alertBefore]); } } @@ -518,6 +548,7 @@ export const pkiAlertV2DALFactory = (db: TDbClient) => { findById, findByIdWithChannels, findByProjectId, + findByProjectIdWithCount, countByProjectId, getDistinctProjectIds, findMatchingCertificates diff --git a/backend/src/services/pki-alert-v2/pki-alert-v2-filter-utils.ts b/backend/src/services/pki-alert-v2/pki-alert-v2-filter-utils.ts index fc4bbc13a..cb7f9cf72 100644 --- a/backend/src/services/pki-alert-v2/pki-alert-v2-filter-utils.ts +++ b/backend/src/services/pki-alert-v2/pki-alert-v2-filter-utils.ts @@ -7,6 +7,13 @@ 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"); @@ -169,25 +176,32 @@ const applySanFilter = (query: Knex.QueryBuilder, filter: TPkiFilterRule): Knex. if (Array.isArray(value)) { return query.where((builder) => { value.forEach((v, index) => { - const condition = `${columnName}::text ILIKE ?`; + const sanitizedValue = `%"${String(v)}"%`; if (index === 0) { - void builder.whereRaw(condition, [`%"${String(v)}"%`]); + void builder.whereRaw(`??."altNames"::text ILIKE ?`, [TableName.Certificate, sanitizedValue]); } else { - void builder.orWhereRaw(condition, [`%"${String(v)}"%`]); + void builder.orWhereRaw(`??."altNames"::text ILIKE ?`, [TableName.Certificate, sanitizedValue]); } }); }); } - return query.whereRaw(`${columnName}::text ILIKE ?`, [`%"${String(value)}"%`]); + { + 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: - return query.whereRaw(`${columnName}::text ILIKE ?`, [`%"${String(value)}%`]); + case PkiFilterOperator.STARTS_WITH: { + const startsWithValue = `%"${String(value)}%`; + return query.whereRaw(`??."altNames"::text ILIKE ?`, [TableName.Certificate, startsWithValue]); + } - case PkiFilterOperator.ENDS_WITH: - return query.whereRaw(`${columnName}::text ILIKE ?`, [`%${String(value)}"%`]); + 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)}`); @@ -244,7 +258,7 @@ export const applyCaFilters = ( filters: TPkiFilterRule[], projectId: string ): Knex.QueryBuilder => { - let filteredQuery = query.where(`${TableName.CertificateAuthority}.projectId`, projectId); + 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) { diff --git a/backend/src/services/pki-alert-v2/pki-alert-v2-queue.ts b/backend/src/services/pki-alert-v2/pki-alert-v2-queue.ts index f7acf911f..81b4bc9ef 100644 --- a/backend/src/services/pki-alert-v2/pki-alert-v2-queue.ts +++ b/backend/src/services/pki-alert-v2/pki-alert-v2-queue.ts @@ -63,7 +63,7 @@ export const pkiAlertV2QueueServiceFactory = ({ const evaluateAlert = async ( alert: { id: string; - slug: string; + name: string; eventType: string; alertBefore: string; filters: TPkiFilterRule[]; @@ -135,7 +135,7 @@ export const pkiAlertV2QueueServiceFactory = ({ for (const alert of alerts) { const typedAlert = alert as { id: string; - slug: string; + name: string; eventType: string; alertBefore: string; filters: TPkiFilterRule[]; @@ -147,13 +147,13 @@ export const pkiAlertV2QueueServiceFactory = ({ await pkiAlertV2Service.sendAlertNotifications(typedAlert.id, certificateIds); notificationsSent += 1; logger.info( - `Sent notification for alert ${typedAlert.id} (${typedAlert.slug}) with ${certificateIds.length} certificates` + `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.slug})`); + logger.error(error, `Failed to process alert ${typedAlert.id} (${typedAlert.name})`); } } diff --git a/backend/src/services/pki-alert-v2/pki-alert-v2-service.ts b/backend/src/services/pki-alert-v2/pki-alert-v2-service.ts index ab00ef9a6..bbc4ff4a6 100644 --- a/backend/src/services/pki-alert-v2/pki-alert-v2-service.ts +++ b/backend/src/services/pki-alert-v2/pki-alert-v2-service.ts @@ -39,8 +39,10 @@ type TPkiAlertV2ServiceFactoryDep = { | "updateById" | "deleteById" | "findByProjectId" + | "findByProjectIdWithCount" | "countByProjectId" | "findMatchingCertificates" + | "transaction" >; pkiAlertChannelDAL: Pick; pkiAlertHistoryDAL: Pick; @@ -59,7 +61,7 @@ export const pkiAlertV2ServiceFactory = ({ }: TPkiAlertV2ServiceFactoryDep) => { type TAlertWithChannels = { id: string; - slug: string; + name: string; description: string; eventType: string; alertBefore: string; @@ -81,7 +83,7 @@ export const pkiAlertV2ServiceFactory = ({ const formatAlertResponse = (alert: TAlertWithChannels): TAlertV2Response => { return { id: alert.id, - slug: alert.slug, + name: alert.name, description: alert.description, eventType: alert.eventType as PkiAlertEventType, alertBefore: alert.alertBefore, @@ -103,7 +105,7 @@ export const pkiAlertV2ServiceFactory = ({ const createAlert = async ({ projectId, - slug, + name, description, eventType, alertBefore, @@ -132,31 +134,36 @@ export const pkiAlertV2ServiceFactory = ({ throw new BadRequestError({ message: "Invalid alertBefore format. Use format like '30d', '1w', '3m', '1y'" }); } - const alert = await pkiAlertV2DAL.create({ - projectId, - slug, - description, - eventType, - alertBefore, - filters, - enabled + 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 channelInserts = channels.map((channel) => ({ - alertId: alert.id, - channelType: channel.channelType, - config: channel.config, - enabled: channel.enabled - })); - - await pkiAlertChannelDAL.insertMany(channelInserts); - - const completeAlert = await pkiAlertV2DAL.findByIdWithChannels(alert.id); - if (!completeAlert) { - throw new NotFoundError({ message: "Failed to retrieve created alert" }); - } - - return formatAlertResponse(completeAlert as TAlertWithChannels); }; const getAlertById = async ({ @@ -208,10 +215,7 @@ export const pkiAlertV2ServiceFactory = ({ const filters = { search, eventType, enabled, limit, offset }; - const [alerts, total] = await Promise.all([ - pkiAlertV2DAL.findByProjectId(projectId, filters), - pkiAlertV2DAL.countByProjectId(projectId, { search, eventType, enabled }) - ]); + const { alerts, total } = await pkiAlertV2DAL.findByProjectIdWithCount(projectId, filters); return { alerts: alerts.map((alert) => formatAlertResponse(alert as TAlertWithChannels)), @@ -221,7 +225,7 @@ export const pkiAlertV2ServiceFactory = ({ const updateAlert = async ({ alertId, - slug, + name, description, eventType, alertBefore, @@ -256,41 +260,43 @@ export const pkiAlertV2ServiceFactory = ({ } const updateData: { - slug?: string; + name?: string; description?: string; eventType?: PkiAlertEventType; alertBefore?: string; filters?: TPkiFilterRule[]; enabled?: boolean; } = {}; - if (slug !== undefined) updateData.slug = slug; + 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; - alert = await pkiAlertV2DAL.updateById(alertId, updateData); + return pkiAlertV2DAL.transaction(async (tx) => { + alert = await pkiAlertV2DAL.updateById(alertId, updateData, tx); - if (channels) { - await pkiAlertChannelDAL.deleteByAlertId(alertId); + if (channels) { + await pkiAlertChannelDAL.deleteByAlertId(alertId, tx); - const channelInserts = channels.map((channel) => ({ - alertId, - channelType: channel.channelType, - config: channel.config, - enabled: channel.enabled - })); + const channelInserts = channels.map((channel) => ({ + alertId, + channelType: channel.channelType, + config: channel.config, + enabled: channel.enabled + })); - await pkiAlertChannelDAL.insertMany(channelInserts); - } + await pkiAlertChannelDAL.insertMany(channelInserts, tx); + } - const completeAlert = await pkiAlertV2DAL.findByIdWithChannels(alertId); - if (!completeAlert) { - throw new NotFoundError({ message: "Failed to retrieve updated alert" }); - } + const completeAlert = await pkiAlertV2DAL.findByIdWithChannels(alertId, tx); + if (!completeAlert) { + throw new NotFoundError({ message: "Failed to retrieve updated alert" }); + } - return formatAlertResponse(completeAlert as TAlertWithChannels); + return formatAlertResponse(completeAlert as TAlertWithChannels); + }); }; const deleteAlert = async ({ @@ -365,9 +371,7 @@ export const pkiAlertV2ServiceFactory = ({ return { certificates: result.certificates, - total: result.total, - limit, - offset + total: result.total }; }; @@ -415,9 +419,7 @@ export const pkiAlertV2ServiceFactory = ({ return { certificates: result.certificates, - total: result.total, - limit, - offset + total: result.total }; }; @@ -445,7 +447,7 @@ export const pkiAlertV2ServiceFactory = ({ if (matchingCertificates.length === 0) return; - let notificationSent = false; + let hasNotificationSent = false; let notificationError: string | undefined; try { @@ -455,7 +457,7 @@ export const pkiAlertV2ServiceFactory = ({ ); const alertBeforeDays = parseTimeToDays((alert as { alertBefore: string }).alertBefore); - const alertName = (alert as { slug: string }).slug; + const alertName = (alert as { name: string }).name; const emailPromises = emailChannels.map((channel) => { const config = channel.config as TEmailChannelConfig; @@ -480,14 +482,14 @@ export const pkiAlertV2ServiceFactory = ({ await Promise.all(emailPromises); - notificationSent = true; + 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, { - notificationSent, + hasNotificationSent, notificationError }); }; diff --git a/backend/src/services/pki-alert-v2/pki-alert-v2-types.ts b/backend/src/services/pki-alert-v2/pki-alert-v2-types.ts index 64bf834e5..fe6055402 100644 --- a/backend/src/services/pki-alert-v2/pki-alert-v2-types.ts +++ b/backend/src/services/pki-alert-v2/pki-alert-v2-types.ts @@ -3,12 +3,18 @@ import { z } from "zod"; import { TGenericPermission } from "@app/lib/types"; -const createSecureSlugValidator = () => { - const slugRegex = new RE2("^[a-z0-9]+(?:-[a-z0-9]+)*$"); - return (value: string) => slugRegex.test(value); +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; @@ -98,11 +104,11 @@ export const CreateChannelSchema = z.object({ export type TCreateChannel = z.infer; export const CreatePkiAlertV2Schema = z.object({ - slug: z + name: z .string() .min(1) .max(255) - .refine(createSecureSlugValidator(), "Must be a valid slug (lowercase, numbers, hyphens only)"), + .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'"), @@ -169,7 +175,7 @@ export type TCertificatePreview = { export type TAlertV2Response = { id: string; - slug: string; + name: string; description: string | null; eventType: PkiAlertEventType; alertBefore: string; @@ -196,6 +202,4 @@ export type TListAlertsV2Response = { export type TListMatchingCertificatesResponse = { certificates: TCertificatePreview[]; total: number; - limit: number; - offset: number; }; diff --git a/frontend/src/hooks/api/pkiAlertsV2/mutations.ts b/frontend/src/hooks/api/pkiAlertsV2/mutations.ts index 126ae6be2..7092cf7b5 100644 --- a/frontend/src/hooks/api/pkiAlertsV2/mutations.ts +++ b/frontend/src/hooks/api/pkiAlertsV2/mutations.ts @@ -10,8 +10,11 @@ export const useCreatePkiAlertV2 = () => { return useMutation({ mutationFn: async (data) => { - const { data: response } = await apiRequest.post("/api/v2/pki/alerts", data); - return response; + const { data: response } = await apiRequest.post<{ alert: TPkiAlertV2 }>( + "/api/v2/pki/alerts", + data + ); + return response.alert; }, onSuccess: (_, variables) => { queryClient.invalidateQueries({ @@ -26,11 +29,11 @@ export const useUpdatePkiAlertV2 = () => { return useMutation({ mutationFn: async ({ alertId, ...data }) => { - const { data: response } = await apiRequest.patch( + const { data: response } = await apiRequest.patch<{ alert: TPkiAlertV2 }>( `/api/v2/pki/alerts/${alertId}`, data ); - return response; + return response.alert; }, onSuccess: (_, variables) => { queryClient.invalidateQueries({ @@ -48,8 +51,10 @@ export const useDeletePkiAlertV2 = () => { return useMutation({ mutationFn: async ({ alertId }) => { - const { data } = await apiRequest.delete(`/api/v2/pki/alerts/${alertId}`); - return data; + const { data } = await apiRequest.delete<{ alert: TPkiAlertV2 }>( + `/api/v2/pki/alerts/${alertId}` + ); + return data.alert; }, onSuccess: (_, variables) => { queryClient.invalidateQueries({ diff --git a/frontend/src/hooks/api/pkiAlertsV2/queries.ts b/frontend/src/hooks/api/pkiAlertsV2/queries.ts index e1175b84e..d139341ae 100644 --- a/frontend/src/hooks/api/pkiAlertsV2/queries.ts +++ b/frontend/src/hooks/api/pkiAlertsV2/queries.ts @@ -31,8 +31,8 @@ const fetchPkiAlertsV2 = async (params: TGetPkiAlertsV2): Promise => { - const { data } = await apiRequest.get(`/api/v2/pki/alerts/${alertId}`); - return data; + const { data } = await apiRequest.get<{ alert: TPkiAlertV2 }>(`/api/v2/pki/alerts/${alertId}`); + return data.alert; }; const fetchPkiAlertV2MatchingCertificates = async ( diff --git a/frontend/src/hooks/api/pkiAlertsV2/types.ts b/frontend/src/hooks/api/pkiAlertsV2/types.ts index 3b38118af..0f824ed10 100644 --- a/frontend/src/hooks/api/pkiAlertsV2/types.ts +++ b/frontend/src/hooks/api/pkiAlertsV2/types.ts @@ -51,7 +51,7 @@ export interface TPkiAlertChannelV2 { export interface TPkiAlertV2 { id: string; projectId: string; - slug: string; + name: string; description?: string; eventType: PkiAlertEventTypeV2; alertBefore?: string; @@ -94,7 +94,7 @@ export interface TGetPkiAlertV2ById { export interface TCreatePkiAlertV2 { projectId: string; - slug: string; + name: string; description?: string; eventType: PkiAlertEventTypeV2; alertBefore?: string; @@ -105,7 +105,7 @@ export interface TCreatePkiAlertV2 { export interface TUpdatePkiAlertV2 { alertId: string; - slug?: string; + name?: string; description?: string; eventType?: PkiAlertEventTypeV2; alertBefore?: string; @@ -172,11 +172,11 @@ export const pkiAlertChannelV2Schema = z.object({ export const createPkiAlertV2Schema = z.object({ projectId: z.string().uuid(), - slug: z + name: z .string() .min(1) .max(255) - .regex(/^[a-z0-9]+(?:-[a-z0-9]+)*$/, "Must be a valid slug (lowercase, numbers, hyphens only)"), + .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 diff --git a/frontend/src/views/PkiAlertsV2Page/PkiAlertsV2Page.tsx b/frontend/src/views/PkiAlertsV2Page/PkiAlertsV2Page.tsx index 84968fd25..6bd156254 100644 --- a/frontend/src/views/PkiAlertsV2Page/PkiAlertsV2Page.tsx +++ b/frontend/src/views/PkiAlertsV2Page/PkiAlertsV2Page.tsx @@ -45,7 +45,7 @@ export const PkiAlertsV2Page = ({ hideContainer = false }: Props) => { const [deleteModal, setDeleteModal] = useState<{ isOpen: boolean; alertId?: string; - slug?: string; + name?: string; }>({ isOpen: false }); @@ -113,7 +113,7 @@ export const PkiAlertsV2Page = ({ hideContainer = false }: Props) => { setDeleteModal({ isOpen: true, alertId: alert.id, - slug: alert.slug + name: alert.name }) } /> @@ -199,8 +199,8 @@ export const PkiAlertsV2Page = ({ hideContainer = false }: Props) => { setDeleteModal({ isOpen, alertId: undefined, slug: undefined })} + title={`Delete PKI Alert "${deleteModal.name}"`} + onChange={(isOpen) => setDeleteModal({ isOpen, alertId: undefined, name: undefined })} onDeleteApproved={handleDeleteAlert} /> diff --git a/frontend/src/views/PkiAlertsV2Page/components/CreatePkiAlertV2FormSteps.tsx b/frontend/src/views/PkiAlertsV2Page/components/CreatePkiAlertV2FormSteps.tsx index 5def9aa18..2b5d0109d 100644 --- a/frontend/src/views/PkiAlertsV2Page/components/CreatePkiAlertV2FormSteps.tsx +++ b/frontend/src/views/PkiAlertsV2Page/components/CreatePkiAlertV2FormSteps.tsx @@ -162,7 +162,7 @@ export const CreatePkiAlertV2FormSteps = () => { render={({ field, fieldState: { error } }) => ( )} @@ -490,12 +490,12 @@ export const CreatePkiAlertV2FormSteps = () => {