mirror of
https://github.com/fosrl/pangolin.git
synced 2026-05-30 20:52:40 +00:00
Merge branch 'alerting-rules' into trial
This commit is contained in:
@@ -55,7 +55,7 @@ export async function fireHealthCheckHealthyAlert(
|
||||
}
|
||||
|
||||
/**
|
||||
* Fire a `health_check_not_healthy` alert for the given health check.
|
||||
* Fire a `health_check_unhealthy` alert for the given health check.
|
||||
*
|
||||
* Call this after a health check has been detected as failing so that any
|
||||
* matching `alertRules` can dispatch their email and webhook actions.
|
||||
@@ -73,7 +73,7 @@ export async function fireHealthCheckNotHealthyAlert(
|
||||
): Promise<void> {
|
||||
try {
|
||||
await processAlerts({
|
||||
eventType: "health_check_not_healthy",
|
||||
eventType: "health_check_unhealthy",
|
||||
orgId,
|
||||
healthCheckId,
|
||||
data: {
|
||||
|
||||
@@ -19,73 +19,109 @@ import { processAlerts } from "../processAlerts";
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* Fire a `health_check_healthy` alert for the given health check.
|
||||
* Fire a `resource_healthy` alert for the given resource.
|
||||
*
|
||||
* Call this after a previously-failing health check has recovered so that any
|
||||
* Call this after a previously-unhealthy resource has recovered so that any
|
||||
* matching `alertRules` can dispatch their email and webhook actions.
|
||||
*
|
||||
* @param orgId - Organisation that owns the health check.
|
||||
* @param healthCheckId - Numeric primary key of the health check.
|
||||
* @param healthCheckName - Human-readable name shown in notifications (optional).
|
||||
* @param extra - Any additional key/value pairs to include in the payload.
|
||||
* @param orgId - Organisation that owns the resource.
|
||||
* @param resourceId - Numeric primary key of the resource.
|
||||
* @param resourceName - Human-readable name shown in notifications (optional).
|
||||
* @param extra - Any additional key/value pairs to include in the payload.
|
||||
*/
|
||||
export async function fireHealthCheckHealthyAlert(
|
||||
export async function fireResourceHealthyAlert(
|
||||
orgId: string,
|
||||
healthCheckId: number,
|
||||
healthCheckName?: string | null,
|
||||
resourceId: number,
|
||||
resourceName?: string | null,
|
||||
extra?: Record<string, unknown>
|
||||
): Promise<void> {
|
||||
try {
|
||||
await processAlerts({
|
||||
eventType: "health_check_healthy",
|
||||
eventType: "resource_healthy",
|
||||
orgId,
|
||||
healthCheckId,
|
||||
resourceId,
|
||||
data: {
|
||||
healthCheckId,
|
||||
...(healthCheckName != null ? { healthCheckName } : {}),
|
||||
resourceId,
|
||||
...(resourceName != null ? { resourceName } : {}),
|
||||
...extra
|
||||
}
|
||||
});
|
||||
} catch (err) {
|
||||
logger.error(
|
||||
`fireHealthCheckHealthyAlert: unexpected error for healthCheckId ${healthCheckId}`,
|
||||
`fireResourceHealthyAlert: unexpected error for resourceId ${resourceId}`,
|
||||
err
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Fire a `health_check_not_healthy` alert for the given health check.
|
||||
* Fire a `resource_unhealthy` alert for the given resource.
|
||||
*
|
||||
* Call this after a health check has been detected as failing so that any
|
||||
* Call this after a resource has been detected as unhealthy so that any
|
||||
* matching `alertRules` can dispatch their email and webhook actions.
|
||||
*
|
||||
* @param orgId - Organisation that owns the health check.
|
||||
* @param healthCheckId - Numeric primary key of the health check.
|
||||
* @param healthCheckName - Human-readable name shown in notifications (optional).
|
||||
* @param extra - Any additional key/value pairs to include in the payload.
|
||||
* @param orgId - Organisation that owns the resource.
|
||||
* @param resourceId - Numeric primary key of the resource.
|
||||
* @param resourceName - Human-readable name shown in notifications (optional).
|
||||
* @param extra - Any additional key/value pairs to include in the payload.
|
||||
*/
|
||||
export async function fireHealthCheckNotHealthyAlert(
|
||||
export async function fireResourceUnhealthyAlert(
|
||||
orgId: string,
|
||||
healthCheckId: number,
|
||||
healthCheckName?: string | null,
|
||||
resourceId: number,
|
||||
resourceName?: string | null,
|
||||
extra?: Record<string, unknown>
|
||||
): Promise<void> {
|
||||
try {
|
||||
await processAlerts({
|
||||
eventType: "health_check_not_healthy",
|
||||
eventType: "resource_unhealthy",
|
||||
orgId,
|
||||
healthCheckId,
|
||||
resourceId,
|
||||
data: {
|
||||
healthCheckId,
|
||||
...(healthCheckName != null ? { healthCheckName } : {}),
|
||||
resourceId,
|
||||
...(resourceName != null ? { resourceName } : {}),
|
||||
...extra
|
||||
}
|
||||
});
|
||||
} catch (err) {
|
||||
logger.error(
|
||||
`fireHealthCheckNotHealthyAlert: unexpected error for healthCheckId ${healthCheckId}`,
|
||||
`fireResourceUnhealthyAlert: unexpected error for resourceId ${resourceId}`,
|
||||
err
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Fire a `resource_toggle` alert for the given resource.
|
||||
*
|
||||
* Call this when a resource's enabled/disabled status is toggled so that any
|
||||
* matching `alertRules` can dispatch their email and webhook actions.
|
||||
*
|
||||
* @param orgId - Organisation that owns the resource.
|
||||
* @param resourceId - Numeric primary key of the resource.
|
||||
* @param resourceName - Human-readable name shown in notifications (optional).
|
||||
* @param extra - Any additional key/value pairs to include in the payload.
|
||||
*/
|
||||
export async function fireResourceToggleAlert(
|
||||
orgId: string,
|
||||
resourceId: number,
|
||||
resourceName?: string | null,
|
||||
extra?: Record<string, unknown>
|
||||
): Promise<void> {
|
||||
try {
|
||||
await processAlerts({
|
||||
eventType: "resource_toggle",
|
||||
orgId,
|
||||
resourceId,
|
||||
data: {
|
||||
resourceId,
|
||||
...(resourceName != null ? { resourceName } : {}),
|
||||
...extra
|
||||
}
|
||||
});
|
||||
} catch (err) {
|
||||
logger.error(
|
||||
`fireResourceToggleAlert: unexpected error for resourceId ${resourceId}`,
|
||||
err
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -11,12 +11,13 @@
|
||||
* This file is not licensed under the AGPLv3.
|
||||
*/
|
||||
|
||||
import { and, eq, isNull, or } from "drizzle-orm";
|
||||
import { and, eq, or } from "drizzle-orm";
|
||||
import { db } from "@server/db";
|
||||
import {
|
||||
alertRules,
|
||||
alertSites,
|
||||
alertHealthChecks,
|
||||
alertResources,
|
||||
alertEmailActions,
|
||||
alertEmailRecipients,
|
||||
alertWebhookActions,
|
||||
@@ -48,11 +49,9 @@ export async function processAlerts(context: AlertContext): Promise<void> {
|
||||
// ------------------------------------------------------------------
|
||||
// 1. Find matching alert rules
|
||||
// ------------------------------------------------------------------
|
||||
// Rules with no junction-table entries match ALL sites / health checks.
|
||||
// Rules with junction entries match only those specific IDs.
|
||||
// We implement this with a LEFT JOIN: a NULL join result means the rule
|
||||
// has no scope restrictions (match all); a non-NULL result that satisfies
|
||||
// the id equality filter means an explicit match.
|
||||
// Rules with allSites / allHealthChecks / allResources set to true match
|
||||
// ANY event of that type. Rules without these flags set match only the
|
||||
// specific IDs listed in the junction tables.
|
||||
const baseConditions = and(
|
||||
eq(alertRules.orgId, context.orgId),
|
||||
eq(alertRules.eventType, context.eventType),
|
||||
@@ -73,12 +72,20 @@ export async function processAlerts(context: AlertContext): Promise<void> {
|
||||
and(
|
||||
baseConditions,
|
||||
or(
|
||||
eq(alertSites.siteId, context.siteId),
|
||||
isNull(alertSites.alertRuleId)
|
||||
eq(alertRules.allSites, true),
|
||||
eq(alertSites.siteId, context.siteId)
|
||||
)
|
||||
)
|
||||
);
|
||||
rules = rows.map((r) => r.alertRules);
|
||||
// Deduplicate in case a rule matched on multiple junction rows
|
||||
const seen = new Set<number>();
|
||||
rules = rows
|
||||
.map((r) => r.alertRules)
|
||||
.filter((r) => {
|
||||
if (seen.has(r.alertRuleId)) return false;
|
||||
seen.add(r.alertRuleId);
|
||||
return true;
|
||||
});
|
||||
} else if (context.healthCheckId != null) {
|
||||
const rows = await db
|
||||
.select()
|
||||
@@ -91,12 +98,44 @@ export async function processAlerts(context: AlertContext): Promise<void> {
|
||||
and(
|
||||
baseConditions,
|
||||
or(
|
||||
eq(alertHealthChecks.healthCheckId, context.healthCheckId),
|
||||
isNull(alertHealthChecks.alertRuleId)
|
||||
eq(alertRules.allHealthChecks, true),
|
||||
eq(alertHealthChecks.healthCheckId, context.healthCheckId)
|
||||
)
|
||||
)
|
||||
);
|
||||
rules = rows.map((r) => r.alertRules);
|
||||
const seen = new Set<number>();
|
||||
rules = rows
|
||||
.map((r) => r.alertRules)
|
||||
.filter((r) => {
|
||||
if (seen.has(r.alertRuleId)) return false;
|
||||
seen.add(r.alertRuleId);
|
||||
return true;
|
||||
});
|
||||
} else if (context.resourceId != null) {
|
||||
const rows = await db
|
||||
.select()
|
||||
.from(alertRules)
|
||||
.leftJoin(
|
||||
alertResources,
|
||||
eq(alertResources.alertRuleId, alertRules.alertRuleId)
|
||||
)
|
||||
.where(
|
||||
and(
|
||||
baseConditions,
|
||||
or(
|
||||
eq(alertRules.allResources, true),
|
||||
eq(alertResources.resourceId, context.resourceId)
|
||||
)
|
||||
)
|
||||
);
|
||||
const seen = new Set<number>();
|
||||
rules = rows
|
||||
.map((r) => r.alertRules)
|
||||
.filter((r) => {
|
||||
if (seen.has(r.alertRuleId)) return false;
|
||||
seen.add(r.alertRuleId);
|
||||
return true;
|
||||
});
|
||||
} else {
|
||||
rules = [];
|
||||
}
|
||||
|
||||
@@ -72,10 +72,20 @@ function buildSubject(context: AlertContext): string {
|
||||
return "[Alert] Site Back Online";
|
||||
case "site_offline":
|
||||
return "[Alert] Site Offline";
|
||||
case "site_toggle":
|
||||
return "[Alert] Site Status Changed";
|
||||
case "health_check_healthy":
|
||||
return "[Alert] Health Check Recovered";
|
||||
case "health_check_not_healthy":
|
||||
case "health_check_unhealthy":
|
||||
return "[Alert] Health Check Failing";
|
||||
case "health_check_toggle":
|
||||
return "[Alert] Health Check Status Changed";
|
||||
case "resource_healthy":
|
||||
return "[Alert] Resource Healthy";
|
||||
case "resource_unhealthy":
|
||||
return "[Alert] Resource Unhealthy";
|
||||
case "resource_toggle":
|
||||
return "[Alert] Resource Status Changed";
|
||||
default: {
|
||||
// Exhaustiveness fallback – should never be reached with a
|
||||
// well-typed caller, but keeps runtime behaviour predictable.
|
||||
@@ -84,4 +94,4 @@ function buildSubject(context: AlertContext): string {
|
||||
return "[Alert] Event Notification";
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -18,8 +18,13 @@
|
||||
export type AlertEventType =
|
||||
| "site_online"
|
||||
| "site_offline"
|
||||
| "site_toggle"
|
||||
| "health_check_healthy"
|
||||
| "health_check_not_healthy";
|
||||
| "health_check_unhealthy"
|
||||
| "health_check_toggle"
|
||||
| "resource_healthy"
|
||||
| "resource_unhealthy"
|
||||
| "resource_toggle";
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Webhook authentication config (stored as encrypted JSON in the DB)
|
||||
@@ -58,6 +63,8 @@ export interface AlertContext {
|
||||
siteId?: number;
|
||||
/** Set for health_check_* events */
|
||||
healthCheckId?: number;
|
||||
/** Set for resource_* events */
|
||||
resourceId?: number;
|
||||
/** Human-readable context data included in emails and webhook payloads */
|
||||
data: Record<string, unknown>;
|
||||
}
|
||||
}
|
||||
|
||||
16
server/private/routers/alertEvents/index.ts
Normal file
16
server/private/routers/alertEvents/index.ts
Normal file
@@ -0,0 +1,16 @@
|
||||
/*
|
||||
* This file is part of a proprietary work.
|
||||
*
|
||||
* Copyright (c) 2025-2026 Fossorial, Inc.
|
||||
* All rights reserved.
|
||||
*
|
||||
* This file is licensed under the Fossorial Commercial License.
|
||||
* You may not use this file except in compliance with the License.
|
||||
* Unauthorized use, copying, modification, or distribution is strictly prohibited.
|
||||
*
|
||||
* This file is not licensed under the AGPLv3.
|
||||
*/
|
||||
|
||||
export * from "./triggerSiteAlert";
|
||||
export * from "./triggerResourceAlert";
|
||||
export * from "./triggerHealthCheckAlert";
|
||||
129
server/private/routers/alertEvents/triggerHealthCheckAlert.ts
Normal file
129
server/private/routers/alertEvents/triggerHealthCheckAlert.ts
Normal file
@@ -0,0 +1,129 @@
|
||||
/*
|
||||
* This file is part of a proprietary work.
|
||||
*
|
||||
* Copyright (c) 2025-2026 Fossorial, Inc.
|
||||
* All rights reserved.
|
||||
*
|
||||
* This file is licensed under the Fossorial Commercial License.
|
||||
* You may not use this file except in compliance with the License.
|
||||
* Unauthorized use, copying, modification, or distribution is strictly prohibited.
|
||||
*
|
||||
* This file is not licensed under the AGPLv3.
|
||||
*/
|
||||
|
||||
import { Request, Response, NextFunction } from "express";
|
||||
import { z } from "zod";
|
||||
import { db } from "@server/db";
|
||||
import { targetHealthCheck, statusHistory } from "@server/db";
|
||||
import response from "@server/lib/response";
|
||||
import HttpCode from "@server/types/HttpCode";
|
||||
import createHttpError from "http-errors";
|
||||
import logger from "@server/logger";
|
||||
import { fromError } from "zod-validation-error";
|
||||
import { eq, and } from "drizzle-orm";
|
||||
import {
|
||||
fireHealthCheckHealthyAlert,
|
||||
fireHealthCheckNotHealthyAlert
|
||||
} from "#private/lib/alerts/events/healthCheckEvents";
|
||||
|
||||
const paramsSchema = z.strictObject({
|
||||
orgId: z.string().nonempty(),
|
||||
healthCheckId: z.coerce.number().int().positive()
|
||||
});
|
||||
|
||||
const bodySchema = z.strictObject({
|
||||
eventType: z.enum(["health_check_healthy", "health_check_unhealthy"])
|
||||
});
|
||||
|
||||
export type TriggerHealthCheckAlertResponse = {
|
||||
success: true;
|
||||
};
|
||||
|
||||
export async function triggerHealthCheckAlert(
|
||||
req: Request,
|
||||
res: Response,
|
||||
next: NextFunction
|
||||
): Promise<any> {
|
||||
try {
|
||||
const parsedParams = paramsSchema.safeParse(req.params);
|
||||
if (!parsedParams.success) {
|
||||
return next(
|
||||
createHttpError(
|
||||
HttpCode.BAD_REQUEST,
|
||||
fromError(parsedParams.error).toString()
|
||||
)
|
||||
);
|
||||
}
|
||||
const { orgId, healthCheckId } = parsedParams.data;
|
||||
|
||||
const parsedBody = bodySchema.safeParse(req.body);
|
||||
if (!parsedBody.success) {
|
||||
return next(
|
||||
createHttpError(
|
||||
HttpCode.BAD_REQUEST,
|
||||
fromError(parsedBody.error).toString()
|
||||
)
|
||||
);
|
||||
}
|
||||
const { eventType } = parsedBody.data;
|
||||
|
||||
// Verify the health check exists and belongs to the org
|
||||
const [healthCheck] = await db
|
||||
.select()
|
||||
.from(targetHealthCheck)
|
||||
.where(
|
||||
and(
|
||||
eq(
|
||||
targetHealthCheck.targetHealthCheckId,
|
||||
healthCheckId
|
||||
),
|
||||
eq(targetHealthCheck.orgId, orgId)
|
||||
)
|
||||
)
|
||||
.limit(1);
|
||||
|
||||
if (!healthCheck) {
|
||||
return next(
|
||||
createHttpError(
|
||||
HttpCode.NOT_FOUND,
|
||||
`Health check ${healthCheckId} not found in organization ${orgId}`
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
await db.insert(statusHistory).values({
|
||||
entityType: "healthCheck",
|
||||
entityId: healthCheckId,
|
||||
orgId,
|
||||
status: eventType === "health_check_healthy" ? "healthy" : "unhealthy",
|
||||
timestamp: Math.floor(Date.now() / 1000)
|
||||
});
|
||||
|
||||
if (eventType === "health_check_healthy") {
|
||||
await fireHealthCheckHealthyAlert(
|
||||
orgId,
|
||||
healthCheckId,
|
||||
healthCheck.name ?? undefined
|
||||
);
|
||||
} else {
|
||||
await fireHealthCheckNotHealthyAlert(
|
||||
orgId,
|
||||
healthCheckId,
|
||||
healthCheck.name ?? undefined
|
||||
);
|
||||
}
|
||||
|
||||
return response<TriggerHealthCheckAlertResponse>(res, {
|
||||
data: { success: true },
|
||||
success: true,
|
||||
error: false,
|
||||
message: "Alert triggered successfully",
|
||||
status: HttpCode.OK
|
||||
});
|
||||
} catch (error) {
|
||||
logger.error(error);
|
||||
return next(
|
||||
createHttpError(HttpCode.INTERNAL_SERVER_ERROR, "An error occurred")
|
||||
);
|
||||
}
|
||||
}
|
||||
135
server/private/routers/alertEvents/triggerResourceAlert.ts
Normal file
135
server/private/routers/alertEvents/triggerResourceAlert.ts
Normal file
@@ -0,0 +1,135 @@
|
||||
/*
|
||||
* This file is part of a proprietary work.
|
||||
*
|
||||
* Copyright (c) 2025-2026 Fossorial, Inc.
|
||||
* All rights reserved.
|
||||
*
|
||||
* This file is licensed under the Fossorial Commercial License.
|
||||
* You may not use this file except in compliance with the License.
|
||||
* Unauthorized use, copying, modification, or distribution is strictly prohibited.
|
||||
*
|
||||
* This file is not licensed under the AGPLv3.
|
||||
*/
|
||||
|
||||
import { Request, Response, NextFunction } from "express";
|
||||
import { z } from "zod";
|
||||
import { db } from "@server/db";
|
||||
import { resources, statusHistory } from "@server/db";
|
||||
import response from "@server/lib/response";
|
||||
import HttpCode from "@server/types/HttpCode";
|
||||
import createHttpError from "http-errors";
|
||||
import logger from "@server/logger";
|
||||
import { fromError } from "zod-validation-error";
|
||||
import { eq, and } from "drizzle-orm";
|
||||
import {
|
||||
fireResourceHealthyAlert,
|
||||
fireResourceUnhealthyAlert,
|
||||
fireResourceToggleAlert
|
||||
} from "#private/lib/alerts/events/resourceEvents";
|
||||
|
||||
const paramsSchema = z.strictObject({
|
||||
orgId: z.string().nonempty(),
|
||||
resourceId: z.coerce.number().int().positive()
|
||||
});
|
||||
|
||||
const bodySchema = z.strictObject({
|
||||
eventType: z.enum(["resource_healthy", "resource_unhealthy", "resource_toggle"])
|
||||
});
|
||||
|
||||
export type TriggerResourceAlertResponse = {
|
||||
success: true;
|
||||
};
|
||||
|
||||
export async function triggerResourceAlert(
|
||||
req: Request,
|
||||
res: Response,
|
||||
next: NextFunction
|
||||
): Promise<any> {
|
||||
try {
|
||||
const parsedParams = paramsSchema.safeParse(req.params);
|
||||
if (!parsedParams.success) {
|
||||
return next(
|
||||
createHttpError(
|
||||
HttpCode.BAD_REQUEST,
|
||||
fromError(parsedParams.error).toString()
|
||||
)
|
||||
);
|
||||
}
|
||||
const { orgId, resourceId } = parsedParams.data;
|
||||
|
||||
const parsedBody = bodySchema.safeParse(req.body);
|
||||
if (!parsedBody.success) {
|
||||
return next(
|
||||
createHttpError(
|
||||
HttpCode.BAD_REQUEST,
|
||||
fromError(parsedBody.error).toString()
|
||||
)
|
||||
);
|
||||
}
|
||||
const { eventType } = parsedBody.data;
|
||||
|
||||
// Verify the resource exists and belongs to the org
|
||||
const [resource] = await db
|
||||
.select()
|
||||
.from(resources)
|
||||
.where(
|
||||
and(
|
||||
eq(resources.resourceId, resourceId),
|
||||
eq(resources.orgId, orgId)
|
||||
)
|
||||
)
|
||||
.limit(1);
|
||||
|
||||
if (!resource) {
|
||||
return next(
|
||||
createHttpError(
|
||||
HttpCode.NOT_FOUND,
|
||||
`Resource ${resourceId} not found in organization ${orgId}`
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
if (eventType === "resource_healthy" || eventType === "resource_unhealthy") {
|
||||
await db.insert(statusHistory).values({
|
||||
entityType: "resource",
|
||||
entityId: resourceId,
|
||||
orgId,
|
||||
status: eventType === "resource_healthy" ? "healthy" : "unhealthy",
|
||||
timestamp: Math.floor(Date.now() / 1000)
|
||||
});
|
||||
}
|
||||
|
||||
if (eventType === "resource_healthy") {
|
||||
await fireResourceHealthyAlert(
|
||||
orgId,
|
||||
resourceId,
|
||||
resource.name ?? undefined
|
||||
);
|
||||
} else if (eventType === "resource_unhealthy") {
|
||||
await fireResourceUnhealthyAlert(
|
||||
orgId,
|
||||
resourceId,
|
||||
resource.name ?? undefined
|
||||
);
|
||||
} else {
|
||||
await fireResourceToggleAlert(
|
||||
orgId,
|
||||
resourceId,
|
||||
resource.name ?? undefined
|
||||
);
|
||||
}
|
||||
|
||||
return response<TriggerResourceAlertResponse>(res, {
|
||||
data: { success: true },
|
||||
success: true,
|
||||
error: false,
|
||||
message: "Alert triggered successfully",
|
||||
status: HttpCode.OK
|
||||
});
|
||||
} catch (error) {
|
||||
logger.error(error);
|
||||
return next(
|
||||
createHttpError(HttpCode.INTERNAL_SERVER_ERROR, "An error occurred")
|
||||
);
|
||||
}
|
||||
}
|
||||
113
server/private/routers/alertEvents/triggerSiteAlert.ts
Normal file
113
server/private/routers/alertEvents/triggerSiteAlert.ts
Normal file
@@ -0,0 +1,113 @@
|
||||
/*
|
||||
* This file is part of a proprietary work.
|
||||
*
|
||||
* Copyright (c) 2025-2026 Fossorial, Inc.
|
||||
* All rights reserved.
|
||||
*
|
||||
* This file is licensed under the Fossorial Commercial License.
|
||||
* You may not use this file except in compliance with the License.
|
||||
* Unauthorized use, copying, modification, or distribution is strictly prohibited.
|
||||
*
|
||||
* This file is not licensed under the AGPLv3.
|
||||
*/
|
||||
|
||||
import { Request, Response, NextFunction } from "express";
|
||||
import { z } from "zod";
|
||||
import { db } from "@server/db";
|
||||
import { sites, statusHistory } from "@server/db";
|
||||
import response from "@server/lib/response";
|
||||
import HttpCode from "@server/types/HttpCode";
|
||||
import createHttpError from "http-errors";
|
||||
import logger from "@server/logger";
|
||||
import { fromError } from "zod-validation-error";
|
||||
import { eq, and } from "drizzle-orm";
|
||||
import {
|
||||
fireSiteOnlineAlert,
|
||||
fireSiteOfflineAlert
|
||||
} from "#private/lib/alerts/events/siteEvents";
|
||||
|
||||
const paramsSchema = z.strictObject({
|
||||
orgId: z.string().nonempty(),
|
||||
siteId: z.coerce.number().int().positive()
|
||||
});
|
||||
|
||||
const bodySchema = z.strictObject({
|
||||
eventType: z.enum(["site_online", "site_offline"])
|
||||
});
|
||||
|
||||
export type TriggerSiteAlertResponse = {
|
||||
success: true;
|
||||
};
|
||||
|
||||
export async function triggerSiteAlert(
|
||||
req: Request,
|
||||
res: Response,
|
||||
next: NextFunction
|
||||
): Promise<any> {
|
||||
try {
|
||||
const parsedParams = paramsSchema.safeParse(req.params);
|
||||
if (!parsedParams.success) {
|
||||
return next(
|
||||
createHttpError(
|
||||
HttpCode.BAD_REQUEST,
|
||||
fromError(parsedParams.error).toString()
|
||||
)
|
||||
);
|
||||
}
|
||||
const { orgId, siteId } = parsedParams.data;
|
||||
|
||||
const parsedBody = bodySchema.safeParse(req.body);
|
||||
if (!parsedBody.success) {
|
||||
return next(
|
||||
createHttpError(
|
||||
HttpCode.BAD_REQUEST,
|
||||
fromError(parsedBody.error).toString()
|
||||
)
|
||||
);
|
||||
}
|
||||
const { eventType } = parsedBody.data;
|
||||
|
||||
// Verify the site exists and belongs to the org
|
||||
const [site] = await db
|
||||
.select()
|
||||
.from(sites)
|
||||
.where(and(eq(sites.siteId, siteId), eq(sites.orgId, orgId)))
|
||||
.limit(1);
|
||||
|
||||
if (!site) {
|
||||
return next(
|
||||
createHttpError(
|
||||
HttpCode.NOT_FOUND,
|
||||
`Site ${siteId} not found in organization ${orgId}`
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
await db.insert(statusHistory).values({
|
||||
entityType: "site",
|
||||
entityId: siteId,
|
||||
orgId,
|
||||
status: eventType === "site_online" ? "online" : "offline",
|
||||
timestamp: Math.floor(Date.now() / 1000)
|
||||
});
|
||||
|
||||
if (eventType === "site_online") {
|
||||
await fireSiteOnlineAlert(orgId, siteId, site.name ?? undefined);
|
||||
} else {
|
||||
await fireSiteOfflineAlert(orgId, siteId, site.name ?? undefined);
|
||||
}
|
||||
|
||||
return response<TriggerSiteAlertResponse>(res, {
|
||||
data: { success: true },
|
||||
success: true,
|
||||
error: false,
|
||||
message: "Alert triggered successfully",
|
||||
status: HttpCode.OK
|
||||
});
|
||||
} catch (error) {
|
||||
logger.error(error);
|
||||
return next(
|
||||
createHttpError(HttpCode.INTERNAL_SERVER_ERROR, "An error occurred")
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -13,11 +13,12 @@
|
||||
|
||||
import { Request, Response, NextFunction } from "express";
|
||||
import { z } from "zod";
|
||||
import { db } from "@server/db";
|
||||
import { db, roles } from "@server/db";
|
||||
import {
|
||||
alertRules,
|
||||
alertSites,
|
||||
alertHealthChecks,
|
||||
alertResources,
|
||||
alertEmailActions,
|
||||
alertEmailRecipients,
|
||||
alertWebhookActions
|
||||
@@ -31,10 +32,16 @@ import { OpenAPITags, registry } from "@server/openApi";
|
||||
import { encrypt } from "@server/lib/crypto";
|
||||
import config from "@server/lib/config";
|
||||
|
||||
const SITE_EVENT_TYPES = ["site_online", "site_offline"] as const;
|
||||
const HC_EVENT_TYPES = [
|
||||
export const SITE_EVENT_TYPES = ["site_online", "site_offline", "site_toggle"] as const;
|
||||
export const HC_EVENT_TYPES = [
|
||||
"health_check_healthy",
|
||||
"health_check_not_healthy"
|
||||
"health_check_unhealthy",
|
||||
"health_check_toggle"
|
||||
] as const;
|
||||
export const RESOURCE_EVENT_TYPES = [
|
||||
"resource_healthy",
|
||||
"resource_unhealthy",
|
||||
"resource_toggle"
|
||||
] as const;
|
||||
|
||||
const paramsSchema = z.strictObject({
|
||||
@@ -51,22 +58,28 @@ const bodySchema = z
|
||||
.strictObject({
|
||||
name: z.string().nonempty(),
|
||||
eventType: z.enum([
|
||||
"site_online",
|
||||
"site_offline",
|
||||
"health_check_healthy",
|
||||
"health_check_not_healthy"
|
||||
...HC_EVENT_TYPES,
|
||||
...SITE_EVENT_TYPES,
|
||||
...RESOURCE_EVENT_TYPES
|
||||
]),
|
||||
enabled: z.boolean().optional().default(true),
|
||||
cooldownSeconds: z.number().int().nonnegative().optional().default(300),
|
||||
// Source join tables - which is required depends on eventType
|
||||
siteIds: z.array(z.number().int().positive()).optional().default([]),
|
||||
allSites: z.boolean().optional().default(false),
|
||||
healthCheckIds: z
|
||||
.array(z.number().int().positive())
|
||||
.optional()
|
||||
.default([]),
|
||||
allHealthChecks: z.boolean().optional().default(false),
|
||||
resourceIds: z
|
||||
.array(z.number().int().positive())
|
||||
.optional()
|
||||
.default([]),
|
||||
allResources: z.boolean().optional().default(false),
|
||||
// Email recipients (flat)
|
||||
userIds: z.array(z.string().nonempty()).optional().default([]),
|
||||
roleIds: z.array(z.string().nonempty()).optional().default([]),
|
||||
roleIds: z.array(z.number()).optional().default([]),
|
||||
emails: z.array(z.string().email()).optional().default([]),
|
||||
// Webhook actions
|
||||
webhookActions: z.array(webhookActionSchema).optional().default([])
|
||||
@@ -78,21 +91,23 @@ const bodySchema = z
|
||||
const isHcEvent = (HC_EVENT_TYPES as readonly string[]).includes(
|
||||
val.eventType
|
||||
);
|
||||
const isResourceEvent = (RESOURCE_EVENT_TYPES as readonly string[]).includes(
|
||||
val.eventType
|
||||
);
|
||||
|
||||
if (isSiteEvent && val.siteIds.length === 0) {
|
||||
if (isSiteEvent && !val.allSites && val.siteIds.length === 0) {
|
||||
ctx.addIssue({
|
||||
code: z.ZodIssueCode.custom,
|
||||
message:
|
||||
"At least one siteId is required for site event types",
|
||||
message: "At least one siteId is required for site event types when allSites is false",
|
||||
path: ["siteIds"]
|
||||
});
|
||||
}
|
||||
|
||||
if (isHcEvent && val.healthCheckIds.length === 0) {
|
||||
if (isHcEvent && !val.allHealthChecks && val.healthCheckIds.length === 0) {
|
||||
ctx.addIssue({
|
||||
code: z.ZodIssueCode.custom,
|
||||
message:
|
||||
"At least one healthCheckId is required for health check event types",
|
||||
"At least one healthCheckId is required for health check event types when allHealthChecks is false",
|
||||
path: ["healthCheckIds"]
|
||||
});
|
||||
}
|
||||
@@ -108,11 +123,50 @@ const bodySchema = z
|
||||
if (isHcEvent && val.siteIds.length > 0) {
|
||||
ctx.addIssue({
|
||||
code: z.ZodIssueCode.custom,
|
||||
message:
|
||||
"siteIds must not be set for health check event types",
|
||||
message: "siteIds must not be set for health check event types",
|
||||
path: ["siteIds"]
|
||||
});
|
||||
}
|
||||
|
||||
if (isResourceEvent && !val.allResources && val.resourceIds.length === 0) {
|
||||
ctx.addIssue({
|
||||
code: z.ZodIssueCode.custom,
|
||||
message: "At least one resourceId is required for resource event types when allResources is false",
|
||||
path: ["resourceIds"]
|
||||
});
|
||||
}
|
||||
|
||||
if (isResourceEvent && val.siteIds.length > 0) {
|
||||
ctx.addIssue({
|
||||
code: z.ZodIssueCode.custom,
|
||||
message: "siteIds must not be set for resource event types",
|
||||
path: ["siteIds"]
|
||||
});
|
||||
}
|
||||
|
||||
if (isResourceEvent && val.healthCheckIds.length > 0) {
|
||||
ctx.addIssue({
|
||||
code: z.ZodIssueCode.custom,
|
||||
message: "healthCheckIds must not be set for resource event types",
|
||||
path: ["healthCheckIds"]
|
||||
});
|
||||
}
|
||||
|
||||
if (isSiteEvent && val.resourceIds.length > 0) {
|
||||
ctx.addIssue({
|
||||
code: z.ZodIssueCode.custom,
|
||||
message: "resourceIds must not be set for site event types",
|
||||
path: ["resourceIds"]
|
||||
});
|
||||
}
|
||||
|
||||
if (isHcEvent && val.resourceIds.length > 0) {
|
||||
ctx.addIssue({
|
||||
code: z.ZodIssueCode.custom,
|
||||
message: "resourceIds must not be set for health check event types",
|
||||
path: ["resourceIds"]
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
export type CreateAlertRuleResponse = {
|
||||
@@ -171,7 +225,11 @@ export async function createAlertRule(
|
||||
enabled,
|
||||
cooldownSeconds,
|
||||
siteIds,
|
||||
allSites,
|
||||
healthCheckIds,
|
||||
allHealthChecks,
|
||||
resourceIds,
|
||||
allResources,
|
||||
userIds,
|
||||
roleIds,
|
||||
emails,
|
||||
@@ -188,13 +246,16 @@ export async function createAlertRule(
|
||||
eventType,
|
||||
enabled,
|
||||
cooldownSeconds,
|
||||
allSites,
|
||||
allHealthChecks,
|
||||
allResources,
|
||||
createdAt: now,
|
||||
updatedAt: now
|
||||
})
|
||||
.returning();
|
||||
|
||||
// Insert site associations
|
||||
if (siteIds.length > 0) {
|
||||
// Insert site associations (skipped when allSites=true — empty junction = match all)
|
||||
if (!allSites && siteIds.length > 0) {
|
||||
await db.insert(alertSites).values(
|
||||
siteIds.map((siteId) => ({
|
||||
alertRuleId: rule.alertRuleId,
|
||||
@@ -203,8 +264,8 @@ export async function createAlertRule(
|
||||
);
|
||||
}
|
||||
|
||||
// Insert health check associations
|
||||
if (healthCheckIds.length > 0) {
|
||||
// Insert health check associations (skipped when allHealthChecks=true)
|
||||
if (!allHealthChecks && healthCheckIds.length > 0) {
|
||||
await db.insert(alertHealthChecks).values(
|
||||
healthCheckIds.map((healthCheckId) => ({
|
||||
alertRuleId: rule.alertRuleId,
|
||||
@@ -213,10 +274,22 @@ export async function createAlertRule(
|
||||
);
|
||||
}
|
||||
|
||||
// Insert resource associations (skipped when allResources=true)
|
||||
if (!allResources && resourceIds.length > 0) {
|
||||
await db.insert(alertResources).values(
|
||||
resourceIds.map((resourceId) => ({
|
||||
alertRuleId: rule.alertRuleId,
|
||||
resourceId
|
||||
}))
|
||||
);
|
||||
}
|
||||
|
||||
// Create the email action pivot row and recipients if any recipients
|
||||
// were supplied (userIds, roleIds, or raw emails).
|
||||
const hasRecipients =
|
||||
userIds.length > 0 || roleIds.length > 0 || emails.length > 0;
|
||||
userIds.length > 0 ||
|
||||
roleIds.length > 0 ||
|
||||
emails.length > 0;
|
||||
|
||||
if (hasRecipients) {
|
||||
const [emailActionRow] = await db
|
||||
@@ -228,7 +301,7 @@ export async function createAlertRule(
|
||||
...userIds.map((userId) => ({
|
||||
emailActionId: emailActionRow.emailActionId,
|
||||
userId,
|
||||
roleId: null as string | null,
|
||||
roleId: null as number | null,
|
||||
email: null as string | null
|
||||
})),
|
||||
...roleIds.map((roleId) => ({
|
||||
@@ -240,7 +313,7 @@ export async function createAlertRule(
|
||||
...emails.map((email) => ({
|
||||
emailActionId: emailActionRow.emailActionId,
|
||||
userId: null as string | null,
|
||||
roleId: null as string | null,
|
||||
roleId: null as number | null,
|
||||
email
|
||||
}))
|
||||
];
|
||||
@@ -254,7 +327,10 @@ export async function createAlertRule(
|
||||
webhookActions.map((wa) => ({
|
||||
alertRuleId: rule.alertRuleId,
|
||||
webhookUrl: wa.webhookUrl,
|
||||
config: wa.config != null ? encrypt(wa.config, serverSecret) : null,
|
||||
config:
|
||||
wa.config != null
|
||||
? encrypt(wa.config, serverSecret)
|
||||
: null,
|
||||
enabled: wa.enabled
|
||||
}))
|
||||
);
|
||||
@@ -275,4 +351,4 @@ export async function createAlertRule(
|
||||
createHttpError(HttpCode.INTERNAL_SERVER_ERROR, "An error occurred")
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -18,6 +18,7 @@ import {
|
||||
alertRules,
|
||||
alertSites,
|
||||
alertHealthChecks,
|
||||
alertResources,
|
||||
alertEmailActions,
|
||||
alertEmailRecipients,
|
||||
alertWebhookActions
|
||||
@@ -31,7 +32,7 @@ import { OpenAPITags, registry } from "@server/openApi";
|
||||
import { and, eq } from "drizzle-orm";
|
||||
import { decrypt } from "@server/lib/crypto";
|
||||
import config from "@server/lib/config";
|
||||
import { WebhookAlertConfig } from "@server/lib/alerts/types";
|
||||
import { WebhookAlertConfig } from "#private/lib/alerts/types";
|
||||
|
||||
const paramsSchema = z
|
||||
.object({
|
||||
@@ -47,8 +48,13 @@ export type GetAlertRuleResponse = {
|
||||
eventType:
|
||||
| "site_online"
|
||||
| "site_offline"
|
||||
| "site_toggle"
|
||||
| "health_check_healthy"
|
||||
| "health_check_not_healthy";
|
||||
| "health_check_unhealthy"
|
||||
| "health_check_toggle"
|
||||
| "resource_healthy"
|
||||
| "resource_unhealthy"
|
||||
| "resource_toggle";
|
||||
enabled: boolean;
|
||||
cooldownSeconds: number;
|
||||
lastTriggeredAt: number | null;
|
||||
@@ -56,10 +62,11 @@ export type GetAlertRuleResponse = {
|
||||
updatedAt: number;
|
||||
siteIds: number[];
|
||||
healthCheckIds: number[];
|
||||
resourceIds: number[];
|
||||
recipients: {
|
||||
recipientId: number;
|
||||
userId: string | null;
|
||||
roleId: string | null;
|
||||
roleId: number | null;
|
||||
email: string | null;
|
||||
}[];
|
||||
webhookActions: {
|
||||
@@ -128,6 +135,12 @@ export async function getAlertRule(
|
||||
.from(alertHealthChecks)
|
||||
.where(eq(alertHealthChecks.alertRuleId, alertRuleId));
|
||||
|
||||
// Fetch resource associations
|
||||
const resourceRows = await db
|
||||
.select()
|
||||
.from(alertResources)
|
||||
.where(eq(alertResources.alertRuleId, alertRuleId));
|
||||
|
||||
// Resolve the single email action row for this rule, then collect all
|
||||
// recipients into a flat list. The emailAction pivot row is an internal
|
||||
// implementation detail and is not surfaced to callers.
|
||||
@@ -175,26 +188,30 @@ export async function getAlertRule(
|
||||
updatedAt: rule.updatedAt,
|
||||
siteIds: siteRows.map((r) => r.siteId),
|
||||
healthCheckIds: healthCheckRows.map((r) => r.healthCheckId),
|
||||
resourceIds: resourceRows.map((r) => r.resourceId),
|
||||
recipients,
|
||||
webhookActions: webhooks.map((w) => {
|
||||
let parsedConfig: WebhookAlertConfig | null = null;
|
||||
if (w.config) {
|
||||
try {
|
||||
const serverSecret = config.getRawConfig().server.secret!;
|
||||
const decrypted = decrypt(w.config, serverSecret);
|
||||
parsedConfig = JSON.parse(decrypted) as WebhookAlertConfig;
|
||||
} catch {
|
||||
// best-effort – return null if decryption fails
|
||||
}
|
||||
}
|
||||
return {
|
||||
webhookActionId: w.webhookActionId,
|
||||
webhookUrl: w.webhookUrl,
|
||||
enabled: w.enabled,
|
||||
lastSentAt: w.lastSentAt ?? null,
|
||||
config: parsedConfig
|
||||
};
|
||||
})
|
||||
let parsedConfig: WebhookAlertConfig | null = null;
|
||||
if (w.config) {
|
||||
try {
|
||||
const serverSecret =
|
||||
config.getRawConfig().server.secret!;
|
||||
const decrypted = decrypt(w.config, serverSecret);
|
||||
parsedConfig = JSON.parse(
|
||||
decrypted
|
||||
) as WebhookAlertConfig;
|
||||
} catch {
|
||||
// best-effort – return null if decryption fails
|
||||
}
|
||||
}
|
||||
return {
|
||||
webhookActionId: w.webhookActionId,
|
||||
webhookUrl: w.webhookUrl,
|
||||
enabled: w.enabled,
|
||||
lastSentAt: w.lastSentAt ?? null,
|
||||
config: parsedConfig
|
||||
};
|
||||
})
|
||||
},
|
||||
success: true,
|
||||
error: false,
|
||||
@@ -207,4 +224,4 @@ export async function getAlertRule(
|
||||
createHttpError(HttpCode.INTERNAL_SERVER_ERROR, "An error occurred")
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -14,14 +14,14 @@
|
||||
import { Request, Response, NextFunction } from "express";
|
||||
import { z } from "zod";
|
||||
import { db } from "@server/db";
|
||||
import { alertRules, alertSites, alertHealthChecks } from "@server/db";
|
||||
import { alertRules, alertSites, alertHealthChecks, alertResources } from "@server/db";
|
||||
import response from "@server/lib/response";
|
||||
import HttpCode from "@server/types/HttpCode";
|
||||
import createHttpError from "http-errors";
|
||||
import logger from "@server/logger";
|
||||
import { fromError } from "zod-validation-error";
|
||||
import { OpenAPITags, registry } from "@server/openApi";
|
||||
import { eq, inArray, sql } from "drizzle-orm";
|
||||
import { and, eq, inArray, like, sql } from "drizzle-orm";
|
||||
|
||||
const paramsSchema = z.strictObject({
|
||||
orgId: z.string().nonempty()
|
||||
@@ -39,7 +39,18 @@ const querySchema = z.strictObject({
|
||||
.optional()
|
||||
.default("0")
|
||||
.transform(Number)
|
||||
.pipe(z.number().int().nonnegative())
|
||||
.pipe(z.number().int().nonnegative()),
|
||||
query: z.string().optional(),
|
||||
siteId: z
|
||||
.string()
|
||||
.optional()
|
||||
.transform((v) => (v !== undefined ? Number(v) : undefined))
|
||||
.pipe(z.number().int().positive().optional()),
|
||||
resourceId: z
|
||||
.string()
|
||||
.optional()
|
||||
.transform((v) => (v !== undefined ? Number(v) : undefined))
|
||||
.pipe(z.number().int().positive().optional())
|
||||
});
|
||||
|
||||
export type ListAlertRulesResponse = {
|
||||
@@ -55,6 +66,7 @@ export type ListAlertRulesResponse = {
|
||||
updatedAt: number;
|
||||
siteIds: number[];
|
||||
healthCheckIds: number[];
|
||||
resourceIds: number[];
|
||||
}[];
|
||||
pagination: {
|
||||
total: number;
|
||||
@@ -101,12 +113,69 @@ export async function listAlertRules(
|
||||
)
|
||||
);
|
||||
}
|
||||
const { limit, offset } = parsedQuery.data;
|
||||
const { limit, offset, query, siteId, resourceId } = parsedQuery.data;
|
||||
|
||||
// Resolve siteId filter → matching alertRuleIds
|
||||
let siteFilterRuleIds: number[] | null = null;
|
||||
if (siteId !== undefined) {
|
||||
const rows = await db
|
||||
.select({ alertRuleId: alertSites.alertRuleId })
|
||||
.from(alertSites)
|
||||
.where(eq(alertSites.siteId, siteId));
|
||||
siteFilterRuleIds = rows.map((r) => r.alertRuleId);
|
||||
if (siteFilterRuleIds.length === 0) {
|
||||
return response<ListAlertRulesResponse>(res, {
|
||||
data: {
|
||||
alertRules: [],
|
||||
pagination: { total: 0, limit, offset }
|
||||
},
|
||||
success: true,
|
||||
error: false,
|
||||
message: "Alert rules retrieved successfully",
|
||||
status: HttpCode.OK
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// Resolve resourceId filter → matching alertRuleIds
|
||||
let resourceFilterRuleIds: number[] | null = null;
|
||||
if (resourceId !== undefined) {
|
||||
const rows = await db
|
||||
.select({ alertRuleId: alertResources.alertRuleId })
|
||||
.from(alertResources)
|
||||
.where(eq(alertResources.resourceId, resourceId));
|
||||
resourceFilterRuleIds = rows.map((r) => r.alertRuleId);
|
||||
if (resourceFilterRuleIds.length === 0) {
|
||||
return response<ListAlertRulesResponse>(res, {
|
||||
data: {
|
||||
alertRules: [],
|
||||
pagination: { total: 0, limit, offset }
|
||||
},
|
||||
success: true,
|
||||
error: false,
|
||||
message: "Alert rules retrieved successfully",
|
||||
status: HttpCode.OK
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
const whereClause = and(
|
||||
eq(alertRules.orgId, orgId),
|
||||
query
|
||||
? like(sql`LOWER(${alertRules.name})`, `%${query.toLowerCase()}%`)
|
||||
: undefined,
|
||||
siteFilterRuleIds !== null
|
||||
? inArray(alertRules.alertRuleId, siteFilterRuleIds)
|
||||
: undefined,
|
||||
resourceFilterRuleIds !== null
|
||||
? inArray(alertRules.alertRuleId, resourceFilterRuleIds)
|
||||
: undefined
|
||||
);
|
||||
|
||||
const list = await db
|
||||
.select()
|
||||
.from(alertRules)
|
||||
.where(eq(alertRules.orgId, orgId))
|
||||
.where(whereClause)
|
||||
.orderBy(sql`${alertRules.createdAt} DESC`)
|
||||
.limit(limit)
|
||||
.offset(offset);
|
||||
@@ -114,7 +183,7 @@ export async function listAlertRules(
|
||||
const [{ count }] = await db
|
||||
.select({ count: sql<number>`count(*)` })
|
||||
.from(alertRules)
|
||||
.where(eq(alertRules.orgId, orgId));
|
||||
.where(whereClause);
|
||||
|
||||
// Batch-fetch site and health-check associations for all returned rules
|
||||
// in two queries rather than N+1 individual lookups.
|
||||
@@ -138,6 +207,14 @@ export async function listAlertRules(
|
||||
)
|
||||
: [];
|
||||
|
||||
const resourceRows =
|
||||
ruleIds.length > 0
|
||||
? await db
|
||||
.select()
|
||||
.from(alertResources)
|
||||
.where(inArray(alertResources.alertRuleId, ruleIds))
|
||||
: [];
|
||||
|
||||
// Index by alertRuleId for O(1) lookup when building the response
|
||||
const sitesByRule = new Map<number, number[]>();
|
||||
for (const row of siteRows) {
|
||||
@@ -153,6 +230,13 @@ export async function listAlertRules(
|
||||
healthChecksByRule.set(row.alertRuleId, existing);
|
||||
}
|
||||
|
||||
const resourcesByRule = new Map<number, number[]>();
|
||||
for (const row of resourceRows) {
|
||||
const existing = resourcesByRule.get(row.alertRuleId) ?? [];
|
||||
existing.push(row.resourceId);
|
||||
resourcesByRule.set(row.alertRuleId, existing);
|
||||
}
|
||||
|
||||
return response<ListAlertRulesResponse>(res, {
|
||||
data: {
|
||||
alertRules: list.map((rule) => ({
|
||||
@@ -167,7 +251,8 @@ export async function listAlertRules(
|
||||
updatedAt: rule.updatedAt,
|
||||
siteIds: sitesByRule.get(rule.alertRuleId) ?? [],
|
||||
healthCheckIds:
|
||||
healthChecksByRule.get(rule.alertRuleId) ?? []
|
||||
healthChecksByRule.get(rule.alertRuleId) ?? [],
|
||||
resourceIds: resourcesByRule.get(rule.alertRuleId) ?? []
|
||||
})),
|
||||
pagination: {
|
||||
total: count,
|
||||
|
||||
@@ -18,6 +18,7 @@ import {
|
||||
alertRules,
|
||||
alertSites,
|
||||
alertHealthChecks,
|
||||
alertResources,
|
||||
alertEmailActions,
|
||||
alertEmailRecipients,
|
||||
alertWebhookActions
|
||||
@@ -31,12 +32,8 @@ import { OpenAPITags, registry } from "@server/openApi";
|
||||
import { and, eq } from "drizzle-orm";
|
||||
import { encrypt } from "@server/lib/crypto";
|
||||
import config from "@server/lib/config";
|
||||
|
||||
const SITE_EVENT_TYPES = ["site_online", "site_offline"] as const;
|
||||
const HC_EVENT_TYPES = [
|
||||
"health_check_healthy",
|
||||
"health_check_not_healthy"
|
||||
] as const;
|
||||
import { HC_EVENT_TYPES, SITE_EVENT_TYPES, RESOURCE_EVENT_TYPES } from "./createAlertRule";
|
||||
import { invalidateAllRemoteExitNodeSessions } from "@server/private/auth/sessions/remoteExitNode";
|
||||
|
||||
const paramsSchema = z
|
||||
.object({
|
||||
@@ -57,20 +54,23 @@ const bodySchema = z
|
||||
name: z.string().nonempty().optional(),
|
||||
eventType: z
|
||||
.enum([
|
||||
"site_online",
|
||||
"site_offline",
|
||||
"health_check_healthy",
|
||||
"health_check_not_healthy"
|
||||
...HC_EVENT_TYPES,
|
||||
...SITE_EVENT_TYPES,
|
||||
...RESOURCE_EVENT_TYPES
|
||||
])
|
||||
.optional(),
|
||||
enabled: z.boolean().optional(),
|
||||
cooldownSeconds: z.number().int().nonnegative().optional(),
|
||||
// Source join tables - if provided the full set is replaced
|
||||
siteIds: z.array(z.number().int().positive()).optional(),
|
||||
allSites: z.boolean().optional(),
|
||||
healthCheckIds: z.array(z.number().int().positive()).optional(),
|
||||
allHealthChecks: z.boolean().optional(),
|
||||
resourceIds: z.array(z.number().int().positive()).optional(),
|
||||
allResources: z.boolean().optional(),
|
||||
// Recipient arrays - if any are provided the full recipient set is replaced
|
||||
userIds: z.array(z.string().nonempty()).optional(),
|
||||
roleIds: z.array(z.string().nonempty()).optional(),
|
||||
roleIds: z.array(z.number()).optional(),
|
||||
emails: z.array(z.string().email()).optional(),
|
||||
// Webhook actions - if provided the full webhook set is replaced
|
||||
webhookActions: z.array(webhookActionSchema).optional()
|
||||
@@ -84,6 +84,33 @@ const bodySchema = z
|
||||
const isHcEvent = (HC_EVENT_TYPES as readonly string[]).includes(
|
||||
val.eventType
|
||||
);
|
||||
const isResourceEvent = (RESOURCE_EVENT_TYPES as readonly string[]).includes(
|
||||
val.eventType
|
||||
);
|
||||
|
||||
if (isSiteEvent && val.siteIds !== undefined && val.siteIds.length === 0 && !val.allSites) {
|
||||
ctx.addIssue({
|
||||
code: z.ZodIssueCode.custom,
|
||||
message: "At least one siteId is required for site event types when allSites is false",
|
||||
path: ["siteIds"]
|
||||
});
|
||||
}
|
||||
|
||||
if (isHcEvent && val.healthCheckIds !== undefined && val.healthCheckIds.length === 0 && !val.allHealthChecks) {
|
||||
ctx.addIssue({
|
||||
code: z.ZodIssueCode.custom,
|
||||
message: "At least one healthCheckId is required for health check event types when allHealthChecks is false",
|
||||
path: ["healthCheckIds"]
|
||||
});
|
||||
}
|
||||
|
||||
if (isResourceEvent && val.resourceIds !== undefined && val.resourceIds.length === 0 && !val.allResources) {
|
||||
ctx.addIssue({
|
||||
code: z.ZodIssueCode.custom,
|
||||
message: "At least one resourceId is required for resource event types when allResources is false",
|
||||
path: ["resourceIds"]
|
||||
});
|
||||
}
|
||||
|
||||
if (isSiteEvent && val.healthCheckIds !== undefined && val.healthCheckIds.length > 0) {
|
||||
ctx.addIssue({
|
||||
@@ -100,6 +127,22 @@ const bodySchema = z
|
||||
path: ["siteIds"]
|
||||
});
|
||||
}
|
||||
|
||||
if (isResourceEvent && val.siteIds !== undefined && val.siteIds.length > 0) {
|
||||
ctx.addIssue({
|
||||
code: z.ZodIssueCode.custom,
|
||||
message: "siteIds must not be set for resource event types",
|
||||
path: ["siteIds"]
|
||||
});
|
||||
}
|
||||
|
||||
if (isResourceEvent && val.healthCheckIds !== undefined && val.healthCheckIds.length > 0) {
|
||||
ctx.addIssue({
|
||||
code: z.ZodIssueCode.custom,
|
||||
message: "healthCheckIds must not be set for resource event types",
|
||||
path: ["healthCheckIds"]
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
export type UpdateAlertRuleResponse = {
|
||||
@@ -174,7 +217,11 @@ export async function updateAlertRule(
|
||||
enabled,
|
||||
cooldownSeconds,
|
||||
siteIds,
|
||||
allSites,
|
||||
healthCheckIds,
|
||||
allHealthChecks,
|
||||
resourceIds,
|
||||
allResources,
|
||||
userIds,
|
||||
roleIds,
|
||||
emails,
|
||||
@@ -189,8 +236,10 @@ export async function updateAlertRule(
|
||||
if (name !== undefined) updateData.name = name;
|
||||
if (eventType !== undefined) updateData.eventType = eventType;
|
||||
if (enabled !== undefined) updateData.enabled = enabled;
|
||||
if (cooldownSeconds !== undefined)
|
||||
updateData.cooldownSeconds = cooldownSeconds;
|
||||
if (cooldownSeconds !== undefined) updateData.cooldownSeconds = cooldownSeconds;
|
||||
if (allSites !== undefined) updateData.allSites = allSites;
|
||||
if (allHealthChecks !== undefined) updateData.allHealthChecks = allHealthChecks;
|
||||
if (allResources !== undefined) updateData.allResources = allResources;
|
||||
|
||||
await db
|
||||
.update(alertRules)
|
||||
@@ -203,12 +252,14 @@ export async function updateAlertRule(
|
||||
);
|
||||
|
||||
// --- Full-replace site associations if siteIds was provided ---
|
||||
if (siteIds !== undefined) {
|
||||
if (siteIds !== undefined || allSites !== undefined) {
|
||||
await db
|
||||
.delete(alertSites)
|
||||
.where(eq(alertSites.alertRuleId, alertRuleId));
|
||||
|
||||
if (siteIds.length > 0) {
|
||||
// Only insert junction rows when allSites is not true
|
||||
const effectiveAllSites = allSites ?? false;
|
||||
if (!effectiveAllSites && siteIds !== undefined && siteIds.length > 0) {
|
||||
await db.insert(alertSites).values(
|
||||
siteIds.map((siteId) => ({
|
||||
alertRuleId,
|
||||
@@ -219,12 +270,13 @@ export async function updateAlertRule(
|
||||
}
|
||||
|
||||
// --- Full-replace health check associations if healthCheckIds was provided ---
|
||||
if (healthCheckIds !== undefined) {
|
||||
if (healthCheckIds !== undefined || allHealthChecks !== undefined) {
|
||||
await db
|
||||
.delete(alertHealthChecks)
|
||||
.where(eq(alertHealthChecks.alertRuleId, alertRuleId));
|
||||
|
||||
if (healthCheckIds.length > 0) {
|
||||
const effectiveAllHealthChecks = allHealthChecks ?? false;
|
||||
if (!effectiveAllHealthChecks && healthCheckIds !== undefined && healthCheckIds.length > 0) {
|
||||
await db.insert(alertHealthChecks).values(
|
||||
healthCheckIds.map((healthCheckId) => ({
|
||||
alertRuleId,
|
||||
@@ -234,6 +286,23 @@ export async function updateAlertRule(
|
||||
}
|
||||
}
|
||||
|
||||
// --- Full-replace resource associations if resourceIds was provided ---
|
||||
if (resourceIds !== undefined || allResources !== undefined) {
|
||||
await db
|
||||
.delete(alertResources)
|
||||
.where(eq(alertResources.alertRuleId, alertRuleId));
|
||||
|
||||
const effectiveAllResources = allResources ?? false;
|
||||
if (!effectiveAllResources && resourceIds !== undefined && resourceIds.length > 0) {
|
||||
await db.insert(alertResources).values(
|
||||
resourceIds.map((resourceId) => ({
|
||||
alertRuleId,
|
||||
resourceId
|
||||
}))
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// --- Full-replace recipients if any recipient array was provided ---
|
||||
const recipientsProvided =
|
||||
userIds !== undefined ||
|
||||
@@ -244,7 +313,7 @@ export async function updateAlertRule(
|
||||
const newRecipients = [
|
||||
...(userIds ?? []).map((userId) => ({
|
||||
userId,
|
||||
roleId: null as string | null,
|
||||
roleId: null as number | null,
|
||||
email: null as string | null
|
||||
})),
|
||||
...(roleIds ?? []).map((roleId) => ({
|
||||
@@ -254,7 +323,7 @@ export async function updateAlertRule(
|
||||
})),
|
||||
...(emails ?? []).map((email) => ({
|
||||
userId: null as string | null,
|
||||
roleId: null as string | null,
|
||||
roleId: null as number | null,
|
||||
email
|
||||
}))
|
||||
];
|
||||
@@ -331,4 +400,4 @@ export async function updateAlertRule(
|
||||
createHttpError(HttpCode.INTERNAL_SERVER_ERROR, "An error occurred")
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -42,7 +42,9 @@ import {
|
||||
verifyRoleAccess,
|
||||
verifyUserAccess,
|
||||
verifyUserCanSetUserOrgRoles,
|
||||
verifySiteProvisioningKeyAccess
|
||||
verifySiteProvisioningKeyAccess,
|
||||
verifyIsLoggedInUser,
|
||||
verifyAdmin
|
||||
} from "@server/middlewares";
|
||||
import { ActionsEnum } from "@server/auth/actions";
|
||||
import {
|
||||
@@ -89,6 +91,7 @@ authenticated.put(
|
||||
"/org/:orgId/idp/oidc",
|
||||
verifyValidLicense,
|
||||
verifyValidSubscription(tierMatrix.orgOidc),
|
||||
orgIdp.requireOrgIdentityProviderMode,
|
||||
verifyOrgAccess,
|
||||
verifyLimits,
|
||||
verifyUserHasAction(ActionsEnum.createIdp),
|
||||
@@ -96,10 +99,23 @@ authenticated.put(
|
||||
orgIdp.createOrgOidcIdp
|
||||
);
|
||||
|
||||
authenticated.post(
|
||||
"/org/:orgId/idp/:idpId/import",
|
||||
verifyValidLicense,
|
||||
verifyValidSubscription(tierMatrix.orgOidc),
|
||||
orgIdp.requireOrgIdentityProviderMode,
|
||||
verifyOrgAccess,
|
||||
verifyLimits,
|
||||
verifyAdmin,
|
||||
logActionAudit(ActionsEnum.createIdp),
|
||||
orgIdp.importOrgIdp
|
||||
);
|
||||
|
||||
authenticated.post(
|
||||
"/org/:orgId/idp/:idpId/oidc",
|
||||
verifyValidLicense,
|
||||
verifyValidSubscription(tierMatrix.orgOidc),
|
||||
orgIdp.requireOrgIdentityProviderMode,
|
||||
verifyOrgAccess,
|
||||
verifyIdpAccess,
|
||||
verifyLimits,
|
||||
@@ -111,6 +127,7 @@ authenticated.post(
|
||||
authenticated.delete(
|
||||
"/org/:orgId/idp/:idpId",
|
||||
verifyValidLicense,
|
||||
orgIdp.requireOrgIdentityProviderMode,
|
||||
verifyOrgAccess,
|
||||
verifyIdpAccess,
|
||||
verifyUserHasAction(ActionsEnum.deleteIdp),
|
||||
@@ -118,6 +135,17 @@ authenticated.delete(
|
||||
orgIdp.deleteOrgIdp
|
||||
);
|
||||
|
||||
authenticated.delete(
|
||||
"/org/:orgId/idp/:idpId/association",
|
||||
verifyValidLicense,
|
||||
orgIdp.requireOrgIdentityProviderMode,
|
||||
verifyOrgAccess,
|
||||
verifyIdpAccess,
|
||||
verifyUserHasAction(ActionsEnum.deleteIdp),
|
||||
logActionAudit(ActionsEnum.deleteIdp),
|
||||
orgIdp.unassociateOrgIdp
|
||||
);
|
||||
|
||||
authenticated.get(
|
||||
"/org/:orgId/idp/:idpId",
|
||||
verifyValidLicense,
|
||||
@@ -127,16 +155,14 @@ authenticated.get(
|
||||
orgIdp.getOrgIdp
|
||||
);
|
||||
|
||||
authenticated.get(
|
||||
"/org/:orgId/idp",
|
||||
verifyValidLicense,
|
||||
verifyOrgAccess,
|
||||
verifyUserHasAction(ActionsEnum.listIdps),
|
||||
orgIdp.listOrgIdps
|
||||
);
|
||||
|
||||
authenticated.get("/org/:orgId/idp", orgIdp.listOrgIdps); // anyone can see this; it's just a list of idp names and ids
|
||||
|
||||
authenticated.get(
|
||||
"/user/:userId/admin-org-idps",
|
||||
verifyIsLoggedInUser,
|
||||
orgIdp.listUserAdminOrgIdps
|
||||
);
|
||||
|
||||
authenticated.get(
|
||||
"/org/:orgId/certificate/:domainId/:domain",
|
||||
verifyValidLicense,
|
||||
|
||||
@@ -13,13 +13,15 @@
|
||||
|
||||
import { Request, Response, NextFunction } from "express";
|
||||
import { z } from "zod";
|
||||
import { db, targetHealthCheck } from "@server/db";
|
||||
import { db, targetHealthCheck, newts, sites } from "@server/db";
|
||||
import { eq } from "drizzle-orm";
|
||||
import response from "@server/lib/response";
|
||||
import HttpCode from "@server/types/HttpCode";
|
||||
import createHttpError from "http-errors";
|
||||
import logger from "@server/logger";
|
||||
import { fromError } from "zod-validation-error";
|
||||
import { OpenAPITags, registry } from "@server/openApi";
|
||||
import { addStandaloneHealthCheck } from "@server/routers/newt/targets";
|
||||
|
||||
const paramsSchema = z.strictObject({
|
||||
orgId: z.string().nonempty()
|
||||
@@ -27,6 +29,7 @@ const paramsSchema = z.strictObject({
|
||||
|
||||
const bodySchema = z.strictObject({
|
||||
name: z.string().nonempty(),
|
||||
siteId: z.number().int().positive(),
|
||||
hcEnabled: z.boolean().default(false),
|
||||
hcMode: z.string().default("http"),
|
||||
hcHostname: z.string().optional(),
|
||||
@@ -97,6 +100,7 @@ export async function createHealthCheck(
|
||||
|
||||
const {
|
||||
name,
|
||||
siteId,
|
||||
hcEnabled,
|
||||
hcMode,
|
||||
hcHostname,
|
||||
@@ -120,6 +124,7 @@ export async function createHealthCheck(
|
||||
.values({
|
||||
targetId: null,
|
||||
orgId,
|
||||
siteId,
|
||||
name,
|
||||
hcEnabled,
|
||||
hcMode,
|
||||
@@ -140,6 +145,31 @@ export async function createHealthCheck(
|
||||
})
|
||||
.returning();
|
||||
|
||||
// Push health check to newt if the site is a newt site
|
||||
if (siteId) {
|
||||
const [site] = await db
|
||||
.select()
|
||||
.from(sites)
|
||||
.where(eq(sites.siteId, siteId))
|
||||
.limit(1);
|
||||
|
||||
if (site && site.type === "newt") {
|
||||
const [newt] = await db
|
||||
.select()
|
||||
.from(newts)
|
||||
.where(eq(newts.siteId, site.siteId))
|
||||
.limit(1);
|
||||
|
||||
if (newt) {
|
||||
await addStandaloneHealthCheck(
|
||||
newt.newtId,
|
||||
record,
|
||||
newt.version
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return response<CreateHealthCheckResponse>(res, {
|
||||
data: {
|
||||
targetHealthCheckId: record.targetHealthCheckId
|
||||
|
||||
@@ -13,7 +13,7 @@
|
||||
|
||||
import { Request, Response, NextFunction } from "express";
|
||||
import { z } from "zod";
|
||||
import { db, targetHealthCheck } from "@server/db";
|
||||
import { db, targetHealthCheck, newts, sites } from "@server/db";
|
||||
import response from "@server/lib/response";
|
||||
import HttpCode from "@server/types/HttpCode";
|
||||
import createHttpError from "http-errors";
|
||||
@@ -21,6 +21,7 @@ import logger from "@server/logger";
|
||||
import { fromError } from "zod-validation-error";
|
||||
import { OpenAPITags, registry } from "@server/openApi";
|
||||
import { and, eq, isNull } from "drizzle-orm";
|
||||
import { removeStandaloneHealthCheck } from "@server/routers/newt/targets";
|
||||
|
||||
const paramsSchema = z
|
||||
.object({
|
||||
@@ -91,6 +92,21 @@ export async function deleteHealthCheck(
|
||||
)
|
||||
);
|
||||
|
||||
// Remove health check from newt if the site is a newt site
|
||||
const [newt] = await db
|
||||
.select()
|
||||
.from(newts)
|
||||
.where(eq(newts.siteId, existing.siteId))
|
||||
.limit(1);
|
||||
|
||||
if (newt) {
|
||||
await removeStandaloneHealthCheck(
|
||||
newt.newtId,
|
||||
healthCheckId,
|
||||
newt.version
|
||||
);
|
||||
}
|
||||
|
||||
return response<null>(res, {
|
||||
data: null,
|
||||
success: true,
|
||||
|
||||
@@ -43,7 +43,7 @@ export async function getHealthCheckStatusHistory(
|
||||
}
|
||||
|
||||
const entityType = "healthCheck";
|
||||
const entityId = parsedParams.data.healthCheckId
|
||||
const entityId = parsedParams.data.healthCheckId;
|
||||
const { days } = parsedQuery.data;
|
||||
|
||||
const nowSec = Math.floor(Date.now() / 1000);
|
||||
|
||||
@@ -11,13 +11,13 @@
|
||||
* This file is not licensed under the AGPLv3.
|
||||
*/
|
||||
|
||||
import { db, targetHealthCheck, targets, resources } from "@server/db";
|
||||
import { db, targetHealthCheck, targets, resources, sites } from "@server/db";
|
||||
import response from "@server/lib/response";
|
||||
import HttpCode from "@server/types/HttpCode";
|
||||
import createHttpError from "http-errors";
|
||||
import logger from "@server/logger";
|
||||
import { OpenAPITags, registry } from "@server/openApi";
|
||||
import { and, eq, isNull, sql } from "drizzle-orm";
|
||||
import { and, eq, like, sql } from "drizzle-orm";
|
||||
import { NextFunction, Request, Response } from "express";
|
||||
import { z } from "zod";
|
||||
import { fromError } from "zod-validation-error";
|
||||
@@ -39,7 +39,8 @@ const querySchema = z.object({
|
||||
.optional()
|
||||
.default("0")
|
||||
.transform(Number)
|
||||
.pipe(z.int().nonnegative())
|
||||
.pipe(z.int().nonnegative()),
|
||||
query: z.string().optional()
|
||||
});
|
||||
|
||||
registry.registerPath({
|
||||
@@ -80,16 +81,25 @@ export async function listHealthChecks(
|
||||
)
|
||||
);
|
||||
}
|
||||
const { limit, offset } = parsedQuery.data;
|
||||
const { limit, offset, query } = parsedQuery.data;
|
||||
|
||||
const whereClause = and(
|
||||
eq(targetHealthCheck.orgId, orgId),
|
||||
query
|
||||
? like(
|
||||
sql`LOWER(${targetHealthCheck.name})`,
|
||||
`%${query.toLowerCase()}%`
|
||||
)
|
||||
: undefined
|
||||
);
|
||||
|
||||
const list = await db
|
||||
.select({
|
||||
targetHealthCheckId: targetHealthCheck.targetHealthCheckId,
|
||||
name: targetHealthCheck.name,
|
||||
siteId: targetHealthCheck.siteId,
|
||||
siteName: sites.name,
|
||||
siteNiceId: sites.niceId,
|
||||
hcEnabled: targetHealthCheck.hcEnabled,
|
||||
hcHealth: targetHealthCheck.hcHealth,
|
||||
hcMode: targetHealthCheck.hcMode,
|
||||
@@ -114,6 +124,7 @@ export async function listHealthChecks(
|
||||
.from(targetHealthCheck)
|
||||
.leftJoin(targets, eq(targetHealthCheck.targetId, targets.targetId))
|
||||
.leftJoin(resources, eq(targets.resourceId, resources.resourceId))
|
||||
.leftJoin(sites, eq(targetHealthCheck.siteId, sites.siteId))
|
||||
.where(whereClause)
|
||||
.orderBy(sql`${targetHealthCheck.targetHealthCheckId} DESC`)
|
||||
.limit(limit)
|
||||
@@ -129,6 +140,9 @@ export async function listHealthChecks(
|
||||
healthChecks: list.map((row) => ({
|
||||
targetHealthCheckId: row.targetHealthCheckId,
|
||||
name: row.name ?? "",
|
||||
siteId: row.siteId ?? null,
|
||||
siteName: row.siteName ?? null,
|
||||
siteNiceId: row.siteNiceId ?? null,
|
||||
hcEnabled: row.hcEnabled,
|
||||
hcHealth: (row.hcHealth ?? "unknown") as
|
||||
| "unknown"
|
||||
|
||||
@@ -13,7 +13,7 @@
|
||||
|
||||
import { Request, Response, NextFunction } from "express";
|
||||
import { z } from "zod";
|
||||
import { db, targetHealthCheck } from "@server/db";
|
||||
import { db, targetHealthCheck, newts, sites } from "@server/db";
|
||||
import response from "@server/lib/response";
|
||||
import HttpCode from "@server/types/HttpCode";
|
||||
import createHttpError from "http-errors";
|
||||
@@ -21,6 +21,7 @@ import logger from "@server/logger";
|
||||
import { fromError } from "zod-validation-error";
|
||||
import { OpenAPITags, registry } from "@server/openApi";
|
||||
import { and, eq, isNull } from "drizzle-orm";
|
||||
import { addStandaloneHealthCheck } from "@server/routers/newt/targets";
|
||||
|
||||
const paramsSchema = z
|
||||
.object({
|
||||
@@ -34,6 +35,7 @@ const paramsSchema = z
|
||||
|
||||
const bodySchema = z.strictObject({
|
||||
name: z.string().nonempty().optional(),
|
||||
siteId: z.number().int().positive().optional(),
|
||||
hcEnabled: z.boolean().optional(),
|
||||
hcMode: z.string().optional(),
|
||||
hcHostname: z.string().optional(),
|
||||
@@ -55,6 +57,7 @@ const bodySchema = z.strictObject({
|
||||
export type UpdateHealthCheckResponse = {
|
||||
targetHealthCheckId: number;
|
||||
name: string | null;
|
||||
siteId: number | null;
|
||||
hcEnabled: boolean;
|
||||
hcHealth: string | null;
|
||||
hcMode: string | null;
|
||||
@@ -125,10 +128,7 @@ export async function updateHealthCheck(
|
||||
.from(targetHealthCheck)
|
||||
.where(
|
||||
and(
|
||||
eq(
|
||||
targetHealthCheck.targetHealthCheckId,
|
||||
healthCheckId
|
||||
),
|
||||
eq(targetHealthCheck.targetHealthCheckId, healthCheckId),
|
||||
eq(targetHealthCheck.orgId, orgId),
|
||||
isNull(targetHealthCheck.targetId)
|
||||
)
|
||||
@@ -145,6 +145,7 @@ export async function updateHealthCheck(
|
||||
|
||||
const {
|
||||
name,
|
||||
siteId,
|
||||
hcEnabled,
|
||||
hcMode,
|
||||
hcHostname,
|
||||
@@ -166,6 +167,7 @@ export async function updateHealthCheck(
|
||||
const updateData: Record<string, unknown> = {};
|
||||
|
||||
if (name !== undefined) updateData.name = name;
|
||||
if (siteId !== undefined) updateData.siteId = siteId;
|
||||
if (hcEnabled !== undefined) updateData.hcEnabled = hcEnabled;
|
||||
if (hcMode !== undefined) updateData.hcMode = hcMode;
|
||||
if (hcHostname !== undefined) updateData.hcHostname = hcHostname;
|
||||
@@ -193,19 +195,28 @@ export async function updateHealthCheck(
|
||||
.set(updateData)
|
||||
.where(
|
||||
and(
|
||||
eq(
|
||||
targetHealthCheck.targetHealthCheckId,
|
||||
healthCheckId
|
||||
),
|
||||
eq(targetHealthCheck.targetHealthCheckId, healthCheckId),
|
||||
eq(targetHealthCheck.orgId, orgId),
|
||||
isNull(targetHealthCheck.targetId)
|
||||
)
|
||||
)
|
||||
.returning();
|
||||
|
||||
// Push updated health check to newt if the site is a newt site
|
||||
const [newt] = await db
|
||||
.select()
|
||||
.from(newts)
|
||||
.where(eq(newts.siteId, updated.siteId))
|
||||
.limit(1);
|
||||
|
||||
if (newt) {
|
||||
await addStandaloneHealthCheck(newt.newtId, updated, newt.version);
|
||||
}
|
||||
|
||||
return response<UpdateHealthCheckResponse>(res, {
|
||||
data: {
|
||||
targetHealthCheckId: updated.targetHealthCheckId,
|
||||
siteId: updated.siteId ?? null,
|
||||
name: updated.name ?? null,
|
||||
hcEnabled: updated.hcEnabled,
|
||||
hcHealth: updated.hcHealth ?? null,
|
||||
|
||||
@@ -14,6 +14,7 @@
|
||||
import * as orgIdp from "#private/routers/orgIdp";
|
||||
import * as org from "#private/routers/org";
|
||||
import * as logs from "#private/routers/auditLogs";
|
||||
import * as alertEvents from "#private/routers/alertEvents";
|
||||
|
||||
import {
|
||||
verifyApiKeyHasAction,
|
||||
@@ -40,6 +41,27 @@ import { tierMatrix } from "@server/lib/billing/tierMatrix";
|
||||
export const unauthenticated = ua;
|
||||
export const authenticated = a;
|
||||
|
||||
authenticated.post(
|
||||
"/org/:orgId/site/:siteId/trigger-alert",
|
||||
verifyApiKeyIsRoot,
|
||||
verifyApiKeyHasAction(ActionsEnum.triggerSiteAlert),
|
||||
alertEvents.triggerSiteAlert
|
||||
);
|
||||
|
||||
authenticated.post(
|
||||
"/org/:orgId/resource/:resourceId/trigger-alert",
|
||||
verifyApiKeyIsRoot,
|
||||
verifyApiKeyHasAction(ActionsEnum.triggerResourceAlert),
|
||||
alertEvents.triggerResourceAlert
|
||||
);
|
||||
|
||||
authenticated.post(
|
||||
"/org/:orgId/health-check/:healthCheckId/trigger-alert",
|
||||
verifyApiKeyIsRoot,
|
||||
verifyApiKeyHasAction(ActionsEnum.triggerHealthCheckAlert),
|
||||
alertEvents.triggerHealthCheckAlert
|
||||
);
|
||||
|
||||
authenticated.post(
|
||||
`/org/:orgId/send-usage-notification`,
|
||||
verifyApiKeyIsRoot, // We are the only ones who can use root key so its fine
|
||||
|
||||
@@ -27,7 +27,6 @@ import config from "@server/lib/config";
|
||||
import { CreateOrgIdpResponse } from "@server/routers/orgIdp/types";
|
||||
import { isSubscribed } from "#private/lib/isSubscribed";
|
||||
import { tierMatrix } from "@server/lib/billing/tierMatrix";
|
||||
import privateConfig from "#private/lib/config";
|
||||
import { build } from "@server/build";
|
||||
|
||||
const paramsSchema = z.strictObject({ orgId: z.string().nonempty() });
|
||||
@@ -45,6 +44,7 @@ const bodySchema = z.strictObject({
|
||||
autoProvision: z.boolean().optional(),
|
||||
variant: z.enum(["oidc", "google", "azure"]).optional().default("oidc"),
|
||||
roleMapping: z.string().optional(),
|
||||
orgMapping: z.string().nullish(),
|
||||
tags: z.string().optional()
|
||||
});
|
||||
|
||||
@@ -94,18 +94,6 @@ export async function createOrgOidcIdp(
|
||||
);
|
||||
}
|
||||
|
||||
if (
|
||||
privateConfig.getRawPrivateConfig().app.identity_provider_mode !==
|
||||
"org"
|
||||
) {
|
||||
return next(
|
||||
createHttpError(
|
||||
HttpCode.BAD_REQUEST,
|
||||
"Organization-specific IdP creation is not allowed in the current identity provider mode. Set app.identity_provider_mode to 'org' in the private configuration to enable this feature."
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
const {
|
||||
clientId,
|
||||
clientSecret,
|
||||
@@ -118,6 +106,7 @@ export async function createOrgOidcIdp(
|
||||
name,
|
||||
variant,
|
||||
roleMapping,
|
||||
orgMapping: orgMappingBody,
|
||||
tags
|
||||
} = parsedBody.data;
|
||||
|
||||
@@ -169,7 +158,7 @@ export async function createOrgOidcIdp(
|
||||
idpId: idpRes.idpId,
|
||||
orgId: orgId,
|
||||
roleMapping: roleMapping || null,
|
||||
orgMapping: `'${orgId}'`
|
||||
orgMapping: orgMappingBody
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -22,7 +22,6 @@ import { fromError } from "zod-validation-error";
|
||||
import { idp, idpOidcConfig, idpOrg } from "@server/db";
|
||||
import { eq } from "drizzle-orm";
|
||||
import { OpenAPITags, registry } from "@server/openApi";
|
||||
import privateConfig from "#private/lib/config";
|
||||
|
||||
const paramsSchema = z
|
||||
.object({
|
||||
@@ -60,18 +59,6 @@ export async function deleteOrgIdp(
|
||||
|
||||
const { idpId } = parsedParams.data;
|
||||
|
||||
if (
|
||||
privateConfig.getRawPrivateConfig().app.identity_provider_mode !==
|
||||
"org"
|
||||
) {
|
||||
return next(
|
||||
createHttpError(
|
||||
HttpCode.BAD_REQUEST,
|
||||
"Organization-specific IdP creation is not allowed in the current identity provider mode. Set app.identity_provider_mode to 'org' in the private configuration to enable this feature."
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
// Check if IDP exists
|
||||
const [existingIdp] = await db
|
||||
.select()
|
||||
|
||||
211
server/private/routers/orgIdp/importOrgIdp.ts
Normal file
211
server/private/routers/orgIdp/importOrgIdp.ts
Normal file
@@ -0,0 +1,211 @@
|
||||
/*
|
||||
* This file is part of a proprietary work.
|
||||
*
|
||||
* Copyright (c) 2025-2026 Fossorial, Inc.
|
||||
* All rights reserved.
|
||||
*
|
||||
* This file is licensed under the Fossorial Commercial License.
|
||||
* You may not use this file except in compliance with the License.
|
||||
* Unauthorized use, copying, modification, or distribution is strictly prohibited.
|
||||
*
|
||||
* This file is not licensed under the AGPLv3.
|
||||
*/
|
||||
|
||||
import { Request, Response, NextFunction } from "express";
|
||||
import { z } from "zod";
|
||||
import { db } from "@server/db";
|
||||
import response from "@server/lib/response";
|
||||
import HttpCode from "@server/types/HttpCode";
|
||||
import createHttpError from "http-errors";
|
||||
import logger from "@server/logger";
|
||||
import { fromError } from "zod-validation-error";
|
||||
import { idp, idpOrg, orgs, roles, userOrgs } from "@server/db";
|
||||
import { and, eq, inArray } from "drizzle-orm";
|
||||
import { CreateOrgIdpResponse } from "@server/routers/orgIdp/types";
|
||||
import { generateOidcRedirectUrl } from "@server/lib/idp/generateRedirectUrl";
|
||||
import { checkOrgAccessPolicy } from "#private/lib/checkOrgAccessPolicy";
|
||||
import { getUserOrgRoleIds } from "@server/lib/userOrgRoles";
|
||||
|
||||
const paramsSchema = z.strictObject({
|
||||
orgId: z.string().nonempty(),
|
||||
idpId: z.coerce.number<number>().int().positive()
|
||||
});
|
||||
|
||||
const bodySchema = z.strictObject({
|
||||
sourceOrgId: z.string().nonempty()
|
||||
});
|
||||
|
||||
async function userIsOrgAdmin(
|
||||
userId: string,
|
||||
orgId: string,
|
||||
session: Request["session"]
|
||||
): Promise<boolean> {
|
||||
const [userOrgRow] = await db
|
||||
.select()
|
||||
.from(userOrgs)
|
||||
.where(and(eq(userOrgs.userId, userId), eq(userOrgs.orgId, orgId)))
|
||||
.limit(1);
|
||||
|
||||
if (!userOrgRow) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const policyCheck = await checkOrgAccessPolicy({
|
||||
orgId,
|
||||
userId,
|
||||
session
|
||||
});
|
||||
if (!policyCheck.allowed || policyCheck.error) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const roleIds = await getUserOrgRoleIds(userId, orgId);
|
||||
if (roleIds.length === 0) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const [adminRole] = await db
|
||||
.select()
|
||||
.from(roles)
|
||||
.where(and(inArray(roles.roleId, roleIds), eq(roles.isAdmin, true)))
|
||||
.limit(1);
|
||||
|
||||
return !!adminRole;
|
||||
}
|
||||
|
||||
export async function importOrgIdp(
|
||||
req: Request,
|
||||
res: Response,
|
||||
next: NextFunction
|
||||
): Promise<any> {
|
||||
try {
|
||||
const parsedParams = paramsSchema.safeParse(req.params);
|
||||
if (!parsedParams.success) {
|
||||
return next(
|
||||
createHttpError(
|
||||
HttpCode.BAD_REQUEST,
|
||||
fromError(parsedParams.error).toString()
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
const { orgId: targetOrgId, idpId } = parsedParams.data;
|
||||
|
||||
const parsedBody = bodySchema.safeParse(req.body);
|
||||
if (!parsedBody.success) {
|
||||
return next(
|
||||
createHttpError(
|
||||
HttpCode.BAD_REQUEST,
|
||||
fromError(parsedBody.error).toString()
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
const { sourceOrgId } = parsedBody.data;
|
||||
|
||||
if (sourceOrgId === targetOrgId) {
|
||||
return next(
|
||||
createHttpError(
|
||||
HttpCode.BAD_REQUEST,
|
||||
"Source and target organization must be different"
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
const userId = req.user!.userId;
|
||||
|
||||
const sourceLinked = await db
|
||||
.select()
|
||||
.from(idpOrg)
|
||||
.where(and(eq(idpOrg.idpId, idpId), eq(idpOrg.orgId, sourceOrgId)))
|
||||
.limit(1);
|
||||
|
||||
if (sourceLinked.length === 0) {
|
||||
return next(
|
||||
createHttpError(
|
||||
HttpCode.NOT_FOUND,
|
||||
"IdP not found for the source organization"
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
const sourceAdmin = await userIsOrgAdmin(
|
||||
userId,
|
||||
sourceOrgId,
|
||||
req.session
|
||||
);
|
||||
if (!sourceAdmin) {
|
||||
return next(
|
||||
createHttpError(
|
||||
HttpCode.FORBIDDEN,
|
||||
"You must be an organization admin in the source organization where this IdP is linked"
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
const [targetOrg] = await db
|
||||
.select({ orgId: orgs.orgId })
|
||||
.from(orgs)
|
||||
.where(eq(orgs.orgId, targetOrgId))
|
||||
.limit(1);
|
||||
|
||||
if (!targetOrg) {
|
||||
return next(
|
||||
createHttpError(
|
||||
HttpCode.NOT_FOUND,
|
||||
"Target organization not found"
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
const [existingIdp] = await db
|
||||
.select()
|
||||
.from(idp)
|
||||
.where(eq(idp.idpId, idpId))
|
||||
.limit(1);
|
||||
|
||||
if (!existingIdp) {
|
||||
return next(createHttpError(HttpCode.NOT_FOUND, "IdP not found"));
|
||||
}
|
||||
|
||||
const alreadyTarget = await db
|
||||
.select()
|
||||
.from(idpOrg)
|
||||
.where(and(eq(idpOrg.idpId, idpId), eq(idpOrg.orgId, targetOrgId)))
|
||||
.limit(1);
|
||||
|
||||
if (alreadyTarget.length > 0) {
|
||||
return next(
|
||||
createHttpError(
|
||||
HttpCode.CONFLICT,
|
||||
"This IdP is already linked to the target organization"
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
await db.insert(idpOrg).values({
|
||||
idpId,
|
||||
orgId: targetOrgId,
|
||||
roleMapping: null,
|
||||
orgMapping: null
|
||||
});
|
||||
|
||||
const redirectUrl = await generateOidcRedirectUrl(idpId, targetOrgId);
|
||||
|
||||
return response<CreateOrgIdpResponse>(res, {
|
||||
data: {
|
||||
idpId,
|
||||
redirectUrl
|
||||
},
|
||||
success: true,
|
||||
error: false,
|
||||
message: "Org IdP imported successfully",
|
||||
status: HttpCode.CREATED
|
||||
});
|
||||
} catch (error) {
|
||||
logger.error(error);
|
||||
return next(
|
||||
createHttpError(HttpCode.INTERNAL_SERVER_ERROR, "An error occurred")
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -12,7 +12,11 @@
|
||||
*/
|
||||
|
||||
export * from "./createOrgOidcIdp";
|
||||
export * from "./importOrgIdp";
|
||||
export * from "./getOrgIdp";
|
||||
export * from "./listOrgIdps";
|
||||
export * from "./listUserAdminOrgIdps";
|
||||
export * from "./updateOrgOidcIdp";
|
||||
export * from "./deleteOrgIdp";
|
||||
export * from "./unassociateOrgIdp";
|
||||
export * from "./requireOrgIdentityProviderMode";
|
||||
|
||||
160
server/private/routers/orgIdp/listUserAdminOrgIdps.ts
Normal file
160
server/private/routers/orgIdp/listUserAdminOrgIdps.ts
Normal file
@@ -0,0 +1,160 @@
|
||||
/*
|
||||
* This file is part of a proprietary work.
|
||||
*
|
||||
* Copyright (c) 2025-2026 Fossorial, Inc.
|
||||
* All rights reserved.
|
||||
*
|
||||
* This file is licensed under the Fossorial Commercial License.
|
||||
* You may not use this file except in compliance with the License.
|
||||
* Unauthorized use, copying, modification, or distribution is strictly prohibited.
|
||||
*
|
||||
* This file is not licensed under the AGPLv3.
|
||||
*/
|
||||
|
||||
import { Request, Response, NextFunction } from "express";
|
||||
import { z } from "zod";
|
||||
import { db, idpOidcConfig } from "@server/db";
|
||||
import { idp, idpOrg, orgs, roles, userOrgRoles } from "@server/db";
|
||||
import response from "@server/lib/response";
|
||||
import HttpCode from "@server/types/HttpCode";
|
||||
import createHttpError from "http-errors";
|
||||
import { and, eq, inArray, sql } from "drizzle-orm";
|
||||
import logger from "@server/logger";
|
||||
import { fromError } from "zod-validation-error";
|
||||
import { OpenAPITags, registry } from "@server/openApi";
|
||||
import { ListUserAdminOrgIdpsResponse } from "@server/routers/orgIdp/types";
|
||||
|
||||
const querySchema = z.strictObject({
|
||||
limit: z
|
||||
.string()
|
||||
.optional()
|
||||
.default("1000")
|
||||
.transform(Number)
|
||||
.pipe(z.int().nonnegative()),
|
||||
offset: z
|
||||
.string()
|
||||
.optional()
|
||||
.default("0")
|
||||
.transform(Number)
|
||||
.pipe(z.int().nonnegative())
|
||||
});
|
||||
|
||||
const paramsSchema = z.strictObject({
|
||||
userId: z.string().nonempty()
|
||||
});
|
||||
|
||||
async function getOrgIdsWhereUserIsAdmin(userId: string): Promise<string[]> {
|
||||
const rows = await db
|
||||
.select({ orgId: userOrgRoles.orgId })
|
||||
.from(userOrgRoles)
|
||||
.innerJoin(roles, eq(userOrgRoles.roleId, roles.roleId))
|
||||
.where(and(eq(userOrgRoles.userId, userId), eq(roles.isAdmin, true)));
|
||||
return [...new Set(rows.map((r) => r.orgId))];
|
||||
}
|
||||
|
||||
async function queryIdpsForOrgs(
|
||||
orgIds: string[],
|
||||
limit: number,
|
||||
offset: number
|
||||
) {
|
||||
return db
|
||||
.select({
|
||||
idpId: idp.idpId,
|
||||
orgId: idpOrg.orgId,
|
||||
orgName: orgs.name,
|
||||
name: idp.name,
|
||||
type: idp.type,
|
||||
variant: idpOidcConfig.variant,
|
||||
tags: idp.tags
|
||||
})
|
||||
.from(idpOrg)
|
||||
.where(inArray(idpOrg.orgId, orgIds))
|
||||
.innerJoin(orgs, eq(orgs.orgId, idpOrg.orgId))
|
||||
.innerJoin(idp, eq(idp.idpId, idpOrg.idpId))
|
||||
.innerJoin(idpOidcConfig, eq(idpOidcConfig.idpId, idpOrg.idpId))
|
||||
.orderBy(sql`idp.name DESC`)
|
||||
.limit(limit)
|
||||
.offset(offset);
|
||||
}
|
||||
|
||||
async function countIdpsForOrgs(orgIds: string[]) {
|
||||
const [{ count }] = await db
|
||||
.select({ count: sql<number>`count(*)` })
|
||||
.from(idpOrg)
|
||||
.innerJoin(idp, eq(idp.idpId, idpOrg.idpId))
|
||||
.innerJoin(idpOidcConfig, eq(idpOidcConfig.idpId, idpOrg.idpId))
|
||||
.where(inArray(idpOrg.orgId, orgIds));
|
||||
return count;
|
||||
}
|
||||
|
||||
export async function listUserAdminOrgIdps(
|
||||
req: Request,
|
||||
res: Response,
|
||||
next: NextFunction
|
||||
): Promise<any> {
|
||||
try {
|
||||
const parsedParams = paramsSchema.safeParse(req.params);
|
||||
if (!parsedParams.success) {
|
||||
return next(
|
||||
createHttpError(
|
||||
HttpCode.BAD_REQUEST,
|
||||
fromError(parsedParams.error).toString()
|
||||
)
|
||||
);
|
||||
}
|
||||
const { userId } = parsedParams.data;
|
||||
|
||||
const parsedQuery = querySchema.safeParse(req.query);
|
||||
if (!parsedQuery.success) {
|
||||
return next(
|
||||
createHttpError(
|
||||
HttpCode.BAD_REQUEST,
|
||||
fromError(parsedQuery.error).toString()
|
||||
)
|
||||
);
|
||||
}
|
||||
const { limit, offset } = parsedQuery.data;
|
||||
|
||||
const adminOrgIds = await getOrgIdsWhereUserIsAdmin(userId);
|
||||
|
||||
if (adminOrgIds.length === 0) {
|
||||
return response<ListUserAdminOrgIdpsResponse>(res, {
|
||||
data: {
|
||||
idps: [],
|
||||
pagination: {
|
||||
total: 0,
|
||||
limit,
|
||||
offset
|
||||
}
|
||||
},
|
||||
success: true,
|
||||
error: false,
|
||||
message: "Org Idps retrieved successfully",
|
||||
status: HttpCode.OK
|
||||
});
|
||||
}
|
||||
|
||||
const list = await queryIdpsForOrgs(adminOrgIds, limit, offset);
|
||||
const total = await countIdpsForOrgs(adminOrgIds);
|
||||
|
||||
return response<ListUserAdminOrgIdpsResponse>(res, {
|
||||
data: {
|
||||
idps: list,
|
||||
pagination: {
|
||||
total,
|
||||
limit,
|
||||
offset
|
||||
}
|
||||
},
|
||||
success: true,
|
||||
error: false,
|
||||
message: "Org Idps retrieved successfully",
|
||||
status: HttpCode.OK
|
||||
});
|
||||
} catch (error) {
|
||||
logger.error(error);
|
||||
return next(
|
||||
createHttpError(HttpCode.INTERNAL_SERVER_ERROR, "An error occurred")
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,34 @@
|
||||
/*
|
||||
* This file is part of a proprietary work.
|
||||
*
|
||||
* Copyright (c) 2025-2026 Fossorial, Inc.
|
||||
* All rights reserved.
|
||||
*
|
||||
* This file is licensed under the Fossorial Commercial License.
|
||||
* You may not use this file except in compliance with the License.
|
||||
* Unauthorized use, copying, modification, or distribution is strictly prohibited.
|
||||
*
|
||||
* This file is not licensed under the AGPLv3.
|
||||
*/
|
||||
|
||||
import { NextFunction, Request, Response } from "express";
|
||||
import createHttpError from "http-errors";
|
||||
import privateConfig from "#private/lib/config";
|
||||
import HttpCode from "@server/types/HttpCode";
|
||||
|
||||
export function requireOrgIdentityProviderMode(
|
||||
_req: Request,
|
||||
_res: Response,
|
||||
next: NextFunction
|
||||
): void {
|
||||
if (privateConfig.getRawPrivateConfig().app.identity_provider_mode !== "org") {
|
||||
return next(
|
||||
createHttpError(
|
||||
HttpCode.BAD_REQUEST,
|
||||
"Organization-specific IdP creation is not allowed in the current identity provider mode. Set app.identity_provider_mode to 'org' in the private configuration to enable this feature."
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
return next();
|
||||
}
|
||||
96
server/private/routers/orgIdp/unassociateOrgIdp.ts
Normal file
96
server/private/routers/orgIdp/unassociateOrgIdp.ts
Normal file
@@ -0,0 +1,96 @@
|
||||
/*
|
||||
* This file is part of a proprietary work.
|
||||
*
|
||||
* Copyright (c) 2025-2026 Fossorial, Inc.
|
||||
* All rights reserved.
|
||||
*
|
||||
* This file is licensed under the Fossorial Commercial License.
|
||||
* You may not use this file except in compliance with the License.
|
||||
* Unauthorized use, copying, modification, or distribution is strictly prohibited.
|
||||
*
|
||||
* This file is not licensed under the AGPLv3.
|
||||
*/
|
||||
|
||||
import { Request, Response, NextFunction } from "express";
|
||||
import { z } from "zod";
|
||||
import { db, idpOrg } from "@server/db";
|
||||
import response from "@server/lib/response";
|
||||
import HttpCode from "@server/types/HttpCode";
|
||||
import createHttpError from "http-errors";
|
||||
import logger from "@server/logger";
|
||||
import { fromError } from "zod-validation-error";
|
||||
import { and, eq, sql } from "drizzle-orm";
|
||||
import { OpenAPITags, registry } from "@server/openApi";
|
||||
|
||||
const paramsSchema = z
|
||||
.object({
|
||||
orgId: z.string().nonempty(),
|
||||
idpId: z.coerce.number<number>().int().positive()
|
||||
})
|
||||
.strict();
|
||||
|
||||
export async function unassociateOrgIdp(
|
||||
req: Request,
|
||||
res: Response,
|
||||
next: NextFunction
|
||||
): Promise<any> {
|
||||
try {
|
||||
const parsedParams = paramsSchema.safeParse(req.params);
|
||||
if (!parsedParams.success) {
|
||||
return next(
|
||||
createHttpError(
|
||||
HttpCode.BAD_REQUEST,
|
||||
fromError(parsedParams.error).toString()
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
const { orgId, idpId } = parsedParams.data;
|
||||
|
||||
const [association] = await db
|
||||
.select()
|
||||
.from(idpOrg)
|
||||
.where(and(eq(idpOrg.idpId, idpId), eq(idpOrg.orgId, orgId)))
|
||||
.limit(1);
|
||||
|
||||
if (!association) {
|
||||
return next(
|
||||
createHttpError(
|
||||
HttpCode.NOT_FOUND,
|
||||
`IdP with ID ${idpId} is not associated with organization ${orgId}`
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
const [{ count }] = await db
|
||||
.select({ count: sql<number>`count(*)` })
|
||||
.from(idpOrg)
|
||||
.where(eq(idpOrg.idpId, idpId));
|
||||
|
||||
if (count <= 1) {
|
||||
return next(
|
||||
createHttpError(
|
||||
HttpCode.BAD_REQUEST,
|
||||
"This is the last organization associated with this identity provider. Delete it instead."
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
await db
|
||||
.delete(idpOrg)
|
||||
.where(and(eq(idpOrg.idpId, idpId), eq(idpOrg.orgId, orgId)));
|
||||
|
||||
return response<null>(res, {
|
||||
data: null,
|
||||
success: true,
|
||||
error: false,
|
||||
message: "Org IdP unassociated successfully",
|
||||
status: HttpCode.OK
|
||||
});
|
||||
} catch (error) {
|
||||
logger.error(error);
|
||||
return next(
|
||||
createHttpError(HttpCode.INTERNAL_SERVER_ERROR, "An error occurred")
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -26,7 +26,6 @@ import { encrypt } from "@server/lib/crypto";
|
||||
import config from "@server/lib/config";
|
||||
import { isSubscribed } from "#private/lib/isSubscribed";
|
||||
import { tierMatrix } from "@server/lib/billing/tierMatrix";
|
||||
import privateConfig from "#private/lib/config";
|
||||
import { build } from "@server/build";
|
||||
|
||||
const paramsSchema = z
|
||||
@@ -48,6 +47,7 @@ const bodySchema = z.strictObject({
|
||||
scopes: z.string().optional(),
|
||||
autoProvision: z.boolean().optional(),
|
||||
roleMapping: z.string().optional(),
|
||||
orgMapping: z.string().nullish(),
|
||||
tags: z.string().optional()
|
||||
});
|
||||
|
||||
@@ -99,18 +99,6 @@ export async function updateOrgOidcIdp(
|
||||
);
|
||||
}
|
||||
|
||||
if (
|
||||
privateConfig.getRawPrivateConfig().app.identity_provider_mode !==
|
||||
"org"
|
||||
) {
|
||||
return next(
|
||||
createHttpError(
|
||||
HttpCode.BAD_REQUEST,
|
||||
"Organization-specific IdP creation is not allowed in the current identity provider mode. Set app.identity_provider_mode to 'org' in the private configuration to enable this feature."
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
const { idpId, orgId } = parsedParams.data;
|
||||
const {
|
||||
clientId,
|
||||
@@ -123,6 +111,7 @@ export async function updateOrgOidcIdp(
|
||||
namePath,
|
||||
name,
|
||||
roleMapping,
|
||||
orgMapping,
|
||||
tags
|
||||
} = parsedBody.data;
|
||||
|
||||
@@ -218,13 +207,20 @@ export async function updateOrgOidcIdp(
|
||||
.where(eq(idpOidcConfig.idpId, idpId));
|
||||
}
|
||||
|
||||
const idpOrgPolicyPatch: {
|
||||
roleMapping?: string;
|
||||
orgMapping?: string | null;
|
||||
} = {};
|
||||
if (roleMapping !== undefined) {
|
||||
// Update IdP-org policy
|
||||
idpOrgPolicyPatch.roleMapping = roleMapping;
|
||||
}
|
||||
if (orgMapping !== undefined) {
|
||||
idpOrgPolicyPatch.orgMapping = orgMapping;
|
||||
}
|
||||
if (Object.keys(idpOrgPolicyPatch).length > 0) {
|
||||
await trx
|
||||
.update(idpOrg)
|
||||
.set({
|
||||
roleMapping
|
||||
})
|
||||
.set(idpOrgPolicyPatch)
|
||||
.where(
|
||||
and(eq(idpOrg.idpId, idpId), eq(idpOrg.orgId, orgId))
|
||||
);
|
||||
|
||||
Reference in New Issue
Block a user