fix user permissions + improve notification system

This commit is contained in:
KingKDot
2026-04-13 14:11:49 -05:00
parent c56a9f16f4
commit f28c0d33a9
12 changed files with 257 additions and 24 deletions
@@ -43,6 +43,9 @@ export type UserDeliveryTarget = {
telegramBotToken: string;
telegramChatId: string;
telegramTemplate: string | null;
clientEventWebhook: boolean;
clientEventTelegram: boolean;
clientEventPush: boolean;
};
export const DEFAULT_WEBHOOK_TEMPLATE =
@@ -324,6 +327,7 @@ export async function deliverWebPushClientEvent(
info: { id: string; host?: string; user?: string; os?: string },
canUserAccessClient: (userId: number, userRole: string, clientId: string) => boolean,
getUserRole: (userId: number) => string | undefined,
isClientEventPushEnabled?: (userId: number) => boolean,
): Promise<void> {
const subs = getAllPushSubscriptions();
if (subs.length === 0) return;
@@ -356,6 +360,7 @@ export async function deliverWebPushClientEvent(
subs.map(async (sub) => {
const role = getUserRole(sub.userId);
if (!role) return;
if (isClientEventPushEnabled && !isClientEventPushEnabled(sub.userId)) return;
if (event === "client_purgatory") {
if (role !== "admin" && role !== "operator") return;
} else if (role !== "admin") {
@@ -418,7 +423,7 @@ export async function deliverClientEventToExternalChannels(
await Promise.allSettled(
targets.map(async (target) => {
if (target.webhookEnabled && target.webhookUrl) {
if (target.webhookEnabled && target.clientEventWebhook && target.webhookUrl) {
const url = target.webhookUrl.trim();
if (!url) return;
let parsed: URL;
@@ -474,7 +479,7 @@ export async function deliverClientEventToExternalChannels(
}
}
if (target.telegramEnabled && target.telegramBotToken && target.telegramChatId) {
if (target.telegramEnabled && target.clientEventTelegram && target.telegramBotToken && target.telegramChatId) {
const token = target.telegramBotToken.trim();
const chatId = target.telegramChatId.trim();
if (!token || !chatId) return;
@@ -14,6 +14,25 @@ import {
import { logAudit, AuditAction } from "../../auditLog";
import * as clientManager from "../../clientManager";
import { setOnlineState } from "../../db";
import {
canUserAccessClient,
getUserClientAccessScope,
listUserClientRuleIdsByAccess,
} from "../../users";
function getClientScopeFilters(userId: number, role: string): { allowedClientIds?: string[]; deniedClientIds?: string[] } {
if (role === "admin") return {};
const scope = getUserClientAccessScope(userId);
if (scope === "none") return { allowedClientIds: [] };
if (scope === "all") return {};
if (scope === "allowlist") {
return { allowedClientIds: listUserClientRuleIdsByAccess(userId, "allow") };
}
if (scope === "denylist") {
return { deniedClientIds: listUserClientRuleIdsByAccess(userId, "deny") };
}
return { allowedClientIds: [] };
}
let _postApproveHook: ((clientId: string) => void) | undefined;
@@ -31,7 +50,8 @@ export async function handleEnrollmentRoutes(
if (!user) return new Response("Unauthorized", { status: 401 });
if (user.role === "viewer") return new Response("Forbidden", { status: 403 });
const clients = getPendingClients();
const scopeFilters = getClientScopeFilters(user.userId, user.role);
const clients = getPendingClients(scopeFilters);
return Response.json({ items: clients });
}
@@ -40,7 +60,8 @@ export async function handleEnrollmentRoutes(
const user = await authenticateRequest(req);
if (!user) return new Response("Unauthorized", { status: 401 });
const stats = getEnrollmentStats();
const scopeFilters = getClientScopeFilters(user.userId, user.role);
const stats = getEnrollmentStats(scopeFilters);
return Response.json(stats);
}
@@ -66,6 +87,10 @@ export async function handleEnrollmentRoutes(
const current = getClientEnrollmentStatus(clientId);
if (!current) return Response.json({ error: "Client not found" }, { status: 404 });
if (!canUserAccessClient(user.userId, user.role, clientId)) {
return new Response("Forbidden: Client access denied", { status: 403 });
}
setClientEnrollmentStatus(clientId, "approved", user.username);
_postApproveHook?.(clientId);
@@ -94,6 +119,10 @@ export async function handleEnrollmentRoutes(
const current = getClientEnrollmentStatus(clientId);
if (!current) return Response.json({ error: "Client not found" }, { status: 404 });
if (!canUserAccessClient(user.userId, user.role, clientId)) {
return new Response("Forbidden: Client access denied", { status: 403 });
}
setClientEnrollmentStatus(clientId, "denied");
logAudit({
@@ -120,6 +149,10 @@ export async function handleEnrollmentRoutes(
const current = getClientEnrollmentStatus(clientId);
if (!current) return Response.json({ error: "Client not found" }, { status: 404 });
if (!canUserAccessClient(user.userId, user.role, clientId)) {
return new Response("Forbidden: Client access denied", { status: 403 });
}
setClientEnrollmentStatus(clientId, "pending");
return Response.json({ ok: true, status: "pending" });
@@ -151,6 +184,7 @@ export async function handleEnrollmentRoutes(
if (action === "ban-ip") {
let banned = 0;
for (const id of ids) {
if (!canUserAccessClient(user.userId, user.role, id)) continue;
const clientIp = getClientIp(id);
if (!clientIp) continue;
banIp(clientIp, `Bulk banned from purgatory by ${user.username}`);
@@ -178,6 +212,7 @@ export async function handleEnrollmentRoutes(
const status = action === "approve" ? "approved" : action === "deny" ? "denied" : "pending";
let updated = 0;
for (const id of ids) {
if (!canUserAccessClient(user.userId, user.role, id)) continue;
const ok = setClientEnrollmentStatus(
id,
status as "approved" | "denied" | "pending",
@@ -215,6 +250,10 @@ export async function handleEnrollmentRoutes(
const targetIp = getClientIp(clientId);
if (!targetIp) return Response.json({ error: "Client IP not found" }, { status: 404 });
if (!canUserAccessClient(user.userId, user.role, clientId)) {
return new Response("Forbidden: Client access denied", { status: 403 });
}
banIp(targetIp, `Banned from purgatory by ${user.username} for client ${clientId}`);
setClientEnrollmentStatus(clientId, "denied");
@@ -4,7 +4,7 @@ import { getConfig, updateSecurityConfig, updateTlsConfig, updateAppearanceConfi
import { getClientMetricsSummary } from "../../db";
import { metrics } from "../../metrics";
import { requirePermission } from "../../rbac";
import { getUserTelegramChatId, setUserTelegramChatId } from "../../users";
import { getUserTelegramChatId, setUserTelegramChatId, getUserClientAccessScope, listUserClientRuleIdsByAccess } from "../../users";
import { runCertbotSetup } from "../certbot-setup";
import {
getActiveProxies,
@@ -311,6 +311,20 @@ export async function handleMiscRoutes(
const endDate = Number(url.searchParams.get("endDate") || 0) || undefined;
const successOnly = url.searchParams.get("successOnly") === "true";
let allowedClientIds: string[] | undefined;
let deniedClientIds: string[] | undefined;
if (user.role !== "admin") {
const scope = getUserClientAccessScope(user.userId);
if (scope === "none") {
return Response.json({ logs: [], total: 0, page, pageSize }, { headers: deps.CORS_HEADERS });
}
if (scope === "allowlist") {
allowedClientIds = listUserClientRuleIdsByAccess(user.userId, "allow");
} else if (scope === "denylist") {
deniedClientIds = listUserClientRuleIdsByAccess(user.userId, "deny");
}
}
const result = getAuditLogs({
page,
pageSize,
@@ -320,6 +334,8 @@ export async function handleMiscRoutes(
startDate,
endDate,
successOnly,
allowedClientIds,
deniedClientIds,
});
return Response.json(result, { headers: deps.CORS_HEADERS });
@@ -224,6 +224,9 @@ export async function handleNotificationsConfigRoutes(
telegram_bot_token: null,
telegram_chat_id: null,
telegram_template: null,
client_event_webhook: 1,
client_event_telegram: 1,
client_event_push: 1,
};
return Response.json({
settings,
@@ -276,6 +279,15 @@ export async function handleNotificationsConfigRoutes(
if (typeof body?.telegram_template === "string") {
patch.telegram_template = body.telegram_template.trim() || null;
}
if (typeof body?.client_event_webhook === "boolean" || typeof body?.client_event_webhook === "number") {
patch.client_event_webhook = body.client_event_webhook ? 1 : 0;
}
if (typeof body?.client_event_telegram === "boolean" || typeof body?.client_event_telegram === "number") {
patch.client_event_telegram = body.client_event_telegram ? 1 : 0;
}
if (typeof body?.client_event_push === "boolean" || typeof body?.client_event_push === "number") {
patch.client_event_push = body.client_event_push ? 1 : 0;
}
const result = updateUserNotificationSettings(user.userId, patch);
if (!result.success) {
@@ -357,9 +357,14 @@ export function createNotificationPluginHandlers(deps: CreateDeps) {
safeSendViewer(session.viewer, item);
}
void deliverWebPushClientEvent(event, info, deps.canUserAccessClient, deps.getUserRole);
const externalTargets = deps.getDeliveryTargetsForClientEvent(event, info.id);
const pushEnabledByUser = new Map(externalTargets.map((t) => [t.userId, t.clientEventPush]));
void deliverWebPushClientEvent(event, info, deps.canUserAccessClient, deps.getUserRole, (userId) => {
const enabled = pushEnabledByUser.get(userId);
return enabled !== undefined ? enabled : true;
});
void deliverClientEventToExternalChannels(event, info, externalTargets);
},