diff --git a/backend/src/@types/fastify.d.ts b/backend/src/@types/fastify.d.ts index cfffdeac8..408b9a4f9 100644 --- a/backend/src/@types/fastify.d.ts +++ b/backend/src/@types/fastify.d.ts @@ -27,6 +27,7 @@ import { TSecretScanningServiceFactory } from "@app/ee/services/secret-scanning/ import { TSecretSnapshotServiceFactory } from "@app/ee/services/secret-snapshot/secret-snapshot-service"; import { TTrustedIpServiceFactory } from "@app/ee/services/trusted-ip/trusted-ip-service"; import { TAuthMode } from "@app/server/plugins/auth/inject-identity"; +import { TAlertServiceFactory } from "@app/services/alert/alert-service"; import { TApiKeyServiceFactory } from "@app/services/api-key/api-key-service"; import { TAuthLoginFactory } from "@app/services/auth/auth-login-service"; import { TAuthPasswordFactory } from "@app/services/auth/auth-password-service"; @@ -114,6 +115,7 @@ declare module "fastify" { group: TGroupServiceFactory; groupProject: TGroupProjectServiceFactory; apiKey: TApiKeyServiceFactory; + alert: TAlertServiceFactory; project: TProjectServiceFactory; projectMembership: TProjectMembershipServiceFactory; projectEnv: TProjectEnvServiceFactory; diff --git a/backend/src/@types/knex.d.ts b/backend/src/@types/knex.d.ts index 5b2f1f2c8..61b582e82 100644 --- a/backend/src/@types/knex.d.ts +++ b/backend/src/@types/knex.d.ts @@ -14,6 +14,9 @@ import { TAccessApprovalRequestsReviewersInsert, TAccessApprovalRequestsReviewersUpdate, TAccessApprovalRequestsUpdate, + TAlerts, + TAlertsInsert, + TAlertsUpdate, TApiKeys, TApiKeysInsert, TApiKeysUpdate, @@ -416,6 +419,7 @@ declare module "knex/types/tables" { [TableName.UserAction]: KnexOriginal.CompositeTableType; [TableName.SuperAdmin]: KnexOriginal.CompositeTableType; [TableName.ApiKey]: KnexOriginal.CompositeTableType; + [TableName.Alert]: KnexOriginal.CompositeTableType; [TableName.Project]: KnexOriginal.CompositeTableType; [TableName.ProjectMembership]: KnexOriginal.CompositeTableType< TProjectMemberships, diff --git a/backend/src/db/migrations/20240806173521_cert-alerting.ts b/backend/src/db/migrations/20240806173521_cert-alerting.ts new file mode 100644 index 000000000..a85218640 --- /dev/null +++ b/backend/src/db/migrations/20240806173521_cert-alerting.ts @@ -0,0 +1,26 @@ +import { Knex } from "knex"; + +import { TableName } from "../schemas"; +import { createOnUpdateTrigger, dropOnUpdateTrigger } from "../utils"; + +export async function up(knex: Knex): Promise { + if (!(await knex.schema.hasTable(TableName.Alert))) { + await knex.schema.createTable(TableName.Alert, (t) => { + t.uuid("id", { primaryKey: true }).defaultTo(knex.fn.uuid()); + t.timestamps(true, true, true); + t.string("projectId").notNullable(); + t.foreign("projectId").references("id").inTable(TableName.Project).onDelete("CASCADE"); + t.string("name").notNullable(); + t.integer("alertBeforeDays").notNullable(); + t.string("recipientEmails").notNullable(); + }); + } + + await createOnUpdateTrigger(knex, TableName.Alert); +} + +export async function down(knex: Knex): Promise { + // certificates + await knex.schema.dropTableIfExists(TableName.Alert); + await dropOnUpdateTrigger(knex, TableName.Alert); +} diff --git a/backend/src/db/schemas/alerts.ts b/backend/src/db/schemas/alerts.ts new file mode 100644 index 000000000..b1a7dcaa0 --- /dev/null +++ b/backend/src/db/schemas/alerts.ts @@ -0,0 +1,22 @@ +// Code generated by automation script, DO NOT EDIT. +// Automated by pulling database and generating zod schema +// To update. Just run npm run generate:schema +// Written by akhilmhdh. + +import { z } from "zod"; + +import { TImmutableDBKeys } from "./models"; + +export const AlertsSchema = z.object({ + id: z.string().uuid(), + createdAt: z.date(), + updatedAt: z.date(), + projectId: z.string(), + name: z.string(), + alertBeforeDays: z.number(), + recipientEmails: z.string() +}); + +export type TAlerts = z.infer; +export type TAlertsInsert = Omit, TImmutableDBKeys>; +export type TAlertsUpdate = Partial, TImmutableDBKeys>>; diff --git a/backend/src/db/schemas/index.ts b/backend/src/db/schemas/index.ts index 61031f910..26ee76ab4 100644 --- a/backend/src/db/schemas/index.ts +++ b/backend/src/db/schemas/index.ts @@ -2,6 +2,7 @@ export * from "./access-approval-policies"; export * from "./access-approval-policies-approvers"; export * from "./access-approval-requests"; export * from "./access-approval-requests-reviewers"; +export * from "./alerts"; export * from "./api-keys"; export * from "./audit-log-streams"; export * from "./audit-logs"; diff --git a/backend/src/db/schemas/models.ts b/backend/src/db/schemas/models.ts index 2e91608e3..7fe196514 100644 --- a/backend/src/db/schemas/models.ts +++ b/backend/src/db/schemas/models.ts @@ -9,6 +9,7 @@ export enum TableName { Certificate = "certificates", CertificateBody = "certificate_bodies", CertificateSecret = "certificate_secrets", + Alert = "alerts", Groups = "groups", GroupProjectMembership = "group_project_memberships", GroupProjectMembershipRole = "group_project_membership_roles", diff --git a/backend/src/ee/services/permission/project-permission.ts b/backend/src/ee/services/permission/project-permission.ts index b942cdd83..22837f12e 100644 --- a/backend/src/ee/services/permission/project-permission.ts +++ b/backend/src/ee/services/permission/project-permission.ts @@ -10,6 +10,7 @@ export enum ProjectPermissionActions { } export enum ProjectPermissionSub { + Alerts = "alerts", Role = "role", Member = "member", Groups = "groups", @@ -63,6 +64,7 @@ export type ProjectPermissionSet = | [ProjectPermissionActions, ProjectPermissionSub.Identity] | [ProjectPermissionActions, ProjectPermissionSub.CertificateAuthorities] | [ProjectPermissionActions, ProjectPermissionSub.Certificates] + | [ProjectPermissionActions, ProjectPermissionSub.Alerts] | [ProjectPermissionActions.Delete, ProjectPermissionSub.Project] | [ProjectPermissionActions.Edit, ProjectPermissionSub.Project] | [ProjectPermissionActions.Read, ProjectPermissionSub.SecretRollback] @@ -72,6 +74,11 @@ export type ProjectPermissionSet = const buildAdminPermissionRules = () => { const { can, rules } = new AbilityBuilder>(createMongoAbility); + can(ProjectPermissionActions.Read, ProjectPermissionSub.Alerts); + can(ProjectPermissionActions.Create, ProjectPermissionSub.Alerts); + can(ProjectPermissionActions.Edit, ProjectPermissionSub.Alerts); + can(ProjectPermissionActions.Delete, ProjectPermissionSub.Alerts); + can(ProjectPermissionActions.Read, ProjectPermissionSub.Secrets); can(ProjectPermissionActions.Create, ProjectPermissionSub.Secrets); can(ProjectPermissionActions.Edit, ProjectPermissionSub.Secrets); @@ -237,6 +244,8 @@ const buildMemberPermissionRules = () => { can(ProjectPermissionActions.Edit, ProjectPermissionSub.Certificates); can(ProjectPermissionActions.Delete, ProjectPermissionSub.Certificates); + can(ProjectPermissionActions.Read, ProjectPermissionSub.Alerts); + return rules; }; diff --git a/backend/src/server/routes/index.ts b/backend/src/server/routes/index.ts index a6ef33a72..70fc8c231 100644 --- a/backend/src/server/routes/index.ts +++ b/backend/src/server/routes/index.ts @@ -73,6 +73,8 @@ import { TKeyStoreFactory } from "@app/keystore/keystore"; import { getConfig } from "@app/lib/config/env"; import { TQueueServiceFactory } from "@app/queue"; import { readLimit } from "@app/server/config/rateLimiter"; +import { alertDALFactory } from "@app/services/alert/alert-dal"; +import { alertServiceFactory } from "@app/services/alert/alert-service"; import { apiKeyDALFactory } from "@app/services/api-key/api-key-dal"; import { apiKeyServiceFactory } from "@app/services/api-key/api-key-service"; import { authDALFactory } from "@app/services/auth/auth-dal"; @@ -216,6 +218,7 @@ export const registerRoutes = async ( const superAdminDAL = superAdminDALFactory(db); const rateLimitDAL = rateLimitDALFactory(db); const apiKeyDAL = apiKeyDALFactory(db); + const alertDAL = alertDALFactory(db); const projectDAL = projectDALFactory(db); const projectMembershipDAL = projectMembershipDALFactory(db); @@ -514,6 +517,10 @@ export const registerRoutes = async ( licenseService }); const apiKeyService = apiKeyServiceFactory({ apiKeyDAL, userDAL }); + const alertService = alertServiceFactory({ + alertDAL, + permissionService + }); const secretScanningQueue = secretScanningQueueFactory({ telemetryService, @@ -643,6 +650,7 @@ export const registerRoutes = async ( licenseService, certificateAuthorityDAL, certificateDAL, + alertDAL, projectUserMembershipRoleDAL, identityProjectMembershipRoleDAL, keyStore, @@ -1071,6 +1079,7 @@ export const registerRoutes = async ( orgRole: orgRoleService, oidc: oidcService, apiKey: apiKeyService, + alert: alertService, authToken: tokenService, superAdmin: superAdminService, project: projectService, diff --git a/backend/src/server/routes/v1/alert-router.ts b/backend/src/server/routes/v1/alert-router.ts new file mode 100644 index 000000000..e4e340588 --- /dev/null +++ b/backend/src/server/routes/v1/alert-router.ts @@ -0,0 +1,191 @@ +import { z } from "zod"; + +import { AlertsSchema } from "@app/db/schemas"; +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"; + +export const registerAlertRouter = async (server: FastifyZodProvider) => { + server.route({ + method: "POST", + url: "/", + config: { + rateLimit: writeLimit + }, + onRequest: verifyAuth([AuthMode.JWT, AuthMode.IDENTITY_ACCESS_TOKEN]), + schema: { + description: "Create alert", + body: z.object({ + projectId: z.string().trim(), + name: z.string().trim(), + alertBeforeDays: z.number(), + emails: z.array(z.string()) + }), + response: { + 200: AlertsSchema + } + }, + handler: async (req) => { + const alert = await server.services.alert.createAlert({ + actor: req.permission.type, + actorId: req.permission.id, + actorAuthMethod: req.permission.authMethod, + actorOrgId: req.permission.orgId, + ...req.body + }); + + // TODO: audit logging + + // await server.services.auditLog.createAuditLog({ + // ...req.auditLogInfo, + // projectId: ca.projectId, + // event: { + // type: EventType.REVOKE_CERT, + // metadata: { + // certId: cert.id, + // cn: cert.commonName, + // serialNumber: cert.serialNumber + // } + // } + // }); + + return alert; + } + }); + + server.route({ + method: "GET", + url: "/:alertId", + config: { + rateLimit: readLimit + }, + onRequest: verifyAuth([AuthMode.JWT, AuthMode.IDENTITY_ACCESS_TOKEN]), + schema: { + description: "Get alert", + params: z.object({ + alertId: z.string().trim() + }), + response: { + 200: AlertsSchema + } + }, + handler: async (req) => { + const alert = await server.services.alert.getAlertById({ + alertId: req.params.alertId, + actor: req.permission.type, + actorId: req.permission.id, + actorAuthMethod: req.permission.authMethod, + actorOrgId: req.permission.orgId + }); + + // TODO: audit logging + + // await server.services.auditLog.createAuditLog({ + // ...req.auditLogInfo, + // projectId: ca.projectId, + // event: { + // type: EventType.GET_CA, + // metadata: { + // caId: ca.id, + // dn: ca.dn + // } + // } + // }); + + return alert; + } + }); + + server.route({ + method: "PATCH", + url: "/:alertId", + config: { + rateLimit: readLimit + }, + onRequest: verifyAuth([AuthMode.JWT, AuthMode.IDENTITY_ACCESS_TOKEN]), + schema: { + description: "Update alert", + params: z.object({ + alertId: z.string().trim() + }), + body: z.object({ + name: z.string().trim().optional(), + alertBeforeDays: z.number().optional(), + emails: z.array(z.string()).optional() + }), + response: { + 200: AlertsSchema + } + }, + handler: async (req) => { + const alert = await server.services.alert.updateAlert({ + alertId: req.params.alertId, + actor: req.permission.type, + actorId: req.permission.id, + actorAuthMethod: req.permission.authMethod, + actorOrgId: req.permission.orgId, + ...req.body + }); + + // TODO: audit logging + + // await server.services.auditLog.createAuditLog({ + // ...req.auditLogInfo, + // projectId: ca.projectId, + // event: { + // type: EventType.GET_CA, + // metadata: { + // caId: ca.id, + // dn: ca.dn + // } + // } + // }); + + return alert; + } + }); + + server.route({ + method: "DELETE", + url: "/:alertId", + config: { + rateLimit: writeLimit + }, + onRequest: verifyAuth([AuthMode.JWT, AuthMode.IDENTITY_ACCESS_TOKEN]), + schema: { + description: "Delete alert", + params: z.object({ + alertId: z.string().trim() + }), + response: { + 200: AlertsSchema + } + }, + handler: async (req) => { + const alert = await server.services.alert.deleteAlert({ + alertId: req.params.alertId, + actor: req.permission.type, + actorId: req.permission.id, + actorAuthMethod: req.permission.authMethod, + actorOrgId: req.permission.orgId + }); + + // TODO: audit logging + + // await server.services.auditLog.createAuditLog({ + // ...req.auditLogInfo, + // projectId: ca.projectId, + // event: { + // type: EventType.DELETE_CERT, + // metadata: { + // certId: deletedCert.id, + // cn: deletedCert.commonName, + // serialNumber: deletedCert.serialNumber + // } + // } + // }); + + return alert; + } + }); +}; diff --git a/backend/src/server/routes/v1/index.ts b/backend/src/server/routes/v1/index.ts index 6c988d995..3a2e36e1c 100644 --- a/backend/src/server/routes/v1/index.ts +++ b/backend/src/server/routes/v1/index.ts @@ -1,4 +1,5 @@ import { registerAdminRouter } from "./admin-router"; +import { registerAlertRouter } from "./alert-router"; import { registerAuthRoutes } from "./auth-router"; import { registerProjectBotRouter } from "./bot-router"; import { registerCaRouter } from "./certificate-authority-router"; @@ -48,6 +49,7 @@ export const registerV1Routes = async (server: FastifyZodProvider) => { }, { prefix: "/auth" } ); + await server.register(registerAlertRouter, { prefix: "/alerts" }); await server.register(registerPasswordRouter, { prefix: "/password" }); await server.register(registerOrgRouter, { prefix: "/organization" }); await server.register(registerAdminRouter, { prefix: "/admin" }); diff --git a/backend/src/server/routes/v2/project-router.ts b/backend/src/server/routes/v2/project-router.ts index 58ae9e293..e332a17f9 100644 --- a/backend/src/server/routes/v2/project-router.ts +++ b/backend/src/server/routes/v2/project-router.ts @@ -1,7 +1,7 @@ import slugify from "@sindresorhus/slugify"; import { z } from "zod"; -import { CertificateAuthoritiesSchema, CertificatesSchema, ProjectKeysSchema } from "@app/db/schemas"; +import { AlertsSchema, CertificateAuthoritiesSchema, CertificatesSchema, ProjectKeysSchema } from "@app/db/schemas"; import { EventType } from "@app/ee/services/audit-log/audit-log-types"; import { PROJECTS } from "@app/lib/api-docs"; import { creationLimit, readLimit, writeLimit } from "@app/server/config/rateLimiter"; @@ -392,4 +392,34 @@ export const registerProjectRouter = async (server: FastifyZodProvider) => { return { certificates, totalCount }; } }); + + server.route({ + method: "GET", + url: "/:projectId/alerts", + config: { + rateLimit: readLimit + }, + schema: { + params: z.object({ + projectId: z.string().trim() + }), + response: { + 200: z.object({ + alerts: z.array(AlertsSchema) + }) + } + }, + onRequest: verifyAuth([AuthMode.JWT, AuthMode.IDENTITY_ACCESS_TOKEN]), + handler: async (req) => { + const { alerts } = await server.services.project.listProjectAlerts({ + projectId: req.params.projectId, + actorId: req.permission.id, + actorOrgId: req.permission.orgId, + actorAuthMethod: req.permission.authMethod, + actor: req.permission.type + }); + + return { alerts }; + } + }); }; diff --git a/backend/src/services/alert/alert-dal.ts b/backend/src/services/alert/alert-dal.ts new file mode 100644 index 000000000..08e8710ef --- /dev/null +++ b/backend/src/services/alert/alert-dal.ts @@ -0,0 +1,12 @@ +import { TDbClient } from "@app/db"; +import { TableName } from "@app/db/schemas"; +import { ormify } from "@app/lib/knex"; + +export type TAlertDALFactory = ReturnType; + +export const alertDALFactory = (db: TDbClient) => { + const alertOrm = ormify(db, TableName.Alert); + return { + ...alertOrm + }; +}; diff --git a/backend/src/services/alert/alert-service.ts b/backend/src/services/alert/alert-service.ts new file mode 100644 index 000000000..26e7c1c56 --- /dev/null +++ b/backend/src/services/alert/alert-service.ts @@ -0,0 +1,117 @@ +import { ForbiddenError } from "@casl/ability"; + +import { TPermissionServiceFactory } from "@app/ee/services/permission/permission-service"; +import { ProjectPermissionActions, ProjectPermissionSub } from "@app/ee/services/permission/project-permission"; +import { NotFoundError } from "@app/lib/errors"; + +import { TAlertDALFactory } from "./alert-dal"; +import { TCreateAlertDTO, TDeleteAlertDTO, TGetAlertByIdDTO, TUpdateAlertDTO } from "./alert-types"; + +type TAlertServiceFactoryDep = { + alertDAL: TAlertDALFactory; + permissionService: Pick; +}; + +export type TAlertServiceFactory = ReturnType; + +export const alertServiceFactory = ({ alertDAL, permissionService }: TAlertServiceFactoryDep) => { + const createAlert = async ({ + projectId, + name, + alertBeforeDays, + emails, + actorId, + actorAuthMethod, + actor, + actorOrgId + }: TCreateAlertDTO) => { + const { permission } = await permissionService.getProjectPermission( + actor, + actorId, + projectId, + actorAuthMethod, + actorOrgId + ); + + ForbiddenError.from(permission).throwUnlessCan(ProjectPermissionActions.Create, ProjectPermissionSub.Alerts); + + const alert = await alertDAL.create({ + projectId, + name, + alertBeforeDays, + recipientEmails: emails.join(",") + }); + return alert; + }; + + const getAlertById = async ({ alertId, actorId, actorAuthMethod, actor, actorOrgId }: TGetAlertByIdDTO) => { + const alert = await alertDAL.findById(alertId); + if (!alert) throw new NotFoundError({ message: "Alert not found" }); + + const { permission } = await permissionService.getProjectPermission( + actor, + actorId, + alert.projectId, + actorAuthMethod, + actorOrgId + ); + + ForbiddenError.from(permission).throwUnlessCan(ProjectPermissionActions.Read, ProjectPermissionSub.Alerts); + return alert; + }; + + const updateAlert = async ({ + alertId, + name, + alertBeforeDays, + emails, + actorId, + actorAuthMethod, + actor, + actorOrgId + }: TUpdateAlertDTO) => { + let alert = await alertDAL.findById(alertId); + if (!alert) throw new NotFoundError({ message: "Alert not found" }); + + const { permission } = await permissionService.getProjectPermission( + actor, + actorId, + alert.projectId, + actorAuthMethod, + actorOrgId + ); + + ForbiddenError.from(permission).throwUnlessCan(ProjectPermissionActions.Create, ProjectPermissionSub.Alerts); + alert = await alertDAL.updateById(alertId, { + name, + alertBeforeDays, + recipientEmails: emails ? emails.join(",") : undefined + }); + + return alert; + }; + + const deleteAlert = async ({ alertId, actorId, actorAuthMethod, actor, actorOrgId }: TDeleteAlertDTO) => { + let alert = await alertDAL.findById(alertId); + if (!alert) throw new NotFoundError({ message: "Alert not found" }); + + const { permission } = await permissionService.getProjectPermission( + actor, + actorId, + alert.projectId, + actorAuthMethod, + actorOrgId + ); + + ForbiddenError.from(permission).throwUnlessCan(ProjectPermissionActions.Create, ProjectPermissionSub.Alerts); + alert = await alertDAL.deleteById(alertId); + return alert; + }; + + return { + createAlert, + getAlertById, + updateAlert, + deleteAlert + }; +}; diff --git a/backend/src/services/alert/alert-types.ts b/backend/src/services/alert/alert-types.ts new file mode 100644 index 000000000..959667b7e --- /dev/null +++ b/backend/src/services/alert/alert-types.ts @@ -0,0 +1,22 @@ +import { TProjectPermission } from "@app/lib/types"; + +export type TCreateAlertDTO = { + name: string; + alertBeforeDays: number; + emails: string[]; +} & TProjectPermission; + +export type TGetAlertByIdDTO = { + alertId: string; +} & Omit; + +export type TUpdateAlertDTO = { + alertId: string; + name?: string; + alertBeforeDays?: number; + emails?: string[]; +} & Omit; + +export type TDeleteAlertDTO = { + alertId: string; +} & Omit; diff --git a/backend/src/services/project/project-service.ts b/backend/src/services/project/project-service.ts index fd22349db..0d58d7f4b 100644 --- a/backend/src/services/project/project-service.ts +++ b/backend/src/services/project/project-service.ts @@ -13,6 +13,7 @@ import { BadRequestError, ForbiddenRequestError, NotFoundError } from "@app/lib/ import { alphaNumericNanoId } from "@app/lib/nanoid"; import { TProjectPermission } from "@app/lib/types"; +import { TAlertDALFactory } from "../alert/alert-dal"; import { ActorType } from "../auth/auth-type"; import { TCertificateDALFactory } from "../certificate/certificate-dal"; import { TCertificateAuthorityDALFactory } from "../certificate-authority/certificate-authority-dal"; @@ -37,6 +38,7 @@ import { TDeleteProjectDTO, TGetProjectDTO, TGetProjectKmsKey, + TListProjectAlertsDTO, TListProjectCasDTO, TListProjectCertsDTO, TLoadProjectKmsBackupDTO, @@ -70,6 +72,7 @@ type TProjectServiceFactoryDep = { projectUserMembershipRoleDAL: Pick; certificateAuthorityDAL: Pick; certificateDAL: Pick; + alertDAL: Pick; permissionService: TPermissionServiceFactory; orgService: Pick; licenseService: Pick; @@ -107,6 +110,7 @@ export const projectServiceFactory = ({ identityProjectMembershipRoleDAL, certificateAuthorityDAL, certificateDAL, + alertDAL, keyStore, kmsService, projectBotDAL @@ -676,6 +680,33 @@ export const projectServiceFactory = ({ }; }; + /** + * Return list of alerts configured for project + */ + const listProjectAlerts = async ({ + projectId, + actor, + actorId, + actorAuthMethod, + actorOrgId + }: TListProjectAlertsDTO) => { + const { permission } = await permissionService.getProjectPermission( + actor, + actorId, + projectId, + actorAuthMethod, + actorOrgId + ); + + ForbiddenError.from(permission).throwUnlessCan(ProjectPermissionActions.Read, ProjectPermissionSub.Alerts); + + const alerts = await alertDAL.find({ projectId }); + + return { + alerts + }; + }; + const updateProjectKmsKey = async ({ projectId, kms, @@ -794,6 +825,7 @@ export const projectServiceFactory = ({ upgradeProject, listProjectCas, listProjectCertificates, + listProjectAlerts, updateVersionLimit, updateAuditLogsRetention, updateProjectKmsKey, diff --git a/backend/src/services/project/project-types.ts b/backend/src/services/project/project-types.ts index 1c2279c7a..ac53b3f71 100644 --- a/backend/src/services/project/project-types.ts +++ b/backend/src/services/project/project-types.ts @@ -106,6 +106,8 @@ export type TListProjectCertsDTO = { commonName?: string; } & Omit; +export type TListProjectAlertsDTO = TProjectPermission; + export type TUpdateProjectKmsDTO = { kms: { type: KmsType.Internal } | { type: KmsType.External; kmsId: string }; } & TProjectPermission; diff --git a/frontend/src/context/ProjectPermissionContext/types.ts b/frontend/src/context/ProjectPermissionContext/types.ts index 557b3cd17..7f1700035 100644 --- a/frontend/src/context/ProjectPermissionContext/types.ts +++ b/frontend/src/context/ProjectPermissionContext/types.ts @@ -8,6 +8,7 @@ export enum ProjectPermissionActions { } export enum ProjectPermissionSub { + Alerts = "alerts", Role = "role", Member = "member", Groups = "groups", @@ -41,6 +42,7 @@ export type ProjectPermissionSet = ProjectPermissionActions, ProjectPermissionSub.Secrets | (ForcedSubject & SubjectFields) ] + | [ProjectPermissionActions, ProjectPermissionSub.Alerts] | [ProjectPermissionActions, ProjectPermissionSub.Role] | [ProjectPermissionActions, ProjectPermissionSub.Tags] | [ProjectPermissionActions, ProjectPermissionSub.Member] diff --git a/frontend/src/hooks/api/alerts/index.tsx b/frontend/src/hooks/api/alerts/index.tsx new file mode 100644 index 000000000..8bb912094 --- /dev/null +++ b/frontend/src/hooks/api/alerts/index.tsx @@ -0,0 +1,2 @@ +export { useCreateAlert, useDeleteAlert,useUpdateAlert } from "./mutations"; +export { useGetAlertById } from "./queries"; diff --git a/frontend/src/hooks/api/alerts/mutations.tsx b/frontend/src/hooks/api/alerts/mutations.tsx new file mode 100644 index 000000000..065bbd39d --- /dev/null +++ b/frontend/src/hooks/api/alerts/mutations.tsx @@ -0,0 +1,48 @@ +import { useMutation, useQueryClient } from "@tanstack/react-query"; + +import { apiRequest } from "@app/config/request"; + +import { workspaceKeys } from "../workspace/queries"; +import { alertKeys } from "./queries"; +import { TAlert, TCreateAlertDTO, TDeleteAlertDTO,TUpdateAlertDTO } from "./types"; + +export const useCreateAlert = () => { + const queryClient = useQueryClient(); + return useMutation({ + mutationFn: async (body) => { + const { data: alert } = await apiRequest.post("/api/v1/alerts", body); + return alert; + }, + onSuccess: (_, { projectId }) => { + queryClient.invalidateQueries(workspaceKeys.getWorkspaceAlerts(projectId)); + } + }); +}; + +export const useUpdateAlert = () => { + const queryClient = useQueryClient(); + return useMutation({ + mutationFn: async ({ alertId, ...body }) => { + const { data: alert } = await apiRequest.patch(`/api/v1/alerts/${alertId}`, body); + return alert; + }, + onSuccess: (_, { projectId, alertId }) => { + queryClient.invalidateQueries(workspaceKeys.getWorkspaceAlerts(projectId)); + queryClient.invalidateQueries(alertKeys.getAlertById(alertId)); + } + }); +}; + +export const useDeleteAlert = () => { + const queryClient = useQueryClient(); + return useMutation({ + mutationFn: async ({ alertId }) => { + const { data: alert } = await apiRequest.delete(`/api/v1/alerts/${alertId}`); + return alert; + }, + onSuccess: (_, { projectId, alertId }) => { + queryClient.invalidateQueries(workspaceKeys.getWorkspaceAlerts(projectId)); + queryClient.invalidateQueries(alertKeys.getAlertById(alertId)); + } + }); +}; diff --git a/frontend/src/hooks/api/alerts/queries.tsx b/frontend/src/hooks/api/alerts/queries.tsx new file mode 100644 index 000000000..29e3d88e6 --- /dev/null +++ b/frontend/src/hooks/api/alerts/queries.tsx @@ -0,0 +1,20 @@ +import { useQuery } from "@tanstack/react-query"; + +import { apiRequest } from "@app/config/request"; + +import { TAlert } from "./types"; + +export const alertKeys = { + getAlertById: (alertId: string) => [{ alertId }, "alert"] +}; + +export const useGetAlertById = (alertId: string) => { + return useQuery({ + queryKey: alertKeys.getAlertById(alertId), + queryFn: async () => { + const { data: alert } = await apiRequest.get(`/api/v1/alerts/${alertId}`); + return alert; + }, + enabled: Boolean(alertId) + }); +}; diff --git a/frontend/src/hooks/api/alerts/types.ts b/frontend/src/hooks/api/alerts/types.ts new file mode 100644 index 000000000..70be5fc54 --- /dev/null +++ b/frontend/src/hooks/api/alerts/types.ts @@ -0,0 +1,29 @@ +export type TAlert = { + id: string; + name: string; + projectId: string; + alertBeforeDays: number; + recipientEmails: string; + createdAt: string; + updatedAt: string; +}; + +export type TCreateAlertDTO = { + projectId: string; + name: string; + alertBeforeDays: number; + emails: string[]; +}; + +export type TUpdateAlertDTO = { + alertId: string; + projectId: string; + name?: string; + alertBeforeDays?: number; + emails?: string[]; +}; + +export type TDeleteAlertDTO = { + alertId: string; + projectId: string; +}; diff --git a/frontend/src/hooks/api/index.tsx b/frontend/src/hooks/api/index.tsx index 5cbd1fb27..53ff4fa4e 100644 --- a/frontend/src/hooks/api/index.tsx +++ b/frontend/src/hooks/api/index.tsx @@ -1,5 +1,6 @@ export * from "./accessApproval"; export * from "./admin"; +export * from "./alerts"; export * from "./apiKeys"; export * from "./auditLogs"; export * from "./auditLogStreams"; diff --git a/frontend/src/hooks/api/workspace/index.tsx b/frontend/src/hooks/api/workspace/index.tsx index 7b528cfbd..aa134d80a 100644 --- a/frontend/src/hooks/api/workspace/index.tsx +++ b/frontend/src/hooks/api/workspace/index.tsx @@ -3,7 +3,8 @@ export { useDeleteGroupFromWorkspace, useLeaveProject, useMigrateProjectToV3, - useUpdateGroupWorkspaceRole} from "./mutations"; + useUpdateGroupWorkspaceRole +} from "./mutations"; export { useAddIdentityToWorkspace, useCreateWorkspace, @@ -22,6 +23,7 @@ export { useGetWorkspaceIntegrations, useGetWorkspaceSecrets, useGetWorkspaceUsers, + useListWorkspaceAlerts, useListWorkspaceCas, useListWorkspaceCertificates, useListWorkspaceGroups, @@ -31,5 +33,4 @@ export { useUpdateIdentityWorkspaceRole, useUpdateUserWorkspaceRole, useUpdateWsEnvironment, - useUpgradeProject -} from "./queries"; + useUpgradeProject} from "./queries"; diff --git a/frontend/src/hooks/api/workspace/queries.tsx b/frontend/src/hooks/api/workspace/queries.tsx index 57087feb9..5059f7334 100644 --- a/frontend/src/hooks/api/workspace/queries.tsx +++ b/frontend/src/hooks/api/workspace/queries.tsx @@ -2,6 +2,7 @@ import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; import { apiRequest } from "@app/config/request"; +import { TAlert } from "../alerts/types"; import { CaStatus } from "../ca/enums"; import { TCertificateAuthority } from "../ca/types"; import { TCertificate } from "../certificates/types"; @@ -62,7 +63,8 @@ export const workspaceKeys = { slug: string; offset: number; limit: number; - }) => [...workspaceKeys.forWorkspaceCertificates(slug), { offset, limit }] as const + }) => [...workspaceKeys.forWorkspaceCertificates(slug), { offset, limit }] as const, + getWorkspaceAlerts: (workspaceId: string) => [{ workspaceId }, "workspace-alerts"] as const }; const fetchWorkspaceById = async (workspaceId: string) => { @@ -601,3 +603,17 @@ export const useListWorkspaceCertificates = ({ enabled: Boolean(projectSlug) }); }; + +export const useListWorkspaceAlerts = ({ workspaceId }: { workspaceId: string }) => { + return useQuery({ + queryKey: workspaceKeys.getWorkspaceAlerts(workspaceId), + queryFn: async () => { + const { + data: { alerts } + } = await apiRequest.get<{ alerts: TAlert[] }>(`/api/v2/workspace/${workspaceId}/alerts`); + + return { alerts }; + }, + enabled: Boolean(workspaceId) + }); +}; diff --git a/frontend/src/views/Project/CertificatesPage/CertificatesPage.tsx b/frontend/src/views/Project/CertificatesPage/CertificatesPage.tsx index 30e531277..4f4b4aead 100644 --- a/frontend/src/views/Project/CertificatesPage/CertificatesPage.tsx +++ b/frontend/src/views/Project/CertificatesPage/CertificatesPage.tsx @@ -2,11 +2,12 @@ import { Tab, TabList, TabPanel, Tabs } from "@app/components/v2"; import { ProjectPermissionActions, ProjectPermissionSub } from "@app/context"; import { withProjectPermission } from "@app/hoc"; -import { CaTab, CertificatesTab } from "./components"; +import { AlertsTab,CaTab, CertificatesTab } from "./components"; enum TabSections { Ca = "certificate-authorities", - Certificates = "certificates" + Certificates = "certificates", + Alerts = "alerts" } export const CertificatesPage = withProjectPermission( @@ -19,6 +20,7 @@ export const CertificatesPage = withProjectPermission( Certificates Certificate Authorities + Alerting @@ -26,6 +28,9 @@ export const CertificatesPage = withProjectPermission( + + + diff --git a/frontend/src/views/Project/CertificatesPage/components/AlertsTab/AlertsTab.tsx b/frontend/src/views/Project/CertificatesPage/components/AlertsTab/AlertsTab.tsx new file mode 100644 index 000000000..b11d2d19a --- /dev/null +++ b/frontend/src/views/Project/CertificatesPage/components/AlertsTab/AlertsTab.tsx @@ -0,0 +1,17 @@ +import { motion } from "framer-motion"; + +import { AlertsSection } from "./components"; + +export const AlertsTab = () => { + return ( + + + + ); +}; diff --git a/frontend/src/views/Project/CertificatesPage/components/AlertsTab/components/AlertModal.tsx b/frontend/src/views/Project/CertificatesPage/components/AlertsTab/components/AlertModal.tsx new file mode 100644 index 000000000..d9591aa85 --- /dev/null +++ b/frontend/src/views/Project/CertificatesPage/components/AlertsTab/components/AlertModal.tsx @@ -0,0 +1,133 @@ +import { useEffect } from "react"; +import { Controller, useForm } from "react-hook-form"; +import { zodResolver } from "@hookform/resolvers/zod"; +import { z } from "zod"; + +import { createNotification } from "@app/components/notifications"; +import { Button, FormControl, Input, Modal, ModalContent } from "@app/components/v2"; +import { useWorkspace } from "@app/context"; +import { useCreateAlert, useGetAlertById,useUpdateAlert } from "@app/hooks/api"; +import { UsePopUpState } from "@app/hooks/usePopUp"; + +const schema = z.object({ + name: z.string().trim().min(1) +}); + +export type FormData = z.infer; + +type Props = { + popUp: UsePopUpState<["alert"]>; + handlePopUpToggle: (popUpName: keyof UsePopUpState<["alert"]>, state?: boolean) => void; +}; + +export const AlertModal = ({ popUp, handlePopUpToggle }: Props) => { + const { currentWorkspace } = useWorkspace(); + const projectId = currentWorkspace?.id || ""; + + const { data: alert } = useGetAlertById( + (popUp?.alert?.data as { alertId: string })?.alertId || "" + ); + + const { mutateAsync: createAlert } = useCreateAlert(); + const { mutateAsync: updateAlert } = useUpdateAlert(); + + const { + control, + handleSubmit, + reset, + formState: { isSubmitting } + } = useForm({ + resolver: zodResolver(schema) + }); + + useEffect(() => { + if (alert) { + reset({ + name: alert.name + }); + } else { + reset({ + name: "" + }); + } + }, [alert]); + + const onFormSubmit = async ({ name }: FormData) => { + try { + if (!projectId) return; + + if (alert) { + // update + await updateAlert({ + alertId: alert.id, + name, + projectId, + alertBeforeDays: 3, + emails: ["test"] + }); + } else { + // create + await createAlert({ + name, + projectId, + alertBeforeDays: 3, + emails: ["test"] + }); + } + + handlePopUpToggle("alert", false); + + reset(); + + createNotification({ + text: `Successfully ${alert ? "updated" : "created"} alert`, + type: "success" + }); + } catch (err) { + console.error(err); + createNotification({ + text: `Failed to ${alert ? "updated" : "created"} alert`, + type: "error" + }); + } + }; + + return ( + { + handlePopUpToggle("alert", isOpen); + reset(); + }} + > + +
+ ( + + + + )} + /> +
+ + +
+ +
+
+ ); +}; diff --git a/frontend/src/views/Project/CertificatesPage/components/AlertsTab/components/AlertsSection.tsx b/frontend/src/views/Project/CertificatesPage/components/AlertsTab/components/AlertsSection.tsx new file mode 100644 index 000000000..7f17844a4 --- /dev/null +++ b/frontend/src/views/Project/CertificatesPage/components/AlertsTab/components/AlertsSection.tsx @@ -0,0 +1,81 @@ +import { faPlus } from "@fortawesome/free-solid-svg-icons"; +import { FontAwesomeIcon } from "@fortawesome/react-fontawesome"; + +import { createNotification } from "@app/components/notifications"; +import { ProjectPermissionCan } from "@app/components/permissions"; +import { Button, DeleteActionModal } from "@app/components/v2"; +import { ProjectPermissionActions, ProjectPermissionSub, useWorkspace } from "@app/context"; +import { useDeleteAlert } from "@app/hooks/api"; +import { usePopUp } from "@app/hooks/usePopUp"; + +import { AlertModal } from "./AlertModal"; +import { AlertsTable } from "./AlertsTable"; + +export const AlertsSection = () => { + const { currentWorkspace } = useWorkspace(); + const projectId = currentWorkspace?.id || ""; + const { mutateAsync: deleteAlert } = useDeleteAlert(); + + const { popUp, handlePopUpOpen, handlePopUpClose, handlePopUpToggle } = usePopUp([ + "alert", + "deleteAlert" + ] as const); + + const onRemoveAlertSubmit = async (alertId: string) => { + try { + if (!projectId) return; + + await deleteAlert({ + alertId, + projectId + }); + + await createNotification({ + text: "Successfully deleted alert", + type: "success" + }); + + handlePopUpClose("deleteAlert"); + } catch (err) { + console.error(err); + createNotification({ + text: "Failed to delete alert", + type: "error" + }); + } + }; + + return ( +
+
+

Alerts

+ + {(isAllowed) => ( + + )} + +
+ + + handlePopUpToggle("deleteAlert", isOpen)} + deleteKey="confirm" + onDeleteApproved={() => + onRemoveAlertSubmit((popUp?.deleteAlert?.data as { alertId: string })?.alertId) + } + /> +
+ ); +}; diff --git a/frontend/src/views/Project/CertificatesPage/components/AlertsTab/components/AlertsTable.tsx b/frontend/src/views/Project/CertificatesPage/components/AlertsTab/components/AlertsTable.tsx new file mode 100644 index 000000000..c52ddfa2c --- /dev/null +++ b/frontend/src/views/Project/CertificatesPage/components/AlertsTab/components/AlertsTable.tsx @@ -0,0 +1,125 @@ +import { faEllipsis, faExclamationCircle } from "@fortawesome/free-solid-svg-icons"; +import { FontAwesomeIcon } from "@fortawesome/react-fontawesome"; +import { twMerge } from "tailwind-merge"; + +import { ProjectPermissionCan } from "@app/components/permissions"; +import { + DropdownMenu, + DropdownMenuContent, + DropdownMenuItem, + DropdownMenuTrigger, + EmptyState, + Table, + TableContainer, + TableSkeleton, + TBody, + Td, + Th, + THead, + Tr +} from "@app/components/v2"; +import { ProjectPermissionActions, ProjectPermissionSub , useWorkspace } from "@app/context"; +import { useListWorkspaceAlerts } from "@app/hooks/api"; +import { UsePopUpState } from "@app/hooks/usePopUp"; + +type Props = { + handlePopUpOpen: (popUpName: keyof UsePopUpState<["alert", "deleteAlert"]>, data?: {}) => void; +}; + +export const AlertsTable = ({ handlePopUpOpen }: Props) => { + const { currentWorkspace } = useWorkspace(); + const projectId = currentWorkspace?.id || ""; + + const { data, isLoading } = useListWorkspaceAlerts({ + workspaceId: projectId + }); + + return ( +
+ + + + + + + + + + + {isLoading && } + {!isLoading && + data?.alerts.map((alert) => { + return ( + + + + + + + ); + })} + +
NameStatusValid Until +
{alert.name}Test 2Test 3 + + +
+ +
+
+ + + {(isAllowed) => ( + { + e.stopPropagation(); + handlePopUpOpen("alert", { + alertId: alert.id + }); + }} + disabled={!isAllowed} + > + Edit Alert + + )} + + + {(isAllowed) => ( + { + e.stopPropagation(); + handlePopUpOpen("deleteAlert", { + alertId: alert.id, + name: alert.name + }); + }} + disabled={!isAllowed} + > + Delete Alert + + )} + + +
+
+ {!isLoading && !data?.alerts?.length && ( + + )} +
+
+ ); +}; diff --git a/frontend/src/views/Project/CertificatesPage/components/AlertsTab/components/index.tsx b/frontend/src/views/Project/CertificatesPage/components/AlertsTab/components/index.tsx new file mode 100644 index 000000000..dcd4dcd18 --- /dev/null +++ b/frontend/src/views/Project/CertificatesPage/components/AlertsTab/components/index.tsx @@ -0,0 +1 @@ +export { AlertsSection } from "./AlertsSection"; diff --git a/frontend/src/views/Project/CertificatesPage/components/AlertsTab/index.tsx b/frontend/src/views/Project/CertificatesPage/components/AlertsTab/index.tsx new file mode 100644 index 000000000..6bfffb6ee --- /dev/null +++ b/frontend/src/views/Project/CertificatesPage/components/AlertsTab/index.tsx @@ -0,0 +1 @@ +export { AlertsTab } from "./AlertsTab"; diff --git a/frontend/src/views/Project/CertificatesPage/components/index.tsx b/frontend/src/views/Project/CertificatesPage/components/index.tsx index 9dbd8694f..6301caf10 100644 --- a/frontend/src/views/Project/CertificatesPage/components/index.tsx +++ b/frontend/src/views/Project/CertificatesPage/components/index.tsx @@ -1,2 +1,3 @@ +export { AlertsTab } from "./AlertsTab"; export { CaTab } from "./CaTab"; export { CertificatesTab } from "./CertificatesTab";