Merge branch 'dev' into private-resource-page

This commit is contained in:
miloschwartz
2026-07-07 15:07:35 -04:00
35 changed files with 614 additions and 657 deletions
+24 -11
View File
@@ -1,3 +1,4 @@
import config from "@server/lib/config";
import { Pool, PoolConfig } from "pg"; import { Pool, PoolConfig } from "pg";
export function createPoolConfig( export function createPoolConfig(
@@ -18,17 +19,7 @@ export function createPoolConfig(
keepAliveInitialDelayMillis: 10000, // send first keepalive after 10s of idle keepAliveInitialDelayMillis: 10000, // send first keepalive after 10s of idle
// Allow connections to be released and recreated more aggressively // Allow connections to be released and recreated more aggressively
// to avoid stale connections building up // to avoid stale connections building up
allowExitOnIdle: false, allowExitOnIdle: false
// Disable JIT compilation for this connection. Our hot-path queries
// (e.g. resource-by-domain lookups) join many tables but only ever
// return a handful of rows. When planner row estimates drift (e.g.
// due to autovacuum lag under write-heavy load), Postgres decides
// these plans are expensive enough to JIT-compile, which can add
// multiple seconds of pure compilation overhead per query and
// saturate the connection pool. JIT never pays off for these
// short-lived OLTP queries, so it's disabled outright rather than
// relying on statistics staying fresh.
options: "-c jit=off"
}; };
} }
@@ -49,6 +40,28 @@ export function attachPoolErrorHandlers(pool: Pool, label: string): void {
`Failed to set statement_timeout on ${label} client: ${err.message}` `Failed to set statement_timeout on ${label} client: ${err.message}`
); );
}); });
// Disable JIT compilation for this connection. Our hot-path queries
// (e.g. resource-by-domain lookups) join many tables but only ever
// return a handful of rows. When planner row estimates drift (e.g.
// due to autovacuum lag under write-heavy load), Postgres decides
// these plans are expensive enough to JIT-compile, which can add
// multiple seconds of pure compilation overhead per query and
// saturate the connection pool. JIT never pays off for these
// short-lived OLTP queries, so it's disabled outright rather than
// relying on statistics staying fresh.
//
// Set via a runtime SET command rather than the `options: "-c
// jit=off"` startup parameter: connections in SaaS mode go through
// a pooler (e.g. PgBouncer) that rejects arbitrary startup packet
// options with a protocol_violation (08P01) error.
if (config.getRawConfig().postgres?.pool.jit_mode == false) {
client.query("SET jit = off").catch((err: Error) => {
console.warn(
`Failed to set jit=off on ${label} client: ${err.message}`
);
});
}
}); });
} }
-2
View File
@@ -23,7 +23,6 @@ export enum TierFeature {
StandaloneHealthChecks = "standaloneHealthChecks", StandaloneHealthChecks = "standaloneHealthChecks",
AlertingRules = "alertingRules", AlertingRules = "alertingRules",
WildcardSubdomain = "wildcardSubdomain", WildcardSubdomain = "wildcardSubdomain",
Labels = "labels",
NewtAutoUpdate = "newtAutoUpdate", NewtAutoUpdate = "newtAutoUpdate",
ResourcePolicies = "resourcePolicies", ResourcePolicies = "resourcePolicies",
AdvancedPublicResources = "advancedPublicResources", AdvancedPublicResources = "advancedPublicResources",
@@ -31,7 +30,6 @@ export enum TierFeature {
} }
export const tierMatrix: Record<TierFeature, Tier[]> = { export const tierMatrix: Record<TierFeature, Tier[]> = {
[TierFeature.Labels]: ["tier1", "tier2", "tier3", "enterprise"],
[TierFeature.OrgOidc]: ["tier1", "tier2", "tier3", "enterprise"], [TierFeature.OrgOidc]: ["tier1", "tier2", "tier3", "enterprise"],
[TierFeature.LoginPageDomain]: ["tier1", "tier2", "tier3", "enterprise"], [TierFeature.LoginPageDomain]: ["tier1", "tier2", "tier3", "enterprise"],
[TierFeature.DeviceApprovals]: ["tier1", "tier3", "enterprise"], [TierFeature.DeviceApprovals]: ["tier1", "tier3", "enterprise"],
@@ -116,7 +116,7 @@ export async function applyNewtDockerBlueprint(
source: "NEWT" source: "NEWT"
}); });
} catch (error) { } catch (error) {
logger.error(`Failed to update database from config: ${error}`); logger.debug(`Failed to update database from config: ${error}`);
await sendToClient(newtId, { await sendToClient(newtId, {
type: "newt/blueprint/results", type: "newt/blueprint/results",
data: { data: {
+2 -1
View File
@@ -184,7 +184,8 @@ export const configSchema = z
.number() .number()
.positive() .positive()
.optional() .optional()
.default(5000) .default(5000),
jit_mode: z.boolean().default(true)
}) })
.optional() .optional()
.prefault({}) .prefault({})
@@ -17,6 +17,7 @@ import HttpCode from "@server/types/HttpCode";
import { build } from "@server/build"; import { build } from "@server/build";
import { getOrgTierData } from "#private/lib/billing"; import { getOrgTierData } from "#private/lib/billing";
import { Tier } from "@server/types/Tiers"; import { Tier } from "@server/types/Tiers";
import logger from "@server/logger";
export function verifyValidSubscription(tiers: Tier[]) { export function verifyValidSubscription(tiers: Tier[]) {
return async function ( return async function (
@@ -30,9 +31,9 @@ export function verifyValidSubscription(tiers: Tier[]) {
} }
const orgId = const orgId =
req.params.orgId || req.params?.orgId ||
req.body.orgId || req.body?.orgId ||
req.query.orgId || req.query?.orgId ||
req.userOrgId; req.userOrgId;
if (!orgId) { if (!orgId) {
@@ -65,6 +66,7 @@ export function verifyValidSubscription(tiers: Tier[]) {
return next(); return next();
} catch (e) { } catch (e) {
logger.error(e);
return next( return next(
createHttpError( createHttpError(
HttpCode.INTERNAL_SERVER_ERROR, HttpCode.INTERNAL_SERVER_ERROR,
-54
View File
@@ -31,7 +31,6 @@ import * as siteProvisioning from "#private/routers/siteProvisioning";
import * as eventStreamingDestination from "#private/routers/eventStreamingDestination"; import * as eventStreamingDestination from "#private/routers/eventStreamingDestination";
import * as alertRule from "#private/routers/alertRule"; import * as alertRule from "#private/routers/alertRule";
import * as healthChecks from "#private/routers/healthChecks"; import * as healthChecks from "#private/routers/healthChecks";
import * as labels from "#private/routers/labels";
import * as client from "@server/routers/client"; import * as client from "@server/routers/client";
import * as resource from "#private/routers/resource"; import * as resource from "#private/routers/resource";
import * as policy from "#private/routers/policy"; import * as policy from "#private/routers/policy";
@@ -810,59 +809,6 @@ authenticated.get(
alertRule.getAlertRule alertRule.getAlertRule
); );
authenticated.get(
"/org/:orgId/labels",
verifyValidLicense,
verifyOrgAccess,
verifyValidSubscription(tierMatrix.labels),
verifyUserHasAction(ActionsEnum.listOrgLabels),
labels.listOrgLabels
);
authenticated.post(
"/org/:orgId/labels",
verifyValidLicense,
verifyOrgAccess,
verifyValidSubscription(tierMatrix.labels),
verifyUserHasAction(ActionsEnum.createOrgLabel),
labels.createOrgLabel
);
authenticated.patch(
"/org/:orgId/label/:labelId",
verifyValidLicense,
verifyOrgAccess,
verifyValidSubscription(tierMatrix.labels),
verifyUserHasAction(ActionsEnum.updateOrgLabel),
labels.updateOrgLabel
);
authenticated.delete(
"/org/:orgId/label/:labelId",
verifyValidLicense,
verifyOrgAccess,
verifyUserHasAction(ActionsEnum.deleteOrgLabel),
labels.deleteOrgLabel
);
authenticated.put(
"/org/:orgId/label/:labelId/attach",
verifyValidLicense,
verifyOrgAccess,
verifyValidSubscription(tierMatrix.labels),
verifyUserHasAction(ActionsEnum.attachLabelToItem),
labels.attachLabelToItem
);
authenticated.put(
"/org/:orgId/label/:labelId/detach",
verifyValidLicense,
verifyOrgAccess,
verifyValidSubscription(tierMatrix.labels),
verifyUserHasAction(ActionsEnum.detachLabelFromItem),
labels.detachLabelFromItem
);
authenticated.get( authenticated.get(
"/org/:orgId/health-checks", "/org/:orgId/health-checks",
verifyValidLicense, verifyValidLicense,
-19
View File
@@ -1,19 +0,0 @@
/*
* 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 "./listOrgLabels";
export * from "./createOrgLabel";
export * from "./updateOrgLabel";
export * from "./attachLabelToItem";
export * from "./detachLabelFromItem";
export * from "./deleteOrgLabel";
@@ -99,7 +99,7 @@ export async function applyJSONBlueprint(
source: "API" source: "API"
}); });
} catch (error) { } catch (error) {
logger.error(`Failed to update database from config: ${error}`); logger.debug(`Failed to update database from config: ${error}`);
return next( return next(
createHttpError( createHttpError(
HttpCode.BAD_REQUEST, HttpCode.BAD_REQUEST,
+14 -24
View File
@@ -304,11 +304,6 @@ export async function listClients(
(client) => client.clientId (client) => client.clientId
); );
const isLabelFeatureEnabled = await isLicensedOrSubscribed(
orgId,
tierMatrix.labels
);
// Get client count with filter // Get client count with filter
const conditions = [ const conditions = [
and( and(
@@ -341,7 +336,7 @@ export async function listClients(
conditions.push(or(...filterAggregates)); conditions.push(or(...filterAggregates));
} }
if (isLabelFeatureEnabled && labelFilter && labelFilter.length > 0) { if (labelFilter && labelFilter.length > 0) {
conditions.push( conditions.push(
inArray( inArray(
clients.clientId, clients.clientId,
@@ -361,25 +356,20 @@ export async function listClients(
const q = "%" + query.toLowerCase() + "%"; const q = "%" + query.toLowerCase() + "%";
const queryList = [ const queryList = [
like(sql`LOWER(${clients.name})`, q), like(sql`LOWER(${clients.name})`, q),
like(sql`LOWER(${clients.niceId})`, q) like(sql`LOWER(${clients.niceId})`, q),
inArray(
clients.clientId,
db
.select({ id: clientLabels.clientId })
.from(clientLabels)
.innerJoin(
labels,
eq(labels.labelId, clientLabels.labelId)
)
.where(like(sql`LOWER(${labels.name})`, q))
)
]; ];
if (isLabelFeatureEnabled) {
queryList.push(
inArray(
clients.clientId,
db
.select({ id: clientLabels.clientId })
.from(clientLabels)
.innerJoin(
labels,
eq(labels.labelId, clientLabels.labelId)
)
.where(like(sql`LOWER(${labels.name})`, q))
)
);
}
conditions.push(or(...queryList)); conditions.push(or(...queryList));
} }
@@ -414,7 +404,7 @@ export async function listClients(
clientId: number; clientId: number;
}> = []; }> = [];
if (isLabelFeatureEnabled && clientIds.length > 0) { if (clientIds.length > 0) {
labelsForClients = await db labelsForClients = await db
.select({ .select({
labelId: labels.labelId, labelId: labels.labelId,
+43
View File
@@ -54,6 +54,7 @@ import { build } from "@server/build";
import { createStore } from "#dynamic/lib/rateLimitStore"; import { createStore } from "#dynamic/lib/rateLimitStore";
import { logActionAudit } from "#dynamic/middlewares"; import { logActionAudit } from "#dynamic/middlewares";
import { checkRoundTripMessage } from "./ws"; import { checkRoundTripMessage } from "./ws";
import * as labels from "@server/routers/labels";
// Root routes // Root routes
export const unauthenticated = Router(); export const unauthenticated = Router();
@@ -1335,6 +1336,48 @@ authenticated.get(
authenticated.get("/ws/round-trip-message/:messageId", checkRoundTripMessage); authenticated.get("/ws/round-trip-message/:messageId", checkRoundTripMessage);
authenticated.get(
"/org/:orgId/labels",
verifyOrgAccess,
verifyUserHasAction(ActionsEnum.listOrgLabels),
labels.listOrgLabels
);
authenticated.post(
"/org/:orgId/labels",
verifyOrgAccess,
verifyUserHasAction(ActionsEnum.createOrgLabel),
labels.createOrgLabel
);
authenticated.patch(
"/org/:orgId/label/:labelId",
verifyOrgAccess,
verifyUserHasAction(ActionsEnum.updateOrgLabel),
labels.updateOrgLabel
);
authenticated.delete(
"/org/:orgId/label/:labelId",
verifyOrgAccess,
verifyUserHasAction(ActionsEnum.deleteOrgLabel),
labels.deleteOrgLabel
);
authenticated.put(
"/org/:orgId/label/:labelId/attach",
verifyOrgAccess,
verifyUserHasAction(ActionsEnum.attachLabelToItem),
labels.attachLabelToItem
);
authenticated.put(
"/org/:orgId/label/:labelId/detach",
verifyOrgAccess,
verifyUserHasAction(ActionsEnum.detachLabelFromItem),
labels.detachLabelFromItem
);
// Auth routes // Auth routes
export const authRouter = Router(); export const authRouter = Router();
unauthenticated.use("/auth", authRouter); unauthenticated.use("/auth", authRouter);
@@ -1,16 +1,3 @@
/*
* 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 { import {
clients, clients,
clientLabels, clientLabels,
@@ -1,15 +1,3 @@
/*
* 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 { import {
db, db,
labels, labels,
@@ -1,15 +1,3 @@
/*
* 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 { db, labels } from "@server/db"; import { db, labels } from "@server/db";
import response from "@server/lib/response"; import response from "@server/lib/response";
import logger from "@server/logger"; import logger from "@server/logger";
@@ -1,16 +1,3 @@
/*
* 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 { import {
clients, clients,
clientLabels, clientLabels,
+6
View File
@@ -0,0 +1,6 @@
export * from "./listOrgLabels";
export * from "./createOrgLabel";
export * from "./updateOrgLabel";
export * from "./attachLabelToItem";
export * from "./detachLabelFromItem";
export * from "./deleteOrgLabel";
@@ -1,16 +1,3 @@
/*
* 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 { db, labels } from "@server/db"; import { db, labels } from "@server/db";
import response from "@server/lib/response"; import response from "@server/lib/response";
import logger from "@server/logger"; import logger from "@server/logger";
@@ -1,16 +1,3 @@
/*
* 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 { db, labels } from "@server/db"; import { db, labels } from "@server/db";
import response from "@server/lib/response"; import response from "@server/lib/response";
import logger from "@server/logger"; import logger from "@server/logger";
@@ -321,10 +321,7 @@ function combineOrConditions(
return or(...parts); return or(...parts);
} }
function buildSearchConditionForPublic( function buildSearchConditionForPublic(query: string) {
query: string,
labelsFeatureEnabled: boolean
) {
if (!query.trim()) { if (!query.trim()) {
return undefined; return undefined;
} }
@@ -342,32 +339,21 @@ function buildSearchConditionForPublic(
.leftJoin(sites, eq(targets.siteId, sites.siteId)) .leftJoin(sites, eq(targets.siteId, sites.siteId))
.leftJoin(exitNodes, eq(sites.exitNodeId, exitNodes.exitNodeId)) .leftJoin(exitNodes, eq(sites.exitNodeId, exitNodes.exitNodeId))
.where(like(sql`LOWER(${exitNodes.endpoint})`, pattern)) .where(like(sql`LOWER(${exitNodes.endpoint})`, pattern))
),
inArray(
resources.resourceId,
db
.select({ id: resourceLabels.resourceId })
.from(resourceLabels)
.innerJoin(labels, eq(labels.labelId, resourceLabels.labelId))
.where(like(sql`LOWER(${labels.name})`, pattern))
) )
]; ];
if (labelsFeatureEnabled) {
queryList.push(
inArray(
resources.resourceId,
db
.select({ id: resourceLabels.resourceId })
.from(resourceLabels)
.innerJoin(
labels,
eq(labels.labelId, resourceLabels.labelId)
)
.where(like(sql`LOWER(${labels.name})`, pattern))
)
);
}
return or(...queryList); return or(...queryList);
} }
function buildSearchConditionForSiteResource( function buildSearchConditionForSiteResource(query: string) {
query: string,
labelsFeatureEnabled: boolean
) {
if (!query.trim()) { if (!query.trim()) {
return undefined; return undefined;
} }
@@ -382,40 +368,34 @@ function buildSearchConditionForSiteResource(
like(sql`LOWER(${siteResources.scheme})`, pattern), like(sql`LOWER(${siteResources.scheme})`, pattern),
like(sql`LOWER(${siteResources.alias})`, pattern), like(sql`LOWER(${siteResources.alias})`, pattern),
like(sql`LOWER(${siteResources.fullDomain})`, pattern), like(sql`LOWER(${siteResources.fullDomain})`, pattern),
like(sql`LOWER(${siteResources.aliasAddress})`, pattern) like(sql`LOWER(${siteResources.aliasAddress})`, pattern),
inArray(
siteResources.siteResourceId,
db
.select({ id: siteResourceLabels.siteResourceId })
.from(siteResourceLabels)
.innerJoin(
labels,
eq(labels.labelId, siteResourceLabels.labelId)
)
.where(like(sql`LOWER(${labels.name})`, pattern))
)
]; ];
if (labelsFeatureEnabled) {
queryList.push(
inArray(
siteResources.siteResourceId,
db
.select({ id: siteResourceLabels.siteResourceId })
.from(siteResourceLabels)
.innerJoin(
labels,
eq(labels.labelId, siteResourceLabels.labelId)
)
.where(like(sql`LOWER(${labels.name})`, pattern))
)
);
}
return or(...queryList); return or(...queryList);
} }
async function filterPublicResourceIdsByTextSearch( async function filterPublicResourceIdsByTextSearch(
orgId: string, orgId: string,
resourceIds: number[], resourceIds: number[],
query: string, query: string
labelsFeatureEnabled: boolean
): Promise<number[]> { ): Promise<number[]> {
if (!query.trim() || resourceIds.length === 0) { if (!query.trim() || resourceIds.length === 0) {
return resourceIds; return resourceIds;
} }
const textMatch = combineOrConditions( const textMatch = combineOrConditions(
buildSearchConditionForPublic(query, labelsFeatureEnabled), buildSearchConditionForPublic(query),
buildSiteNameSearchCondition(query) buildSiteNameSearchCondition(query)
); );
if (!textMatch) { if (!textMatch) {
@@ -442,15 +422,14 @@ async function filterPublicResourceIdsByTextSearch(
async function filterSiteResourceIdsByTextSearch( async function filterSiteResourceIdsByTextSearch(
orgId: string, orgId: string,
siteResourceIds: number[], siteResourceIds: number[],
query: string, query: string
labelsFeatureEnabled: boolean
): Promise<number[]> { ): Promise<number[]> {
if (!query.trim() || siteResourceIds.length === 0) { if (!query.trim() || siteResourceIds.length === 0) {
return siteResourceIds; return siteResourceIds;
} }
const textMatch = combineOrConditions( const textMatch = combineOrConditions(
buildSearchConditionForSiteResource(query, labelsFeatureEnabled), buildSearchConditionForSiteResource(query),
buildSiteNameSearchCondition(query) buildSiteNameSearchCondition(query)
); );
if (!textMatch) { if (!textMatch) {
@@ -477,10 +456,6 @@ async function filterSiteResourceIdsByTextSearch(
return rows.map((row) => row.siteResourceId); return rows.map((row) => row.siteResourceId);
} }
async function labelsEnabled(orgId: string): Promise<boolean> {
return isLicensedOrSubscribed(orgId, tierMatrix.labels);
}
async function fetchLabelsForResources( async function fetchLabelsForResources(
orgId: string, orgId: string,
resourceIds: number[], resourceIds: number[],
@@ -492,10 +467,6 @@ async function fetchLabelsForResources(
const byResourceId = new Map<number, LauncherLabel[]>(); const byResourceId = new Map<number, LauncherLabel[]>();
const bySiteResourceId = new Map<number, LauncherLabel[]>(); const bySiteResourceId = new Map<number, LauncherLabel[]>();
if (!(await labelsEnabled(orgId))) {
return { byResourceId, bySiteResourceId };
}
const [resourceLabelRows, siteResourceLabelRows] = await Promise.all([ const [resourceLabelRows, siteResourceLabelRows] = await Promise.all([
resourceIds.length === 0 resourceIds.length === 0
? Promise.resolve([]) ? Promise.resolve([])
@@ -571,15 +542,8 @@ async function listSiteGroups(
): Promise<{ groups: LauncherGroup[]; total: number }> { ): Promise<{ groups: LauncherGroup[]; total: number }> {
const siteFilterIds = parseIdListParam(query.siteIds); const siteFilterIds = parseIdListParam(query.siteIds);
const labelFilterIds = parseIdListParam(query.labelIds); const labelFilterIds = parseIdListParam(query.labelIds);
const labelsFeatureEnabled = await labelsEnabled(orgId); const searchPublic = buildSearchConditionForPublic(query.query);
const searchPublic = buildSearchConditionForPublic( const searchSite = buildSearchConditionForSiteResource(query.query);
query.query,
labelsFeatureEnabled
);
const searchSite = buildSearchConditionForSiteResource(
query.query,
labelsFeatureEnabled
);
const siteCountMap = new Map<number, SiteGroupRow>(); const siteCountMap = new Map<number, SiteGroupRow>();
if (accessible.resourceIds.length > 0) { if (accessible.resourceIds.length > 0) {
@@ -822,10 +786,6 @@ async function listLabelGroups(
>(); >();
let unlabeledCount = 0; let unlabeledCount = 0;
if (!(await labelsEnabled(orgId))) {
return { groups: [], total: 0 };
}
const matchesLabelFilters = (labelId: number) => const matchesLabelFilters = (labelId: number) =>
labelFilterIds.length === 0 || labelFilterIds.includes(labelId); labelFilterIds.length === 0 || labelFilterIds.includes(labelId);
@@ -835,7 +795,7 @@ async function listLabelGroups(
eq(resources.orgId, orgId), eq(resources.orgId, orgId),
eq(resources.enabled, true) eq(resources.enabled, true)
]; ];
const searchPublic = buildSearchConditionForPublic(query.query, true); const searchPublic = buildSearchConditionForPublic(query.query);
if (searchPublic) { if (searchPublic) {
publicConditions.push(searchPublic); publicConditions.push(searchPublic);
} }
@@ -899,10 +859,7 @@ async function listLabelGroups(
eq(siteResources.orgId, orgId), eq(siteResources.orgId, orgId),
eq(siteResources.enabled, true) eq(siteResources.enabled, true)
]; ];
const searchSite = buildSearchConditionForSiteResource( const searchSite = buildSearchConditionForSiteResource(query.query);
query.query,
true
);
if (searchSite) { if (searchSite) {
siteConditions.push(searchSite); siteConditions.push(searchSite);
} }
@@ -1349,23 +1306,19 @@ async function listLauncherResourcesForUserUncached(
} }
} }
const labelsFeatureEnabled = await labelsEnabled(orgId);
if (query.query.trim()) { if (query.query.trim()) {
if (filteredResourceIds.length > 0) { if (filteredResourceIds.length > 0) {
filteredResourceIds = await filterPublicResourceIdsByTextSearch( filteredResourceIds = await filterPublicResourceIdsByTextSearch(
orgId, orgId,
filteredResourceIds, filteredResourceIds,
query.query, query.query
labelsFeatureEnabled
); );
} }
if (filteredSiteResourceIds.length > 0) { if (filteredSiteResourceIds.length > 0) {
filteredSiteResourceIds = await filterSiteResourceIdsByTextSearch( filteredSiteResourceIds = await filterSiteResourceIdsByTextSearch(
orgId, orgId,
filteredSiteResourceIds, filteredSiteResourceIds,
query.query, query.query
labelsFeatureEnabled
); );
} }
} }
@@ -1565,10 +1518,6 @@ async function collectAccessibleLabels(
): Promise<Map<number, LauncherLabel>> { ): Promise<Map<number, LauncherLabel>> {
const labelMap = new Map<number, LauncherLabel>(); const labelMap = new Map<number, LauncherLabel>();
if (!(await labelsEnabled(orgId))) {
return labelMap;
}
if (accessible.resourceIds.length > 0) { if (accessible.resourceIds.length > 0) {
const publicConditions = [ const publicConditions = [
inArray(resources.resourceId, accessible.resourceIds), inArray(resources.resourceId, accessible.resourceIds),
@@ -50,7 +50,7 @@ export const handleApplyBlueprintMessage: MessageHandler = async (context) => {
source: "NEWT" source: "NEWT"
}); });
} catch (error) { } catch (error) {
logger.error(`Failed to update database from config: ${error}`); logger.debug(`Failed to update database from config: ${error}`);
return { return {
message: { message: {
type: "newt/blueprint/results", type: "newt/blueprint/results",
@@ -1,7 +1,12 @@
import { Request, Response, NextFunction } from "express"; import { Request, Response, NextFunction } from "express";
import { z } from "zod"; import { z } from "zod";
import { db } from "@server/db"; import { db } from "@server/db";
import { resources, resourceWhitelist } from "@server/db"; import {
resources,
resourceWhitelist,
resourcePolicies,
resourcePolicyWhiteList
} from "@server/db";
import response from "@server/lib/response"; import response from "@server/lib/response";
import HttpCode from "@server/types/HttpCode"; import HttpCode from "@server/types/HttpCode";
import createHttpError from "http-errors"; import createHttpError from "http-errors";
@@ -103,40 +108,99 @@ export async function addEmailToResourceWhitelist(
); );
} }
if (!resource.emailWhitelistEnabled) { // A shared policy takes precedence over the resource's inline
return next( // (default) policy, which takes precedence over the resource's own
createHttpError( // direct whitelist fields. This mirrors the precedence used at
HttpCode.BAD_REQUEST, // request time in authWithWhitelist.ts / getResourceAuthInfo.ts.
"Email whitelist is not enabled for this resource" const policyId =
) resource.resourcePolicyId ?? resource.defaultResourcePolicyId;
);
if (policyId !== null) {
const [policy] = await db
.select()
.from(resourcePolicies)
.where(eq(resourcePolicies.resourcePolicyId, policyId));
if (!policy) {
return next(
createHttpError(
HttpCode.NOT_FOUND,
"Resource policy not found"
)
);
}
if (!policy.emailWhitelistEnabled) {
return next(
createHttpError(
HttpCode.BAD_REQUEST,
"Email whitelist is not enabled for this resource"
)
);
}
const existingEntry = await db
.select()
.from(resourcePolicyWhiteList)
.where(
and(
eq(
resourcePolicyWhiteList.resourcePolicyId,
policyId
),
eq(resourcePolicyWhiteList.email, email)
)
);
if (existingEntry.length > 0) {
return next(
createHttpError(
HttpCode.CONFLICT,
"Email already exists in whitelist"
)
);
}
await db.insert(resourcePolicyWhiteList).values({
email,
resourcePolicyId: policyId
});
} else {
if (!resource.emailWhitelistEnabled) {
return next(
createHttpError(
HttpCode.BAD_REQUEST,
"Email whitelist is not enabled for this resource"
)
);
}
// Check if email already exists in whitelist
const existingEntry = await db
.select()
.from(resourceWhitelist)
.where(
and(
eq(resourceWhitelist.resourceId, resourceId),
eq(resourceWhitelist.email, email)
)
);
if (existingEntry.length > 0) {
return next(
createHttpError(
HttpCode.CONFLICT,
"Email already exists in whitelist"
)
);
}
await db.insert(resourceWhitelist).values({
email,
resourceId
});
} }
// Check if email already exists in whitelist
const existingEntry = await db
.select()
.from(resourceWhitelist)
.where(
and(
eq(resourceWhitelist.resourceId, resourceId),
eq(resourceWhitelist.email, email)
)
);
if (existingEntry.length > 0) {
return next(
createHttpError(
HttpCode.CONFLICT,
"Email already exists in whitelist"
)
);
}
await db.insert(resourceWhitelist).values({
email,
resourceId
});
return response(res, { return response(res, {
data: {}, data: {},
success: true, success: true,
@@ -96,13 +96,17 @@ export async function getResourceWhitelist(
); );
} }
const isInlinePolicy = // A shared policy takes precedence over the resource's inline
resource.resourcePolicyId === null && // (default) policy, which takes precedence over the resource's own
resource.defaultResourcePolicyId !== null; // direct whitelist fields. This mirrors the precedence used at
// request time in authWithWhitelist.ts / getResourceAuthInfo.ts.
const policyId =
resource.resourcePolicyId ?? resource.defaultResourcePolicyId;
const whitelist = isInlinePolicy const whitelist =
? await queryPolicyWhitelist(resource.defaultResourcePolicyId!) policyId !== null
: await queryWhitelist(resourceId); ? await queryPolicyWhitelist(policyId)
: await queryWhitelist(resourceId);
return response<GetResourceWhitelistResponse>(res, { return response<GetResourceWhitelistResponse>(res, {
data: { data: {
+38 -47
View File
@@ -363,11 +363,6 @@ export async function getUserResources(
(r) => r.siteResourceId (r) => r.siteResourceId
); );
const isLabelFeatureEnabled = await isLicensedOrSubscribed(
orgId,
tierMatrix.labels
);
let labelsForResources: Array<{ let labelsForResources: Array<{
labelId: number; labelId: number;
name: string; name: string;
@@ -381,49 +376,45 @@ export async function getUserResources(
siteResourceId: number; siteResourceId: number;
}> = []; }> = [];
if (isLabelFeatureEnabled) { [labelsForResources, labelsForSiteResources] = await Promise.all([
[labelsForResources, labelsForSiteResources] = await Promise.all([ resourceIdList.length === 0
resourceIdList.length === 0 ? Promise.resolve([])
? Promise.resolve([]) : db
: db .select({
.select({ labelId: labels.labelId,
labelId: labels.labelId, name: labels.name,
name: labels.name, color: labels.color,
color: labels.color, resourceId: resourceLabels.resourceId
resourceId: resourceLabels.resourceId })
}) .from(labels)
.from(labels) .innerJoin(
.innerJoin( resourceLabels,
resourceLabels, eq(resourceLabels.labelId, labels.labelId)
eq(resourceLabels.labelId, labels.labelId) )
.where(inArray(resourceLabels.resourceId, resourceIdList))
.orderBy(asc(resourceLabels.resourceLabelId)),
siteResourceIdList.length === 0
? Promise.resolve([])
: db
.select({
labelId: labels.labelId,
name: labels.name,
color: labels.color,
siteResourceId: siteResourceLabels.siteResourceId
})
.from(labels)
.innerJoin(
siteResourceLabels,
eq(siteResourceLabels.labelId, labels.labelId)
)
.where(
inArray(
siteResourceLabels.siteResourceId,
siteResourceIdList
) )
.where( )
inArray(resourceLabels.resourceId, resourceIdList) .orderBy(asc(siteResourceLabels.siteResourceLabelId))
) ]);
.orderBy(asc(resourceLabels.resourceLabelId)),
siteResourceIdList.length === 0
? Promise.resolve([])
: db
.select({
labelId: labels.labelId,
name: labels.name,
color: labels.color,
siteResourceId: siteResourceLabels.siteResourceId
})
.from(labels)
.innerJoin(
siteResourceLabels,
eq(siteResourceLabels.labelId, labels.labelId)
)
.where(
inArray(
siteResourceLabels.siteResourceId,
siteResourceIdList
)
)
.orderBy(asc(siteResourceLabels.siteResourceLabelId))
]);
}
// Check for password, pincode, and whitelist protection for each resource // Check for password, pincode, and whitelist protection for each resource
const resourcesWithAuth = await Promise.all( const resourcesWithAuth = await Promise.all(
+30 -44
View File
@@ -484,11 +484,6 @@ export async function listResources(
); );
} }
const isLabelFeatureEnabled = await isLicensedOrSubscribed(
orgId,
tierMatrix.labels
);
let accessibleResources: Array<{ resourceId: number }>; let accessibleResources: Array<{ resourceId: number }>;
if (req.user) { if (req.user) {
accessibleResources = await db accessibleResources = await db
@@ -676,7 +671,7 @@ export async function listResources(
); );
} }
if (isLabelFeatureEnabled && labelFilter && labelFilter.length > 0) { if (labelFilter && labelFilter.length > 0) {
conditions.push( conditions.push(
inArray( inArray(
resources.resourceId, resources.resourceId,
@@ -697,25 +692,20 @@ export async function listResources(
const queryList = [ const queryList = [
like(sql`LOWER(${resources.name})`, q), like(sql`LOWER(${resources.name})`, q),
like(sql`LOWER(${resources.niceId})`, q), like(sql`LOWER(${resources.niceId})`, q),
like(sql`LOWER(${resources.fullDomain})`, q) like(sql`LOWER(${resources.fullDomain})`, q),
inArray(
resources.resourceId,
db
.select({ id: resourceLabels.resourceId })
.from(resourceLabels)
.innerJoin(
labels,
eq(labels.labelId, resourceLabels.labelId)
)
.where(like(sql`LOWER(${labels.name})`, q))
)
]; ];
if (isLabelFeatureEnabled) {
queryList.push(
inArray(
resources.resourceId,
db
.select({ id: resourceLabels.resourceId })
.from(resourceLabels)
.innerJoin(
labels,
eq(labels.labelId, resourceLabels.labelId)
)
.where(like(sql`LOWER(${labels.name})`, q))
)
);
}
conditions.push(or(...queryList)); conditions.push(or(...queryList));
} }
@@ -747,27 +737,23 @@ export async function listResources(
resourceId: number; resourceId: number;
}> = []; }> = [];
if (isLabelFeatureEnabled) { labelsForResources =
labelsForResources = resourceIdList.length === 0
resourceIdList.length === 0 ? []
? [] : await db
: await db .select({
.select({ labelId: labels.labelId,
labelId: labels.labelId, name: labels.name,
name: labels.name, color: labels.color,
color: labels.color, resourceId: resourceLabels.resourceId
resourceId: resourceLabels.resourceId })
}) .from(labels)
.from(labels) .innerJoin(
.innerJoin( resourceLabels,
resourceLabels, eq(resourceLabels.labelId, labels.labelId)
eq(resourceLabels.labelId, labels.labelId) )
) .where(inArray(resourceLabels.resourceId, resourceIdList))
.where( .orderBy(asc(resourceLabels.resourceLabelId));
inArray(resourceLabels.resourceId, resourceIdList)
)
.orderBy(asc(resourceLabels.resourceLabelId));
}
const allResourceTargets = const allResourceTargets =
resourceIdList.length === 0 resourceIdList.length === 0
@@ -248,11 +248,6 @@ export async function listUserResourceAliases(
}); });
} }
const isLabelFeatureEnabled = await isLicensedOrSubscribed(
orgId,
tierMatrix.labels
);
const whereConditions = [ const whereConditions = [
eq(siteResources.orgId, orgId), eq(siteResources.orgId, orgId),
eq(siteResources.enabled, true), eq(siteResources.enabled, true),
@@ -262,7 +257,7 @@ export async function listUserResourceAliases(
inArray(siteResources.siteResourceId, accessibleSiteResourceIds) inArray(siteResources.siteResourceId, accessibleSiteResourceIds)
]; ];
if (isLabelFeatureEnabled && labelFilter && labelFilter.length > 0) { if (labelFilter && labelFilter.length > 0) {
whereConditions.push( whereConditions.push(
inArray( inArray(
siteResources.siteResourceId, siteResources.siteResourceId,
@@ -310,7 +305,7 @@ export async function listUserResourceAliases(
siteResourceId: number; siteResourceId: number;
}> = []; }> = [];
if (isLabelFeatureEnabled && siteResourceIdList.length > 0) { if (siteResourceIdList.length > 0) {
labelsForSiteResources = await db labelsForSiteResources = await db
.select({ .select({
name: labels.name, name: labels.name,
@@ -1,7 +1,12 @@
import { Request, Response, NextFunction } from "express"; import { Request, Response, NextFunction } from "express";
import { z } from "zod"; import { z } from "zod";
import { db } from "@server/db"; import { db } from "@server/db";
import { resources, resourceWhitelist } from "@server/db"; import {
resources,
resourceWhitelist,
resourcePolicies,
resourcePolicyWhiteList
} from "@server/db";
import response from "@server/lib/response"; import response from "@server/lib/response";
import HttpCode from "@server/types/HttpCode"; import HttpCode from "@server/types/HttpCode";
import createHttpError from "http-errors"; import createHttpError from "http-errors";
@@ -102,44 +107,110 @@ export async function removeEmailFromResourceWhitelist(
); );
} }
if (!resource.emailWhitelistEnabled) { // A shared policy takes precedence over the resource's inline
return next( // (default) policy, which takes precedence over the resource's own
createHttpError( // direct whitelist fields. This mirrors the precedence used at
HttpCode.BAD_REQUEST, // request time in authWithWhitelist.ts / getResourceAuthInfo.ts.
"Email whitelist is not enabled for this resource" const policyId =
) resource.resourcePolicyId ?? resource.defaultResourcePolicyId;
);
if (policyId !== null) {
const [policy] = await db
.select()
.from(resourcePolicies)
.where(eq(resourcePolicies.resourcePolicyId, policyId));
if (!policy) {
return next(
createHttpError(
HttpCode.NOT_FOUND,
"Resource policy not found"
)
);
}
if (!policy.emailWhitelistEnabled) {
return next(
createHttpError(
HttpCode.BAD_REQUEST,
"Email whitelist is not enabled for this resource"
)
);
}
const existingEntry = await db
.select()
.from(resourcePolicyWhiteList)
.where(
and(
eq(
resourcePolicyWhiteList.resourcePolicyId,
policyId
),
eq(resourcePolicyWhiteList.email, email)
)
);
if (existingEntry.length === 0) {
return next(
createHttpError(
HttpCode.NOT_FOUND,
"Email not found in whitelist"
)
);
}
await db
.delete(resourcePolicyWhiteList)
.where(
and(
eq(
resourcePolicyWhiteList.resourcePolicyId,
policyId
),
eq(resourcePolicyWhiteList.email, email)
)
);
} else {
if (!resource.emailWhitelistEnabled) {
return next(
createHttpError(
HttpCode.BAD_REQUEST,
"Email whitelist is not enabled for this resource"
)
);
}
// Check if email exists in whitelist
const existingEntry = await db
.select()
.from(resourceWhitelist)
.where(
and(
eq(resourceWhitelist.resourceId, resourceId),
eq(resourceWhitelist.email, email)
)
);
if (existingEntry.length === 0) {
return next(
createHttpError(
HttpCode.NOT_FOUND,
"Email not found in whitelist"
)
);
}
await db
.delete(resourceWhitelist)
.where(
and(
eq(resourceWhitelist.resourceId, resourceId),
eq(resourceWhitelist.email, email)
)
);
} }
// Check if email exists in whitelist
const existingEntry = await db
.select()
.from(resourceWhitelist)
.where(
and(
eq(resourceWhitelist.resourceId, resourceId),
eq(resourceWhitelist.email, email)
)
);
if (existingEntry.length === 0) {
return next(
createHttpError(
HttpCode.NOT_FOUND,
"Email not found in whitelist"
)
);
}
await db
.delete(resourceWhitelist)
.where(
and(
eq(resourceWhitelist.resourceId, resourceId),
eq(resourceWhitelist.email, email)
)
);
return response(res, { return response(res, {
data: {}, data: {},
success: true, success: true,
@@ -109,13 +109,14 @@ export async function setResourceWhitelist(
); );
} }
const isInlinePolicy = // A shared policy takes precedence over the resource's inline
resource.resourcePolicyId === null && // (default) policy, which takes precedence over the resource's own
resource.defaultResourcePolicyId !== null; // direct whitelist fields. This mirrors the precedence used at
// request time in authWithWhitelist.ts / getResourceAuthInfo.ts.
if (isInlinePolicy) { const policyId =
const policyId = resource.defaultResourcePolicyId!; resource.resourcePolicyId ?? resource.defaultResourcePolicyId;
if (policyId !== null) {
const [policy] = await db const [policy] = await db
.select() .select()
.from(resourcePolicies) .from(resourcePolicies)
+30 -41
View File
@@ -235,11 +235,6 @@ export async function listSites(
); );
} }
const isLabelFeatureEnabled = await isLicensedOrSubscribed(
orgId,
tierMatrix.labels
);
const { const {
pageSize, pageSize,
page, page,
@@ -291,7 +286,7 @@ export async function listSites(
conditions.push(eq(sites.status, status)); conditions.push(eq(sites.status, status));
} }
if (isLabelFeatureEnabled && labelFilter && labelFilter.length > 0) { if (labelFilter && labelFilter.length > 0) {
conditions.push( conditions.push(
inArray( inArray(
sites.siteId, sites.siteId,
@@ -311,24 +306,20 @@ export async function listSites(
const q = "%" + query.toLowerCase() + "%"; const q = "%" + query.toLowerCase() + "%";
const queryList = [ const queryList = [
like(sql`LOWER(${sites.name})`, q), like(sql`LOWER(${sites.name})`, q),
like(sql`LOWER(${sites.niceId})`, q) like(sql`LOWER(${sites.niceId})`, q),
inArray(
sites.siteId,
db
.select({ id: siteLabels.siteId })
.from(siteLabels)
.innerJoin(
labels,
eq(labels.labelId, siteLabels.labelId)
)
.where(like(sql`LOWER(${labels.name})`, q))
)
]; ];
if (isLabelFeatureEnabled) {
queryList.push(
inArray(
sites.siteId,
db
.select({ id: siteLabels.siteId })
.from(siteLabels)
.innerJoin(
labels,
eq(labels.labelId, siteLabels.labelId)
)
.where(like(sql`LOWER(${labels.name})`, q))
)
);
}
conditions.push(or(...queryList)!); conditions.push(or(...queryList)!);
} }
@@ -366,25 +357,23 @@ export async function listSites(
siteId: number; siteId: number;
}> = []; }> = [];
if (isLabelFeatureEnabled) { labelsForSites =
labelsForSites = siteIds.length === 0
siteIds.length === 0 ? []
? [] : await db
: await db .select({
.select({ labelId: labels.labelId,
labelId: labels.labelId, name: labels.name,
name: labels.name, color: labels.color,
color: labels.color, siteId: siteLabels.siteId
siteId: siteLabels.siteId })
}) .from(labels)
.from(labels) .innerJoin(
.innerJoin( siteLabels,
siteLabels, eq(siteLabels.labelId, labels.labelId)
eq(siteLabels.labelId, labels.labelId) )
) .where(inArray(siteLabels.siteId, siteIds))
.where(inArray(siteLabels.siteId, siteIds)) .orderBy(asc(siteLabels.siteLabelId));
.orderBy(asc(siteLabels.siteLabelId));
}
const sitesWithUpdates: SiteWithUpdateAvailable[] = rows.map((site) => { const sitesWithUpdates: SiteWithUpdateAvailable[] = rows.map((site) => {
const siteWithUpdate: SiteWithUpdateAvailable = { ...site }; const siteWithUpdate: SiteWithUpdateAvailable = { ...site };
@@ -286,11 +286,6 @@ export async function listAllSiteResourcesByOrg(
labels: labelFilter labels: labelFilter
} = parsedQuery.data; } = parsedQuery.data;
const isLabelFeatureEnabled = await isLicensedOrSubscribed(
orgId,
tierMatrix.labels
);
const conditions = [and(eq(siteResources.orgId, orgId))]; const conditions = [and(eq(siteResources.orgId, orgId))];
if (siteId != null) { if (siteId != null) {
@@ -320,7 +315,7 @@ export async function listAllSiteResourcesByOrg(
conditions.push(eq(siteResources.mode, mode)); conditions.push(eq(siteResources.mode, mode));
} }
if (isLabelFeatureEnabled && labelFilter && labelFilter.length > 0) { if (labelFilter && labelFilter.length > 0) {
conditions.push( conditions.push(
inArray( inArray(
siteResources.siteResourceId, siteResources.siteResourceId,
@@ -344,25 +339,20 @@ export async function listAllSiteResourcesByOrg(
like(sql`LOWER(${siteResources.destination})`, q), like(sql`LOWER(${siteResources.destination})`, q),
like(sql`LOWER(${siteResources.alias})`, q), like(sql`LOWER(${siteResources.alias})`, q),
like(sql`LOWER(${siteResources.aliasAddress})`, q), like(sql`LOWER(${siteResources.aliasAddress})`, q),
like(sql`LOWER(${sites.name})`, q) like(sql`LOWER(${sites.name})`, q),
inArray(
siteResources.siteResourceId,
db
.select({ id: siteResourceLabels.siteResourceId })
.from(siteResourceLabels)
.innerJoin(
labels,
eq(labels.labelId, siteResourceLabels.labelId)
)
.where(like(sql`LOWER(${labels.name})`, q))
)
]; ];
if (isLabelFeatureEnabled) {
queryList.push(
inArray(
siteResources.siteResourceId,
db
.select({ id: siteResourceLabels.siteResourceId })
.from(siteResourceLabels)
.innerJoin(
labels,
eq(labels.labelId, siteResourceLabels.labelId)
)
.where(like(sql`LOWER(${labels.name})`, q))
)
);
}
conditions.push(or(...queryList)); conditions.push(or(...queryList));
} }
@@ -403,7 +393,7 @@ export async function listAllSiteResourcesByOrg(
siteResourceId: number; siteResourceId: number;
}> = []; }> = [];
if (isLabelFeatureEnabled && siteResourceIdList.length > 0) { if (siteResourceIdList.length > 0) {
labelsForSiteResources = await db labelsForSiteResources = await db
.select({ .select({
labelId: labels.labelId, labelId: labels.labelId,
+125 -91
View File
@@ -68,9 +68,9 @@ const updateSiteResourceSchema = z
"Fully qualified domain name with optional wildcards, e.g., example.internal, *.example.internal, or host-0?.example.internal", "Fully qualified domain name with optional wildcards, e.g., example.internal, *.example.internal, or host-0?.example.internal",
example: "service.example.internal" example: "service.example.internal"
}), }),
userIds: z.array(z.string()), userIds: z.array(z.string()).optional(),
roleIds: z.array(z.int()), roleIds: z.array(z.int()).optional(),
clientIds: z.array(z.int()), clientIds: z.array(z.int()).optional(),
tcpPortRangeString: portRangeStringSchema, tcpPortRangeString: portRangeStringSchema,
udpPortRangeString: portRangeStringSchema, udpPortRangeString: portRangeStringSchema,
disableIcmp: z.boolean().optional(), disableIcmp: z.boolean().optional(),
@@ -167,6 +167,10 @@ const updateSiteResourceSchema = z
) )
.refine( .refine(
(data) => { (data) => {
// if neither is provided, the existing site associations are left unchanged
if (data.siteIds === undefined && data.siteId === undefined) {
return true;
}
return ( return (
(data.siteIds !== undefined && data.siteIds.length > 0) || (data.siteIds !== undefined && data.siteIds.length > 0) ||
data.siteId !== undefined data.siteId !== undefined
@@ -263,7 +267,7 @@ export async function updateSiteResource(
const { siteResourceId } = parsedParams.data; const { siteResourceId } = parsedParams.data;
const { const {
name, name,
siteIds: siteIdsInput = [], // because it can change siteIds: siteIdsInput,
siteId, siteId,
niceId, niceId,
mode, mode,
@@ -287,9 +291,14 @@ export async function updateSiteResource(
} = parsedBody.data; } = parsedBody.data;
// Backward compatibility: merge deprecated siteId into siteIds array // Backward compatibility: merge deprecated siteId into siteIds array
const siteIds = [...siteIdsInput]; const siteIdsProvided =
if (siteId !== undefined && !siteIds.includes(siteId)) { siteIdsInput !== undefined || siteId !== undefined;
siteIds.push(siteId); let siteIds: number[] | undefined;
if (siteIdsProvided) {
siteIds = [...(siteIdsInput ?? [])];
if (siteId !== undefined && !siteIds.includes(siteId)) {
siteIds.push(siteId);
}
} }
// Check if site resource exists // Check if site resource exists
@@ -356,20 +365,22 @@ export async function updateSiteResource(
} }
// Verify the site exists and belongs to the org // Verify the site exists and belongs to the org
const sitesToAssign = await db if (siteIds !== undefined) {
.select() const sitesToAssign = await db
.from(sites) .select()
.where( .from(sites)
and( .where(
inArray(sites.siteId, siteIds), and(
eq(sites.orgId, existingSiteResource.orgId) inArray(sites.siteId, siteIds),
) eq(sites.orgId, existingSiteResource.orgId)
); )
);
if (sitesToAssign.length !== siteIds.length) { if (sitesToAssign.length !== siteIds.length) {
return next( return next(
createHttpError(HttpCode.NOT_FOUND, "Some site not found") createHttpError(HttpCode.NOT_FOUND, "Some site not found")
); );
}
} }
// Only check if destination is an IP address // Only check if destination is an IP address
@@ -463,7 +474,8 @@ export async function updateSiteResource(
} }
let updatedSiteResource: SiteResource | undefined; let updatedSiteResource: SiteResource | undefined;
let updatedSiteIds: number[] = []; // defaults to the existing sites; only overwritten below if siteIds/siteId was provided
let updatedSiteIds: number[] = [...existingSiteIds];
await db.transaction(async (trx) => { await db.transaction(async (trx) => {
// Update the site resource // Update the site resource
const sshPamSet = const sshPamSet =
@@ -522,81 +534,103 @@ export async function updateSiteResource(
//////////////////// update the associations //////////////////// //////////////////// update the associations ////////////////////
// delete the site - site resources associations if (siteIds !== undefined) {
await trx // delete the site - site resources associations
.delete(siteNetworks)
.where(
eq(siteNetworks.networkId, updatedSiteResource.networkId!)
);
for (const siteId of siteIds) {
await trx.insert(siteNetworks).values({
siteId: siteId,
networkId: updatedSiteResource.networkId!
});
updatedSiteIds.push(siteId);
}
await trx
.delete(clientSiteResources)
.where(eq(clientSiteResources.siteResourceId, siteResourceId));
if (clientIds.length > 0) {
await trx.insert(clientSiteResources).values(
clientIds.map((clientId) => ({
clientId,
siteResourceId
}))
);
}
await trx
.delete(userSiteResources)
.where(eq(userSiteResources.siteResourceId, siteResourceId));
if (userIds.length > 0) {
await trx.insert(userSiteResources).values(
userIds.map((userId) => ({
userId,
siteResourceId
}))
);
}
// Get all admin role IDs for this org to exclude from deletion
const adminRoles = await trx
.select()
.from(roles)
.where(
and(
eq(roles.isAdmin, true),
eq(roles.orgId, updatedSiteResource.orgId)
)
);
const adminRoleIds = adminRoles.map((role) => role.roleId);
if (adminRoleIds.length > 0) {
await trx.delete(roleSiteResources).where(
and(
eq(roleSiteResources.siteResourceId, siteResourceId),
ne(roleSiteResources.roleId, adminRoleIds[0]) // delete all but the admin role
)
);
} else {
await trx await trx
.delete(roleSiteResources) .delete(siteNetworks)
.where( .where(
eq(roleSiteResources.siteResourceId, siteResourceId) eq(
siteNetworks.networkId,
updatedSiteResource.networkId!
)
); );
updatedSiteIds = [];
for (const siteId of siteIds) {
await trx.insert(siteNetworks).values({
siteId: siteId,
networkId: updatedSiteResource.networkId!
});
updatedSiteIds.push(siteId);
}
} }
if (roleIds.length > 0) { if (clientIds !== undefined) {
await trx.insert(roleSiteResources).values( await trx
roleIds.map((roleId) => ({ .delete(clientSiteResources)
roleId, .where(
siteResourceId eq(clientSiteResources.siteResourceId, siteResourceId)
})) );
);
if (clientIds.length > 0) {
await trx.insert(clientSiteResources).values(
clientIds.map((clientId) => ({
clientId,
siteResourceId
}))
);
}
}
if (userIds !== undefined) {
await trx
.delete(userSiteResources)
.where(
eq(userSiteResources.siteResourceId, siteResourceId)
);
if (userIds.length > 0) {
await trx.insert(userSiteResources).values(
userIds.map((userId) => ({
userId,
siteResourceId
}))
);
}
}
if (roleIds !== undefined) {
// Get all admin role IDs for this org to exclude from deletion
const adminRoles = await trx
.select()
.from(roles)
.where(
and(
eq(roles.isAdmin, true),
eq(roles.orgId, updatedSiteResource.orgId)
)
);
const adminRoleIds = adminRoles.map((role) => role.roleId);
if (adminRoleIds.length > 0) {
await trx.delete(roleSiteResources).where(
and(
eq(
roleSiteResources.siteResourceId,
siteResourceId
),
ne(roleSiteResources.roleId, adminRoleIds[0]) // delete all but the admin role
)
);
} else {
await trx
.delete(roleSiteResources)
.where(
eq(
roleSiteResources.siteResourceId,
siteResourceId
)
);
}
if (roleIds.length > 0) {
await trx.insert(roleSiteResources).values(
roleIds.map((roleId) => ({
roleId,
siteResourceId
}))
);
}
} }
logger.info(`Updated site resource ${siteResourceId}`); logger.info(`Updated site resource ${siteResourceId}`);
@@ -3,9 +3,7 @@ import { authCookieHeader } from "@app/lib/api/cookies";
import { ListOrgLabelsResponse } from "@server/routers/labels/types"; import { ListOrgLabelsResponse } from "@server/routers/labels/types";
import { AxiosResponse } from "axios"; import { AxiosResponse } from "axios";
import OrgLabelsTable from "@app/components/OrgLabelsTable"; import OrgLabelsTable from "@app/components/OrgLabelsTable";
import { PaidFeaturesAlert } from "@app/components/PaidFeaturesAlert";
import SettingsSectionTitle from "@app/components/SettingsSectionTitle"; import SettingsSectionTitle from "@app/components/SettingsSectionTitle";
import { tierMatrix } from "@server/lib/billing/tierMatrix";
import type { Metadata } from "next"; import type { Metadata } from "next";
import { getTranslations } from "next-intl/server"; import { getTranslations } from "next-intl/server";
@@ -51,8 +49,6 @@ export default async function LabelsPage({ params, searchParams }: Props) {
description={t("orgLabelsDescription")} description={t("orgLabelsDescription")}
/> />
<PaidFeaturesAlert tiers={tierMatrix.labels} />
<OrgLabelsTable <OrgLabelsTable
labels={labels} labels={labels}
orgId={orgId} orgId={orgId}
+5 -9
View File
@@ -103,7 +103,6 @@ export default function MachineClientsTable({
const [isNavigatingToAddPage, startNavigation] = useTransition(); const [isNavigatingToAddPage, startNavigation] = useTransition();
const { isPaidUser } = usePaidStatus(); const { isPaidUser } = usePaidStatus();
const isLabelFeatureEnabled = isPaidUser(tierMatrix.labels);
const data = useQuery(productUpdatesQueries.latestVersion(true)); const data = useQuery(productUpdatesQueries.latestVersion(true));
const latestPlatformVersions = data.data?.data; const latestPlatformVersions = data.data?.data;
@@ -434,11 +433,8 @@ export default function MachineClientsTable({
accessorKey: "subnet", accessorKey: "subnet",
friendlyName: t("address"), friendlyName: t("address"),
header: () => <span className="px-3">{t("address")}</span> header: () => <span className="px-3">{t("address")}</span>
} },
]; {
if (isLabelFeatureEnabled) {
baseColumns.push({
id: "labels", id: "labels",
accessorKey: "labels", accessorKey: "labels",
header: () => ( header: () => (
@@ -458,8 +454,8 @@ export default function MachineClientsTable({
orgId={orgId} orgId={orgId}
/> />
) )
}); }
} ];
// Only include actions column if there are rows without userIds // Only include actions column if there are rows without userIds
if (hasRowsWithoutUserId) { if (hasRowsWithoutUserId) {
@@ -541,7 +537,7 @@ export default function MachineClientsTable({
} }
return baseColumns; return baseColumns;
}, [hasRowsWithoutUserId, isLabelFeatureEnabled, orgId, t, searchParams]); }, [hasRowsWithoutUserId, orgId, t, searchParams]);
function handleFilterChange( function handleFilterChange(
column: string, column: string,
-3
View File
@@ -21,9 +21,6 @@ import {
ControlledDataTable, ControlledDataTable,
type ExtendedColumnDef type ExtendedColumnDef
} from "./ui/controlled-data-table"; } from "./ui/controlled-data-table";
import { LabelBadge } from "./label-badge";
import { getNextSortOrder, getSortDirection } from "@app/lib/sortColumn";
import { cn } from "@app/lib/cn";
import ConfirmDeleteDialog from "./ConfirmDeleteDialog"; import ConfirmDeleteDialog from "./ConfirmDeleteDialog";
import { CreateOrgLabelDialog } from "./CreateOrgLabelDialog"; import { CreateOrgLabelDialog } from "./CreateOrgLabelDialog";
import { EditOrgLabelDialog } from "./EditOrgLabelDialog"; import { EditOrgLabelDialog } from "./EditOrgLabelDialog";
+5 -9
View File
@@ -150,7 +150,6 @@ export default function PrivateResourcesTable({
const [isRefreshing, startRefreshTransition] = useTransition(); const [isRefreshing, startRefreshTransition] = useTransition();
const { isPaidUser } = usePaidStatus(); const { isPaidUser } = usePaidStatus();
const isLabelFeatureEnabled = isPaidUser(tierMatrix.labels);
// useEffect(() => { // useEffect(() => {
// const interval = setInterval(() => { // const interval = setInterval(() => {
@@ -488,11 +487,8 @@ export default function PrivateResourcesTable({
</div> </div>
); );
} }
} },
]; {
if (isLabelFeatureEnabled) {
cols.splice(cols.length - 1, 0, {
id: "labels", id: "labels",
accessorKey: "labels", accessorKey: "labels",
header: () => ( header: () => (
@@ -512,11 +508,11 @@ export default function PrivateResourcesTable({
orgId={orgId} orgId={orgId}
/> />
) )
}); }
} ];
return cols; return cols;
}, [isLabelFeatureEnabled, orgId, t, searchParams]); }, [orgId, t, searchParams]);
function handleFilterChange( function handleFilterChange(
column: string, column: string,
+5 -9
View File
@@ -151,7 +151,6 @@ export default function PublicResourcesTable({
useState<ResourceRow | null>(); useState<ResourceRow | null>();
const { isPaidUser } = usePaidStatus(); const { isPaidUser } = usePaidStatus();
const isLabelFeatureEnabled = isPaidUser(tierMatrix.labels);
const [isRefreshing, startTransition] = useTransition(); const [isRefreshing, startTransition] = useTransition();
const [isNavigatingToAddPage, startNavigation] = useTransition(); const [isNavigatingToAddPage, startNavigation] = useTransition();
@@ -604,11 +603,8 @@ export default function PublicResourcesTable({
</div> </div>
); );
} }
} },
]; {
if (isLabelFeatureEnabled) {
cols.splice(cols.length - 1, 0, {
id: "labels", id: "labels",
accessorKey: "labels", accessorKey: "labels",
header: () => ( header: () => (
@@ -625,11 +621,11 @@ export default function PublicResourcesTable({
cell: ({ row }: { row: { original: ResourceRow } }) => ( cell: ({ row }: { row: { original: ResourceRow } }) => (
<ResourceLabelCell resource={row.original} orgId={orgId} /> <ResourceLabelCell resource={row.original} orgId={orgId} />
) )
}); }
} ];
return cols; return cols;
}, [isLabelFeatureEnabled, orgId, t, searchParams]); }, [orgId, t, searchParams]);
function handleFilterChange( function handleFilterChange(
column: string, column: string,
+9 -11
View File
@@ -53,7 +53,6 @@ import {
import { useOptimisticLabels } from "@app/hooks/useOptimisticLabels"; import { useOptimisticLabels } from "@app/hooks/useOptimisticLabels";
import { usePaidStatus } from "@app/hooks/usePaidStatus"; import { usePaidStatus } from "@app/hooks/usePaidStatus";
import { tierMatrix } from "@server/lib/billing/tierMatrix";
import { LabelColumnFilterButton } from "./LabelColumnFilterButton"; import { LabelColumnFilterButton } from "./LabelColumnFilterButton";
import { LabelsTableCell } from "./LabelsTableCell"; import { LabelsTableCell } from "./LabelsTableCell";
import { useQuery } from "@tanstack/react-query"; import { useQuery } from "@tanstack/react-query";
@@ -114,7 +113,6 @@ export default function SitesTable({
const [isNavigatingToAddPage, startNavigation] = useTransition(); const [isNavigatingToAddPage, startNavigation] = useTransition();
const { isPaidUser } = usePaidStatus(); const { isPaidUser } = usePaidStatus();
const isLabelFeatureEnabled = isPaidUser(tierMatrix.labels);
const api = createApiClient(useEnvContext()); const api = createApiClient(useEnvContext());
const t = useTranslations(); const t = useTranslations();
@@ -171,7 +169,10 @@ export default function SitesTable({
toast({ toast({
variant: "destructive", variant: "destructive",
title: t("siteErrorRestart"), title: t("siteErrorRestart"),
description: formatAxiosError(e, t("siteErrorRestartDescription")) description: formatAxiosError(
e,
t("siteErrorRestartDescription")
)
}); });
} finally { } finally {
setRestartingSite(null); setRestartingSite(null);
@@ -598,11 +599,8 @@ export default function SitesTable({
</div> </div>
); );
} }
} },
]; {
if (isLabelFeatureEnabled) {
cols.splice(cols.length - 1, 0, {
accessorKey: "labels", accessorKey: "labels",
header: () => ( header: () => (
<LabelColumnFilterButton <LabelColumnFilterButton
@@ -618,11 +616,11 @@ export default function SitesTable({
cell: ({ row }: { row: { original: SiteRow } }) => ( cell: ({ row }: { row: { original: SiteRow } }) => (
<SiteLabelCell site={row.original} orgId={orgId} /> <SiteLabelCell site={row.original} orgId={orgId} />
) )
}); }
} ];
return cols; return cols;
}, [isLabelFeatureEnabled, orgId, t, searchParams, latestNewtVersion]); }, [orgId, t, searchParams, latestNewtVersion]);
function toggleSort(column: string) { function toggleSort(column: string) {
const newSearch = getNextSortOrder(column, searchParams); const newSearch = getNextSortOrder(column, searchParams);